summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/AndroidManifest.xml58
-rw-r--r--app/src/main/java/org/terst/listen/ListenApp.kt5
-rw-r--r--app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt283
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackService.kt39
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt34
-rw-r--r--app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt32
-rw-r--r--app/src/main/res/drawable/ic_launcher_foreground.xml21
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml5
-rw-r--r--app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml5
-rw-r--r--app/src/main/res/values-night/colors.xml4
-rw-r--r--app/src/main/res/values-night/themes.xml9
-rw-r--r--app/src/main/res/values/colors.xml5
-rw-r--r--app/src/main/res/values/strings.xml4
-rw-r--r--app/src/main/res/values/themes.xml14
-rw-r--r--app/src/test/java/org/terst/listen/feed/PodcastFeedParserTest.kt221
15 files changed, 739 insertions, 0 deletions
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..ac8bb04
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,58 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+ <uses-permission android:name="android.permission.INTERNET" />
+ <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+ <!-- Background audio requires a foreground service, and since Android 14 that service must
+ declare its type. The notification this produces is the transport control (play/pause on
+ the lock screen), not an alert: Listen posts no new-episode notifications of any kind. -->
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
+
+ <application
+ android:name=".ListenApp"
+ android:allowBackup="true"
+ android:icon="@mipmap/ic_launcher"
+ android:label="@string/app_name"
+ android:supportsRtl="true"
+ android:theme="@style/Theme.Listen">
+
+ <activity
+ android:name=".ui.MainActivity"
+ android:exported="true"
+ android:theme="@style/Theme.Listen">
+ <intent-filter>
+ <action android:name="android.intent.action.MAIN" />
+ <category android:name="android.intent.category.LAUNCHER" />
+ </intent-filter>
+ </activity>
+
+ <!-- Share-sheet target. Listen has no discovery, so this and OPML import are how
+ subscriptions get added: a URL shared from a browser or FeeDreader is resolved to
+ a feed here. -->
+ <activity
+ android:name=".ui.ShareReceiverActivity"
+ android:exported="true"
+ android:excludeFromRecents="true"
+ android:theme="@style/Theme.Listen">
+ <intent-filter>
+ <action android:name="android.intent.action.SEND" />
+ <category android:name="android.intent.category.DEFAULT" />
+ <data android:mimeType="text/plain" />
+ </intent-filter>
+ </activity>
+
+ <service
+ android:name=".playback.PlaybackService"
+ android:exported="false"
+ android:foregroundServiceType="mediaPlayback">
+ <intent-filter>
+ <action android:name="androidx.media3.session.MediaSessionService" />
+ </intent-filter>
+ </service>
+
+ </application>
+</manifest>
diff --git a/app/src/main/java/org/terst/listen/ListenApp.kt b/app/src/main/java/org/terst/listen/ListenApp.kt
new file mode 100644
index 0000000..063e824
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ListenApp.kt
@@ -0,0 +1,5 @@
+package org.terst.listen
+
+import android.app.Application
+
+class ListenApp : Application()
diff --git a/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt b/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt
new file mode 100644
index 0000000..8b22bce
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt
@@ -0,0 +1,283 @@
+package org.terst.listen.feed
+
+import org.xmlpull.v1.XmlPullParser
+import org.xmlpull.v1.XmlPullParserFactory
+import java.io.InputStream
+import java.text.SimpleDateFormat
+import java.util.Locale
+import java.util.TimeZone
+
+/** One playable item in a feed. */
+data class ParsedEpisode(
+ val guid: String,
+ val title: String?,
+ val description: String?,
+ val mediaUrl: String,
+ val mediaType: String,
+ val mediaLengthBytes: Long?,
+ val durationMs: Long?,
+ val publishedAtMs: Long?,
+ val seasonNumber: Int?,
+ val episodeNumber: Int?,
+ val imageUrl: String?,
+ val chaptersUrl: String?,
+)
+
+/** A parsed podcast feed. */
+data class ParsedFeed(
+ val title: String?,
+ val description: String?,
+ val author: String?,
+ val link: String?,
+ val imageUrl: String?,
+ val previousArchiveUrl: String?,
+ val episodes: List<ParsedEpisode>,
+)
+
+/**
+ * Parses a podcast RSS feed.
+ *
+ * Written rather than reused from FeeDreader because that parser inspects `<enclosure>` only while
+ * hunting for a thumbnail -- it has no concept of playable media, duration, or episode identity.
+ *
+ * Namespace processing stays off, so element names arrive prefixed ("itunes:duration") the way
+ * feeds actually write them. Prefixes are stripped per-element instead, because publishers bind
+ * these namespaces to whatever prefix they like.
+ */
+object PodcastFeedParser {
+
+ private const val MS_PER_SECOND = 1000L
+ private const val SECONDS_PER_MINUTE = 60L
+ private const val CLOCK_PARTS_HMS = 3
+ private const val CLOCK_PARTS_MS = 2
+
+ fun parse(input: InputStream): ParsedFeed {
+ // XmlPullParserFactory rather than android.util.Xml, which returns null off-device and
+ // so cannot be unit-tested at all -- the reason FeeDreader's parser has no tests. Both
+ // resolve to the same KXmlParser on Android.
+ val parser = XmlPullParserFactory.newInstance().newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(input, null)
+
+ val channel = ChannelBuilder()
+ val episodes = mutableListOf<ParsedEpisode>()
+ var item: ItemBuilder? = null
+
+ while (parser.next() != XmlPullParser.END_DOCUMENT) {
+ val name = parser.localName()
+ when (parser.eventType) {
+ XmlPullParser.START_TAG ->
+ if (name.isItem()) item = ItemBuilder() else (item ?: channel).readTag(parser, name)
+
+ XmlPullParser.END_TAG -> if (name.isItem()) {
+ item?.build()?.let(episodes::add)
+ item = null
+ }
+ }
+ }
+
+ return channel.build(episodes)
+ }
+
+ private fun String.isItem() = this == "item" || this == "entry"
+
+ /** Accumulates fields as they are encountered; both scopes share the dispatch shape. */
+ private interface TagSink {
+ fun readTag(parser: XmlPullParser, name: String)
+ }
+
+ private class ChannelBuilder : TagSink {
+ private var title: String? = null
+ private var description: String? = null
+ private var author: String? = null
+ private var link: String? = null
+ private var imageUrl: String? = null
+ private var itunesImageUrl: String? = null
+ private var previousArchiveUrl: String? = null
+
+ override fun readTag(parser: XmlPullParser, name: String) {
+ when (name) {
+ "title" -> title = title ?: parser.text()
+ "description", "subtitle", "summary" -> description = description ?: parser.text()
+ "author" -> author = author ?: parser.text()
+ "image" -> itunesImageUrl = itunesImageUrl ?: parser.attr("href")
+ "url" -> imageUrl = imageUrl ?: parser.text()
+ "link" -> readLink(parser)
+ }
+ }
+
+ private fun readLink(parser: XmlPullParser) {
+ // RFC 5005 archive paging is how a drip subscription reaches episodes the current
+ // page no longer lists.
+ when (parser.attr("rel")) {
+ "prev-archive" -> previousArchiveUrl = previousArchiveUrl ?: parser.attr("href")
+ null -> link = link ?: (parser.attr("href") ?: parser.text())
+ }
+ }
+
+ fun build(episodes: List<ParsedEpisode>) = ParsedFeed(
+ title = title,
+ description = description,
+ author = author,
+ link = link,
+ imageUrl = itunesImageUrl ?: imageUrl,
+ previousArchiveUrl = previousArchiveUrl,
+ episodes = episodes,
+ )
+ }
+
+ /** Accumulates one item's fields; resolves identity only once the whole item has been seen. */
+ private class ItemBuilder : TagSink {
+ var title: String? = null
+ var description: String? = null
+ var guid: String? = null
+ var mediaUrl: String? = null
+ var mediaType: String? = null
+ var mediaLength: Long? = null
+ var durationMs: Long? = null
+ var publishedAtMs: Long? = null
+ var season: Int? = null
+ var episode: Int? = null
+ var imageUrl: String? = null
+ var chaptersUrl: String? = null
+
+ // Split by where the value comes from -- attributes or element text -- rather than
+ // one dispatch over every tag, which was complex enough for detekt to object to.
+ override fun readTag(parser: XmlPullParser, name: String) {
+ when (name) {
+ "enclosure" -> readEnclosure(parser)
+ "image" -> imageUrl = imageUrl ?: parser.attr("href")
+ "chapters" -> chaptersUrl = chaptersUrl ?: parser.attr("url")
+ else -> readTextTag(parser, name)
+ }
+ }
+
+ /** Tags stored as the text they carry. First value seen wins, so a duplicate can't clobber it. */
+ private fun readTextTag(parser: XmlPullParser, name: String) {
+ when (name) {
+ "title" -> title = title ?: parser.text()
+ "description", "summary" -> description = description ?: parser.text()
+ "guid", "id" -> guid = guid ?: parser.text()
+ else -> readConvertedTag(parser, name)
+ }
+ }
+
+ /** Tags whose text has to be converted before it means anything. */
+ private fun readConvertedTag(parser: XmlPullParser, name: String) {
+ when (name) {
+ "pubDate", "published" -> publishedAtMs = publishedAtMs ?: parseDate(parser.text())
+ "duration" -> durationMs = durationMs ?: parseDuration(parser.text())
+ "season" -> season = season ?: parser.text()?.toIntOrNull()
+ "episode" -> episode = episode ?: parser.text()?.toIntOrNull()
+ }
+ }
+
+ private fun readEnclosure(parser: XmlPullParser) {
+ if (mediaUrl != null) return
+ val url = parser.attr("url") ?: return
+ val type = parser.attr("type") ?: inferType(url) ?: return
+ // An item whose only attachment is cover art is not an episode. This is the exact
+ // distinction FeeDreader's parser does not make.
+ if (!isPlayable(type)) return
+ mediaUrl = url
+ mediaType = type
+ mediaLength = parser.attr("length")?.toLongOrNull()
+ }
+
+ fun build(): ParsedEpisode? {
+ val url = mediaUrl ?: return null
+ val type = mediaType ?: return null
+ return ParsedEpisode(
+ // Identity resolved last: a missing guid falls back to the enclosure URL, which
+ // is stable enough to keep a playback position attached to the right episode.
+ guid = guid?.takeIf { it.isNotBlank() } ?: url,
+ title = title,
+ description = description,
+ mediaUrl = url,
+ mediaType = type,
+ mediaLengthBytes = mediaLength,
+ durationMs = durationMs,
+ publishedAtMs = publishedAtMs,
+ seasonNumber = season,
+ episodeNumber = episode,
+ imageUrl = imageUrl,
+ chaptersUrl = chaptersUrl,
+ )
+ }
+ }
+
+ private fun isPlayable(type: String) =
+ type.startsWith("audio/", ignoreCase = true) || type.startsWith("video/", ignoreCase = true)
+
+ private fun inferType(url: String): String? = when (url.substringAfterLast('.', "").lowercase()) {
+ "mp3" -> "audio/mpeg"
+ "m4a", "m4b", "aac" -> "audio/mp4"
+ "opus" -> "audio/opus"
+ "ogg" -> "audio/ogg"
+ "flac" -> "audio/flac"
+ "wav" -> "audio/wav"
+ "mp4", "m4v" -> "video/mp4"
+ else -> null
+ }
+
+ /**
+ * iTunes durations come as either a plain second count or a clock value. Returns null rather
+ * than zero when absent, so "unknown length" stays distinguishable from "zero length".
+ */
+ internal fun parseDuration(text: String?): Long? {
+ val raw = text?.trim().orEmpty()
+ if (raw.isEmpty()) return null
+ if (!raw.contains(':')) return raw.toLongOrNull()?.times(MS_PER_SECOND)
+
+ val parts = raw.split(':').map { it.trim().toLongOrNull() ?: return null }
+ val seconds = when (parts.size) {
+ CLOCK_PARTS_HMS ->
+ parts[0] * SECONDS_PER_MINUTE * SECONDS_PER_MINUTE + parts[1] * SECONDS_PER_MINUTE + parts[2]
+ CLOCK_PARTS_MS -> parts[0] * SECONDS_PER_MINUTE + parts[1]
+ else -> return null
+ }
+ return seconds * MS_PER_SECOND
+ }
+
+ private val DATE_PATTERNS = listOf(
+ "EEE, dd MMM yyyy HH:mm:ss zzz",
+ "EEE, dd MMM yyyy HH:mm:ss Z",
+ "EEE, dd MMM yyyy HH:mm zzz",
+ "dd MMM yyyy HH:mm:ss zzz",
+ "yyyy-MM-dd'T'HH:mm:ssXXX",
+ "yyyy-MM-dd'T'HH:mm:ss'Z'",
+ )
+
+ /** Returns null on anything unparseable; a wrong date is worse than a missing one. */
+ internal fun parseDate(text: String?): Long? {
+ val raw = text?.trim().orEmpty()
+ if (raw.isEmpty()) return null
+ for (pattern in DATE_PATTERNS) {
+ val format = SimpleDateFormat(pattern, Locale.US)
+ format.timeZone = TimeZone.getTimeZone("UTC")
+ // Lenient on purpose. Strict mode rejects "Tue, 02 Sep 2026 06:00:00 GMT" -- an
+ // entirely ordinary RFC 822 date -- which would null out every pubDate in the app
+ // and destroy episode ordering. Leniency relaxes field values, not structure, so
+ // unstructured text still fails to match and still returns null.
+ format.isLenient = true
+ val parsed = runCatching { format.parse(raw) }.getOrNull()
+ if (parsed != null) return parsed.time
+ }
+ return null
+ }
+
+ /** Element name with any namespace prefix stripped. */
+ private fun XmlPullParser.localName() = name?.substringAfterLast(':').orEmpty()
+
+ private fun XmlPullParser.attr(attribute: String): String? {
+ for (i in 0 until attributeCount) {
+ if (getAttributeName(i).substringAfterLast(':') == attribute) {
+ return getAttributeValue(i)?.trim()?.takeIf { it.isNotEmpty() }
+ }
+ }
+ return null
+ }
+
+ private fun XmlPullParser.text(): String? =
+ runCatching { nextText() }.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
+}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackService.kt b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
new file mode 100644
index 0000000..5fb39c2
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackService.kt
@@ -0,0 +1,39 @@
+package org.terst.listen.playback
+
+import androidx.media3.exoplayer.ExoPlayer
+import androidx.media3.session.MediaSession
+import androidx.media3.session.MediaSessionService
+
+/**
+ * Hosts the player so audio survives the app leaving the foreground.
+ *
+ * The notification this produces is the transport control -- lock screen, Bluetooth, car head
+ * unit. It is mandatory for background playback and is not an alert; Listen posts no
+ * new-episode notifications.
+ */
+class PlaybackService : MediaSessionService() {
+
+ private var player: ExoPlayer? = null
+ private var session: MediaSession? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ val exo = ExoPlayer.Builder(this)
+ // Podcast listening is continuous: keep playing through a transient focus loss
+ // rather than stopping dead, and duck for navigation prompts.
+ .setHandleAudioBecomingNoisy(true)
+ .build()
+ player = exo
+ session = MediaSession.Builder(this, exo).build()
+ }
+
+ override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? = session
+
+ override fun onDestroy() {
+ session?.release()
+ player?.release()
+ session = null
+ player = null
+ super.onDestroy()
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
new file mode 100644
index 0000000..88cd45f
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -0,0 +1,34 @@
+package org.terst.listen.ui
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+class MainActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ setContent { ListenTheme { LibraryPlaceholder() } }
+ }
+}
+
+@Composable
+fun ListenTheme(content: @Composable () -> Unit) {
+ MaterialTheme { Surface(modifier = Modifier.fillMaxSize()) { content() } }
+}
+
+@Composable
+private fun LibraryPlaceholder() {
+ Column(modifier = Modifier.padding(24.dp)) {
+ Text("Listen", style = MaterialTheme.typography.headlineMedium)
+ Text("No subscriptions yet.", style = MaterialTheme.typography.bodyMedium)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt
new file mode 100644
index 0000000..4130c2b
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt
@@ -0,0 +1,32 @@
+package org.terst.listen.ui
+
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+
+/**
+ * Share-sheet target. With discovery out of scope, this and OPML import are how subscriptions
+ * get added: a URL shared from a browser or FeeDreader is resolved to a feed.
+ *
+ * Resolution itself is not wired up yet -- see SubscriptionResolver.
+ */
+class ShareReceiverActivity : ComponentActivity() {
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ val shared = intent?.takeIf { it.action == Intent.ACTION_SEND }
+ ?.getStringExtra(Intent.EXTRA_TEXT)
+ .orEmpty()
+ val url = URL_REGEX.find(shared)?.value
+ Toast.makeText(
+ this,
+ if (url == null) "No link found in that share" else "Resolving $url",
+ Toast.LENGTH_SHORT,
+ ).show()
+ finish()
+ }
+
+ private companion object {
+ val URL_REGEX = Regex("""https?://\S+""")
+ }
+}
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..bd43aaf
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Headphones, drawn inside the adaptive icon's 72dp safe zone (centred in a 108dp canvas).
+ Amber on deep teal, matching the design plan's palette. -->
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+ <path
+ android:pathData="M36,64 A18,18 0 0 1 72,64"
+ android:strokeColor="#E0A83C"
+ android:strokeWidth="7"
+ android:strokeLineCap="round"
+ android:fillColor="@android:color/transparent" />
+ <path
+ android:pathData="M31,62 h8 a3,3 0 0 1 3,3 v12 a3,3 0 0 1 -3,3 h-8 a3,3 0 0 1 -3,-3 v-12 a3,3 0 0 1 3,-3 z"
+ android:fillColor="#E0A83C" />
+ <path
+ android:pathData="M69,62 h8 a3,3 0 0 1 3,3 v12 a3,3 0 0 1 -3,3 h-8 a3,3 0 0 1 -3,-3 v-12 a3,3 0 0 1 3,-3 z"
+ android:fillColor="#E0A83C" />
+</vector>
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..b686c90
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/icon_background" />
+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..b686c90
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+ <background android:drawable="@color/icon_background" />
+ <foreground android:drawable="@drawable/ic_launcher_foreground" />
+</adaptive-icon>
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
new file mode 100644
index 0000000..4b2fc81
--- /dev/null
+++ b/app/src/main/res/values-night/colors.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="window_background">#121618</color>
+</resources>
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
new file mode 100644
index 0000000..9a93256
--- /dev/null
+++ b/app/src/main/res/values-night/themes.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <style name="Theme.Listen" parent="android:Theme.Material.NoActionBar">
+ <item name="android:windowBackground">@color/window_background</item>
+ <item name="android:statusBarColor">@color/window_background</item>
+ <item name="android:navigationBarColor">@color/window_background</item>
+ <item name="android:windowLightStatusBar">false</item>
+ </style>
+</resources>
diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000..5118730
--- /dev/null
+++ b/app/src/main/res/values/colors.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <color name="window_background">#EDEFEC</color>
+ <color name="icon_background">#1F5352</color>
+</resources>
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..745e7f4
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <string name="app_name">Listen</string>
+</resources>
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..816a5ab
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+ <!-- Deliberately a plain API 21+ platform theme rather than a DayNight or DeviceDefault
+ variant. FeeDreader shipped a theme parent that silently required API 29 against a
+ minSdk of 26 and only an audit caught it; picking a parent well below minSdk removes
+ that class of mistake. Compose draws the whole UI, so this theme only needs to supply
+ a window background and get out of the way. Dark mode comes from values-night. -->
+ <style name="Theme.Listen" parent="android:Theme.Material.Light.NoActionBar">
+ <item name="android:windowBackground">@color/window_background</item>
+ <item name="android:statusBarColor">@color/window_background</item>
+ <item name="android:navigationBarColor">@color/window_background</item>
+ <item name="android:windowLightStatusBar">true</item>
+ </style>
+</resources>
diff --git a/app/src/test/java/org/terst/listen/feed/PodcastFeedParserTest.kt b/app/src/test/java/org/terst/listen/feed/PodcastFeedParserTest.kt
new file mode 100644
index 0000000..f5bd6b0
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/feed/PodcastFeedParserTest.kt
@@ -0,0 +1,221 @@
+package org.terst.listen.feed
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+/**
+ * The parser FeeDreader has does not do this job -- it only ever inspects an <enclosure> while
+ * looking for a thumbnail. These tests pin the podcast semantics it lacks: audio enclosures,
+ * durations, episode identity, and the Podcasting 2.0 tags the skip feature depends on.
+ */
+class PodcastFeedParserTest {
+
+ private fun feed(items: String, channelExtra: String = "") = """
+ <?xml version="1.0" encoding="UTF-8"?>
+ <rss version="2.0"
+ xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"
+ xmlns:podcast="https://podcastindex.org/namespace/1.0">
+ <channel>
+ <title>The Rest Is History</title>
+ <link>https://example.com</link>
+ <description>Two historians.</description>
+ <itunes:author>Goalhanger</itunes:author>
+ $channelExtra
+ $items
+ </channel>
+ </rss>
+ """.trim() // trim, not trimIndent: interpolating an already-dedented string drops the common
+ // indent to zero, leaving whitespace ahead of the XML declaration -- which is invalid XML.
+
+ private fun item(
+ title: String = "Episode One",
+ guid: String = "urn:uuid:1",
+ enclosure: String = """<enclosure url="https://cdn.example.com/1.mp3" length="1000" type="audio/mpeg"/>""",
+ extra: String = "",
+ ) = """
+ <item>
+ <title>$title</title>
+ <guid isPermaLink="false">$guid</guid>
+ <pubDate>Tue, 02 Sep 2026 06:00:00 GMT</pubDate>
+ $enclosure
+ $extra
+ </item>
+ """.trimIndent()
+
+ @Test
+ fun `reads channel metadata`() {
+ val parsed = PodcastFeedParser.parse(feed(item()).byteInputStream())
+ assertEquals("The Rest Is History", parsed.title)
+ assertEquals("Two historians.", parsed.description)
+ assertEquals("Goalhanger", parsed.author)
+ }
+
+ @Test
+ fun `reads an audio enclosure as the episode media`() {
+ val parsed = PodcastFeedParser.parse(feed(item()).byteInputStream())
+ val episode = parsed.episodes.single()
+ assertEquals("https://cdn.example.com/1.mp3", episode.mediaUrl)
+ assertEquals("audio/mpeg", episode.mediaType)
+ assertEquals(1000L, episode.mediaLengthBytes)
+ }
+
+ /**
+ * The whole reason this parser exists rather than reusing FeeDreader's: an item without
+ * playable media is not an episode, and an image enclosure is not media.
+ */
+ @Test
+ fun `drops items whose enclosure is not playable media`() {
+ val imageOnly = item(enclosure = """<enclosure url="https://c.example.com/art.jpg" length="9" type="image/jpeg"/>""")
+ val noEnclosure = item(guid = "urn:uuid:2", enclosure = "")
+ val parsed = PodcastFeedParser.parse(feed(imageOnly + noEnclosure).byteInputStream())
+ assertTrue("expected no episodes, got ${parsed.episodes}", parsed.episodes.isEmpty())
+ }
+
+ @Test
+ fun `keeps a video enclosure`() {
+ val video = item(enclosure = """<enclosure url="https://c.example.com/1.mp4" length="5" type="video/mp4"/>""")
+ val parsed = PodcastFeedParser.parse(feed(video).byteInputStream())
+ assertEquals("video/mp4", parsed.episodes.single().mediaType)
+ }
+
+ @Test
+ fun `parses itunes duration in seconds`() {
+ val parsed = PodcastFeedParser.parse(
+ feed(item(extra = "<itunes:duration>3600</itunes:duration>")).byteInputStream()
+ )
+ assertEquals(3600_000L, parsed.episodes.single().durationMs)
+ }
+
+ @Test
+ fun `parses itunes duration in clock form`() {
+ val cases = mapOf(
+ "01:02:03" to 3723_000L,
+ "02:03" to 123_000L,
+ "1:00:00" to 3600_000L,
+ )
+ for ((text, expected) in cases) {
+ val parsed = PodcastFeedParser.parse(
+ feed(item(extra = "<itunes:duration>$text</itunes:duration>")).byteInputStream()
+ )
+ assertEquals("duration $text", expected, parsed.episodes.single().durationMs)
+ }
+ }
+
+ @Test
+ fun `duration is null when absent rather than zero`() {
+ val parsed = PodcastFeedParser.parse(feed(item()).byteInputStream())
+ assertNull(parsed.episodes.single().durationMs)
+ }
+
+ @Test
+ fun `reads season and episode numbers`() {
+ val parsed = PodcastFeedParser.parse(
+ feed(
+ item(extra = "<itunes:season>4</itunes:season><itunes:episode>12</itunes:episode>")
+ ).byteInputStream()
+ )
+ val episode = parsed.episodes.single()
+ assertEquals(4, episode.seasonNumber)
+ assertEquals(12, episode.episodeNumber)
+ }
+
+ /**
+ * Episode identity is what a playback position hangs off. A missing guid must fall back to
+ * something stable rather than being dropped or randomised.
+ */
+ @Test
+ fun `falls back to the enclosure url when guid is missing`() {
+ val parsed = PodcastFeedParser.parse(feed(item(guid = "")).byteInputStream())
+ assertEquals("https://cdn.example.com/1.mp3", parsed.episodes.single().guid)
+ }
+
+ @Test
+ fun `parses pubDate to an epoch millis value`() {
+ val parsed = PodcastFeedParser.parse(feed(item()).byteInputStream())
+ // Tue, 02 Sep 2026 06:00:00 GMT
+ assertEquals(1788328800_000L, parsed.episodes.single().publishedAtMs)
+ }
+
+ @Test
+ fun `survives a malformed pubDate`() {
+ val parsed = PodcastFeedParser.parse(
+ feed(item().replace("Tue, 02 Sep 2026 06:00:00 GMT", "last thursday")).byteInputStream()
+ )
+ assertNull(parsed.episodes.single().publishedAtMs)
+ }
+
+ /**
+ * Tier 1 of the skip feature. A show that publishes its ad markers makes detection exact,
+ * so the link has to survive parsing.
+ */
+ @Test
+ fun `reads the podcast chapters url`() {
+ val parsed = PodcastFeedParser.parse(
+ feed(
+ item(
+ extra = """<podcast:chapters url="https://example.com/1.json" type="application/json+chapters"/>"""
+ )
+ ).byteInputStream()
+ )
+ assertEquals("https://example.com/1.json", parsed.episodes.single().chaptersUrl)
+ }
+
+ @Test
+ fun `reads the rfc5005 previous archive link for drip subscriptions`() {
+ val parsed = PodcastFeedParser.parse(
+ feed(
+ item(),
+ channelExtra = """<atom:link xmlns:atom="http://www.w3.org/2005/Atom"
+ rel="prev-archive" href="https://example.com/page2.xml"/>"""
+ ).byteInputStream()
+ )
+ assertEquals("https://example.com/page2.xml", parsed.previousArchiveUrl)
+ }
+
+ @Test
+ fun `prefers itunes image over channel image`() {
+ val parsed = PodcastFeedParser.parse(
+ feed(
+ item(),
+ channelExtra = """<itunes:image href="https://example.com/art.jpg"/>"""
+ ).byteInputStream()
+ )
+ assertEquals("https://example.com/art.jpg", parsed.imageUrl)
+ }
+
+ @Test
+ fun `preserves feed order`() {
+ val three = item(title = "Third", guid = "c") + item(title = "Second", guid = "b") +
+ item(title = "First", guid = "a")
+ val parsed = PodcastFeedParser.parse(feed(three).byteInputStream())
+ assertEquals(listOf("Third", "Second", "First"), parsed.episodes.map { it.title })
+ }
+
+ @Test
+ fun `unescapes entities in titles`() {
+ val parsed = PodcastFeedParser.parse(
+ feed(item(title = "Mathematics &amp; Physics")).byteInputStream()
+ )
+ assertEquals("Mathematics & Physics", parsed.episodes.single().title)
+ }
+
+ @Test
+ fun `reads an episode description from itunes summary when description is absent`() {
+ val parsed = PodcastFeedParser.parse(
+ feed(item(extra = "<itunes:summary>A summary.</itunes:summary>")).byteInputStream()
+ )
+ assertEquals("A summary.", parsed.episodes.single().description)
+ }
+
+ @Test
+ fun `rejects a document that is not a feed`() {
+ val html = "<html><body><p>not a feed</p></body></html>"
+ val parsed = PodcastFeedParser.parse(html.byteInputStream())
+ assertNotNull(parsed)
+ assertTrue(parsed.episodes.isEmpty())
+ assertNull(parsed.title)
+ }
+}