diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/build.gradle.kts | 140 | ||||
| -rw-r--r-- | app/src/main/AndroidManifest.xml | 58 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ListenApp.kt | 5 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/feed/PodcastFeedParser.kt | 283 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/playback/PlaybackService.kt | 39 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/MainActivity.kt | 34 | ||||
| -rw-r--r-- | app/src/main/java/org/terst/listen/ui/ShareReceiverActivity.kt | 32 | ||||
| -rw-r--r-- | app/src/main/res/drawable/ic_launcher_foreground.xml | 21 | ||||
| -rw-r--r-- | app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 | ||||
| -rw-r--r-- | app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml | 5 | ||||
| -rw-r--r-- | app/src/main/res/values-night/colors.xml | 4 | ||||
| -rw-r--r-- | app/src/main/res/values-night/themes.xml | 9 | ||||
| -rw-r--r-- | app/src/main/res/values/colors.xml | 5 | ||||
| -rw-r--r-- | app/src/main/res/values/strings.xml | 4 | ||||
| -rw-r--r-- | app/src/main/res/values/themes.xml | 14 | ||||
| -rw-r--r-- | app/src/test/java/org/terst/listen/feed/PodcastFeedParserTest.kt | 221 |
16 files changed, 879 insertions, 0 deletions
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 & 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) + } +} |
