summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.coverage-baseline1
-rwxr-xr-x.githooks/pre-commit94
-rw-r--r--.gitignore11
-rw-r--r--CLAUDE.md91
-rw-r--r--app/build.gradle.kts140
-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
-rw-r--r--build.gradle.kts8
-rw-r--r--config/detekt/detekt.yml20
-rw-r--r--gradle.properties5
-rw-r--r--gradle/wrapper/gradle-wrapper.jarbin0 -> 43462 bytes
-rw-r--r--gradle/wrapper/gradle-wrapper.properties7
-rwxr-xr-xgradlew8
-rw-r--r--settings.gradle.kts18
27 files changed, 1142 insertions, 0 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
new file mode 100644
index 0000000..f362818
--- /dev/null
+++ b/.coverage-baseline
@@ -0,0 +1 @@
+75.88
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..32b492a
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,94 @@
+#!/usr/bin/env bash
+# Quality gate -- see CLAUDE.md "Quality gate -- every commit" for what each step is and why.
+# Activate once per clone with: git config core.hooksPath .githooks
+set -euo pipefail
+cd "$(git rev-parse --show-toplevel)"
+
+fail() {
+ echo "pre-commit: $1" >&2
+ exit 1
+}
+
+echo "== [1/8] Build =="
+./gradlew :app:compileDebugKotlin -q
+
+echo "== [2/8] Tests =="
+./gradlew :app:testDebugUnitTest -q
+
+echo "== [3/8] Static analysis: lint =="
+if ! ./gradlew :app:lintDebug -q; then
+ fail "lint failed -- fix all lint ERRORS before committing (warnings are fine to accumulate, see CLAUDE.md)."
+fi
+
+echo "== [4/8] Static analysis: detekt (code-quality auditor) =="
+if ! ./gradlew :app:detekt -q; then
+ fail "detekt found new issues beyond the tracked baseline. Fix them, or if they're genuinely pre-existing debt being touched incidentally, regenerate the baseline with './gradlew :app:detektBaseline' and explain why in the commit message."
+fi
+
+echo "== [5/8] Coverage must not decrease =="
+./gradlew :app:jacocoTestReport -q
+REPORT="app/build/reports/jacoco/jacocoTestReport/jacocoTestReport.xml"
+BASELINE_FILE=".coverage-baseline"
+if [ -f "$REPORT" ]; then
+ CURRENT=$(python3 - "$REPORT" <<'PY'
+import sys, xml.etree.ElementTree as ET
+root = ET.parse(sys.argv[1]).getroot()
+line = next(c for c in root.findall('counter') if c.get('type') == 'LINE')
+missed, covered = int(line.get('missed')), int(line.get('covered'))
+total = missed + covered
+print(f"{(covered / total * 100) if total else 0.0:.2f}")
+PY
+)
+else
+ CURRENT="0.00"
+fi
+BASELINE=$(cat "$BASELINE_FILE" 2>/dev/null || echo "0.00")
+
+DROPPED=$(python3 -c "print(1 if float('$CURRENT') < float('$BASELINE') else 0)")
+if [ "$DROPPED" = "1" ]; then
+ if [ -n "${COVERAGE_EXCEPTION:-}" ]; then
+ echo "pre-commit: coverage dropped ${BASELINE}% -> ${CURRENT}% -- allowed via COVERAGE_EXCEPTION: ${COVERAGE_EXCEPTION}"
+ echo "$CURRENT" > "$BASELINE_FILE"
+ git add "$BASELINE_FILE"
+ else
+ fail "coverage dropped ${BASELINE}% -> ${CURRENT}%. If this drop is the correct outcome (e.g. deleting dead covered code), commit with COVERAGE_EXCEPTION=\"reason\" git commit ... Otherwise, add tests."
+ fi
+else
+ echo "$CURRENT" > "$BASELINE_FILE"
+ git add "$BASELINE_FILE"
+ echo "pre-commit: coverage ${BASELINE}% -> ${CURRENT}%"
+fi
+
+echo "== [6/8] WebView JavaScript justification =="
+STAGED_KT=$(git diff --cached --name-only --diff-filter=ACM -- '*.kt' || true)
+if [ -n "$STAGED_KT" ]; then
+ for f in $STAGED_KT; do
+ [ -f "$f" ] || continue
+ ADDED_JS_LINES=$(git diff --cached -U0 -- "$f" | grep -E '^\+.*(javaScriptEnabled\s*=\s*true|setJavaScriptEnabled\(true\))' || true)
+ if [ -n "$ADDED_JS_LINES" ]; then
+ if ! grep -q '@SuppressLint("SetJavaScriptEnabled")' "$f"; then
+ fail "$f enables JavaScript on a WebView with no @SuppressLint(\"SetJavaScriptEnabled\") + justification comment nearby (see ReaderExtractor.kt, LinkReaderScreen.kt, BrowserScreen.kt for the pattern). This app's security posture depends on every JS-enabled site being deliberate and explained."
+ fi
+ fi
+ done
+fi
+
+echo "== [7/8] Manifest permission diff =="
+if git diff --cached --name-only | grep -q '^app/src/main/AndroidManifest.xml$'; then
+ if git diff --cached -- app/src/main/AndroidManifest.xml | grep -qE '^\+.*<uses-permission'; then
+ echo "pre-commit: WARNING -- AndroidManifest.xml adds a new <uses-permission>. Make sure the commit message says why."
+ fi
+fi
+
+echo "== [8/8] Secret scan =="
+if [ -z "${SECRET_SCAN_OVERRIDE:-}" ]; then
+ SECRET_HIT=$(git diff --cached -U0 -- . ':!*.lock' | grep -E '^\+' | grep -viE '^\+\+\+' | grep -E '(api[_-]?key|secret|password|token)["'\'' ]*[:=]["'\'' ]*[A-Za-z0-9_\-]{16,}' || true)
+ if [ -n "$SECRET_HIT" ]; then
+ echo "$SECRET_HIT" >&2
+ fail "possible hardcoded secret in the staged diff (see above). If this is a false positive (e.g. an obviously-fake test fixture key), commit with SECRET_SCAN_OVERRIDE=1 git commit ..."
+ fi
+else
+ echo "pre-commit: secret scan skipped (SECRET_SCAN_OVERRIDE set)"
+fi
+
+echo "pre-commit: all checks passed."
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2c97816
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+*.iml
+.gradle/
+/local.properties
+/.idea/
+.DS_Store
+/build
+/captures
+.externalNativeBuild
+.cxx
+local.properties
+app/build/
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..50e1fcb
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,91 @@
+# Listen — Agent Instructions
+
+Personal Android podcast app, `org.terst.listen`. Sibling to FeeDreader (`/workspace/feedreader`),
+built for Peter's own use. Greenfield, hands-on-testing-rounds workflow: sideload a debug build,
+use it, batch feedback.
+
+Design plan: https://claude.ai/code/artifact/85137e78-3718-4d84-8ab8-f72885170653
+
+## What this app is, and isn't
+
+Replacing Pocket Casts completely. The distinguishing features are audio-based intro/outro
+skipping, slow-drip archive subscriptions, and rule-driven auto-queueing.
+
+**Non-goals, deliberately:** anything social, new-episode notifications (or any alert — the media
+notification is a transport control and is mandatory for background audio, which is a different
+thing), favourites, bookmarks. Discovery and video were both cut from scope.
+
+## Relationship to FeeDreader
+
+Separate repos, no shared module. That was a considered call, not an accident: FeeDreader's
+`FeedParser` treats `<enclosure>` as an image source and its `FilterRuleEngine` is 46 lines of
+substring matching that doesn't generalise to queue rules, so only `Opml.kt` (~96 lines) genuinely
+transferred.
+
+**Standing instruction:** watch for things that do generalise, and avoid pointless drift between
+the two apps. If a change here would obviously improve FeeDreader too, say so. Don't force sharing
+where the problems are actually different — but don't let two solutions to the same problem quietly
+diverge either.
+
+## Test-driven development is mandatory
+
+Same rule as FeeDreader, same reasons. Every change to `app/src/main/` follows red-green-refactor,
+in that literal order:
+
+1. **Write the test first**, for the specific behaviour you're about to add or change.
+2. **Run it. Confirm it fails, and fails for the right reason** — an assertion mismatch showing the
+ missing behaviour, not a compile error.
+3. Write the minimal production code to pass.
+4. Run again. Confirm green.
+5. Refactor if needed, keeping it green.
+6. Run the full quality gate before committing.
+
+**If production code was written before or without a preceding failing test**, discard the diff and
+redo it test-first. Do not backfill tests onto existing code — a test written against working code
+confirms what the code does, not what it should do.
+
+**Scoping exception:** pure resource/config/manifest changes with no unit-testable behaviour don't
+get a red step; the static-analysis gate covers those.
+
+## Verification — mandatory
+
+**Never conclude a build or test result from grepped console output**, and never verify a launch
+with `adb shell monkey`. Use the shared wrappers, which report from exit codes, parsed JUnit XML,
+and a logcat capture started before launch:
+
+```
+/workspace/devtools/bin/gradle-test /workspace/listen -- --rerun-tasks
+/workspace/devtools/bin/android-launch-check org.terst.listen \
+ --apk app/build/outputs/apk/debug/app-debug.apk --settle 15
+```
+
+See `/workspace/devtools/README.md`. These exist because grepped console text has produced wrong
+claims three separate times across these repos.
+
+## Quality gate — every commit
+
+1. **Build** — `./gradlew compileDebugKotlin` succeeds.
+2. **Tests** — `./gradlew testDebugUnitTest` passes in full.
+3. **Static analysis** — `./gradlew detekt lintDebug` clean: zero lint errors, zero new detekt
+ findings beyond the baseline.
+4. **Coverage must not decrease** against `.coverage-baseline` (line coverage over
+ `app/src/main/java` excluding `ui/**`). Set `COVERAGE_EXCEPTION="reason"` for justified drops,
+ e.g. deleting covered dead code.
+5. **Manifest permission diff** — if `<uses-permission>` changed, the commit message says why.
+6. **Schema migrations** — if `AppDatabase.kt`'s `version` changed, a migration test must be
+ touched in the same diff. Room tracks by version; a wrong migration corrupts a real,
+ non-reproducible library.
+
+## Domain notes worth keeping
+
+- **Playback position is the crown jewel of the data model.** It must survive process death. Save
+ on a timer, on pause, and on lifecycle transitions — not just on stop.
+- **Never assume audio.** An enclosure carries a MIME type. Video was cut from scope, but the model
+ stays media-agnostic so it isn't a retrofit.
+- **GUIDs move.** Publishers rewrite feeds; match episodes on GUID first, then enclosure URL, then
+ title+date. An episode that loses its identity loses its playback position.
+- **Retention is auto-delete-once-played** with a grace period, so a mis-tap doesn't destroy an
+ episode you were still listening to.
+- **YouTube never touches this app.** Audio arrives via podkit running in Termux on the phone,
+ writing to a local folder Listen imports. No extractor code here, nothing to break when Google
+ ships a change.
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..a1d1854
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,140 @@
+plugins {
+ id("com.android.application")
+ id("org.jetbrains.kotlin.android")
+ id("org.jetbrains.kotlin.plugin.serialization")
+ id("org.jetbrains.kotlin.plugin.compose")
+ id("com.google.devtools.ksp")
+ id("io.gitlab.arturbosch.detekt")
+ id("jacoco")
+}
+
+android {
+ namespace = "org.terst.listen"
+ compileSdk = 36
+
+ defaultConfig {
+ applicationId = "org.terst.listen"
+ // Deliberately not FeeDreader's minSdk. That value is 36 in its working tree, which
+ // would restrict the app to Android 16 and newer; 29 covers any phone in real use and
+ // is comfortably above Media3's floor of 21.
+ minSdk = 29
+ targetSdk = 36
+ versionCode = 1
+ versionName = "1.0"
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ debug {
+ enableUnitTestCoverage = true
+ }
+ release {
+ isMinifyEnabled = false
+ }
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+
+ testOptions {
+ unitTests {
+ // Room's and Media3's Android stubs otherwise throw on every call from a JVM test.
+ isReturnDefaultValues = true
+ }
+ }
+}
+
+dependencies {
+ val composeBom = platform("androidx.compose:compose-bom:2026.06.01")
+ implementation(composeBom)
+ androidTestImplementation(composeBom)
+
+ implementation("androidx.core:core-ktx:1.17.0")
+ implementation("androidx.activity:activity-compose:1.11.0")
+ implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.9.4")
+ implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.9.4")
+ implementation("androidx.navigation:navigation-compose:2.9.5")
+
+ implementation("androidx.compose.ui:ui")
+ implementation("androidx.compose.ui:ui-graphics")
+ implementation("androidx.compose.ui:ui-tooling-preview")
+ implementation("androidx.compose.material3:material3")
+ implementation("androidx.compose.material:material-icons-extended")
+
+ // Media3 is the whole playback stack: ExoPlayer for decode and speed control,
+ // MediaSessionService for lock-screen and Bluetooth transport, and its DownloadManager,
+ // which shares a cache with playback so a partly-downloaded episode streams the remainder
+ // instead of failing.
+ val media3 = "1.8.0"
+ implementation("androidx.media3:media3-exoplayer:$media3")
+ implementation("androidx.media3:media3-session:$media3")
+ implementation("androidx.media3:media3-ui:$media3")
+ implementation("androidx.media3:media3-datasource-okhttp:$media3")
+
+ implementation("androidx.room:room-runtime:2.8.4")
+ implementation("androidx.room:room-ktx:2.8.4")
+ ksp("androidx.room:room-compiler:2.8.4")
+
+ implementation("androidx.work:work-runtime-ktx:2.10.5")
+ implementation("androidx.datastore:datastore-preferences:1.1.7")
+
+ implementation("com.squareup.okhttp3:okhttp:4.12.0")
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
+ implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
+
+ testImplementation("junit:junit:4.13.2")
+ testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
+ testImplementation("androidx.room:room-testing:2.8.4")
+ // The JVM has no XmlPullParser implementation; the feed parser needs a real one under test.
+ testImplementation("net.sf.kxml:kxml2:2.3.0")
+ testImplementation("xmlpull:xmlpull:1.1.3.1")
+ testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
+
+ androidTestImplementation("androidx.test.ext:junit:1.3.0")
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
+ androidTestImplementation("androidx.compose.ui:ui-test-junit4")
+ androidTestImplementation("androidx.room:room-testing:2.8.4")
+ androidTestImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.8.0")
+
+ debugImplementation("androidx.compose.ui:ui-tooling")
+ debugImplementation("androidx.compose.ui:ui-test-manifest")
+}
+
+detekt {
+ buildUponDefaultConfig = true
+ config.setFrom(files("$rootDir/config/detekt/detekt.yml"))
+ baseline = file("$rootDir/config/detekt/baseline.xml")
+}
+
+jacoco {
+ toolVersion = "0.8.12"
+}
+
+tasks.register<JacocoReport>("jacocoTestReport") {
+ dependsOn("testDebugUnitTest")
+ reports {
+ xml.required.set(true)
+ html.required.set(true)
+ }
+ // ui/** is excluded for the same reason as FeeDreader: Compose screens are impractical to
+ // unit-test for coverage and including them makes the metric meaningless.
+ val kotlinDebugTree = fileTree("${layout.buildDirectory.get()}/tmp/kotlin-classes/debug") {
+ exclude("**/ui/**", "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/*_Impl*")
+ }
+ val javaDebugTree = fileTree("${layout.buildDirectory.get()}/intermediates/javac/debug") {
+ exclude("**/ui/**", "**/R.class", "**/R$*.class", "**/BuildConfig.*", "**/*_Impl*")
+ }
+ classDirectories.setFrom(files(kotlinDebugTree, javaDebugTree))
+ sourceDirectories.setFrom(files("src/main/java"))
+ executionData.setFrom(fileTree(layout.buildDirectory) { include("**/*.exec") })
+}
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)
+ }
+}
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..a2551b0
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ id("com.android.application") version "8.13.2" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.21" apply false
+ id("org.jetbrains.kotlin.plugin.serialization") version "2.2.21" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.2.21" apply false
+ id("com.google.devtools.ksp") version "2.2.21-2.0.5" apply false
+ id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false
+}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
new file mode 100644
index 0000000..a5b1129
--- /dev/null
+++ b/config/detekt/detekt.yml
@@ -0,0 +1,20 @@
+# Builds on detekt's default ruleset (see app/build.gradle.kts: buildUponDefaultConfig = true).
+# Only overrides/additions relative to the defaults belong here -- keep this file small so
+# upgrading detekt pulls in sane new defaults automatically instead of us silently diverging.
+
+style:
+ ReturnCount:
+ max: 5 # the feed parser is guard-clause heavy on purpose -- an enclosure with no URL, no
+ # type, or a non-playable type each bail out early. Forcing those into nested conditionals
+ # to satisfy a limit of 2 would make the code harder to read, not easier.
+ MaxLineLength:
+ maxLineLength: 140 # several files carry long, deliberate rationale comments (see
+ # ReaderExtractor.kt) -- 120 (detekt's default) wrapped mid-explanation too aggressively.
+ ForbiddenComment:
+ active: false # this codebase deliberately doesn't use TODO/FIXME markers (see CLAUDE.md
+ # "why these checks") -- nothing to forbid.
+
+naming:
+ FunctionNaming:
+ ignoreAnnotated: ["Composable"] # Jetpack Compose's own naming convention is PascalCase for
+ # composable functions -- that's the correct style here, not a violation.
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..fb2663e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,5 @@
+android.useAndroidX=true
+
+android.nonTransitiveRClass=true
+
+org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000..d64cd49
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.jar
Binary files differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000..1a70468
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 0000000..90fc21f
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,8 @@
+#!/bin/sh
+# Standard Gradle wrapper invocation -- this project needs a newer Gradle than the shared
+# system install at /opt/gradle/gradle-8.6 (AGP 9.x requires Gradle 9.5+), and other projects
+# in this sandbox rely on that shared install staying at 8.6, so this project uses its own
+# pinned version via gradle/wrapper/gradle-wrapper.properties instead of the shared shortcut.
+APP_HOME="$(cd "$(dirname "$0")" && pwd)"
+CLASSPATH="$APP_HOME/gradle/wrapper/gradle-wrapper.jar"
+exec java -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..c9939d4
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,18 @@
+pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Listen"
+include(":app")