summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-21 08:13:49 +0000
committerClaude <noreply@anthropic.com>2026-09-21 08:13:49 +0000
commit5876025559ba0ad287d3a7a4d9fa0246509e668e (patch)
treec918d01e2b74d205ee00fe96926232475840ff97 /app/src
Scaffold Listen, with a podcast-native feed parser
Sibling to FeeDreader, not a shared module. That was a considered call: its FeedParser only inspects <enclosure> while hunting for a thumbnail, and its FilterRuleEngine is 46 lines of title substring matching that doesn't generalise to queue rules, so only Opml.kt would genuinely have transferred. The parser is the piece everything else sits on, so it goes first, test-first: 18 tests covering audio/video enclosure selection, iTunes durations in both second and clock form, season/episode numbers, podcast:chapters for the skip feature, RFC 5005 archive paging for drip subscriptions, and episode identity falling back to the enclosure URL when a guid is missing. Two things worth recording: - Built on XmlPullParserFactory rather than android.util.Xml. The latter returns null off-device, which is why FeeDreader's 297-line parser has no tests at all. Both resolve to the same KXmlParser on Android. - SimpleDateFormat with isLenient=false rejects "Tue, 02 Sep 2026 06:00:00 GMT" -- an entirely ordinary RFC 822 date. Left strict, every pubDate in the app would have been null and episode ordering would have been silently wrong everywhere. minSdk is 29, deliberately not FeeDreader's 36, which would restrict the app to Android 16 and newer. Permissions: INTERNET and ACCESS_NETWORK_STATE for feeds; FOREGROUND_SERVICE, FOREGROUND_SERVICE_MEDIA_PLAYBACK, POST_NOTIFICATIONS and WAKE_LOCK because Android requires a foreground service with a notification to play audio in the background. That notification is the transport control, not an alert -- Listen posts no new-episode notifications by design. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
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)
+ }
+}