summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt42
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt132
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt114
-rw-r--r--app/src/main/java/org/terst/listen/data/EpisodeMatcher.kt107
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt100
-rw-r--r--app/src/main/java/org/terst/listen/feed/FeedFetcher.kt77
-rw-r--r--app/src/test/java/org/terst/listen/data/EpisodeMatcherTest.kt207
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt138
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt165
-rw-r--r--app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt137
10 files changed, 1219 insertions, 0 deletions
diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
new file mode 100644
index 0000000..bf7411d
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -0,0 +1,42 @@
+package org.terst.listen.data
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+
+class Converters {
+ @TypeConverter fun toKind(value: String) = SubscriptionKind.valueOf(value)
+ @TypeConverter fun fromKind(value: SubscriptionKind) = value.name
+ @TypeConverter fun toDownloadState(value: String) = DownloadState.valueOf(value)
+ @TypeConverter fun fromDownloadState(value: DownloadState) = value.name
+}
+
+@Database(
+ entities = [SubscriptionEntity::class, EpisodeEntity::class, QueueEntity::class],
+ version = 1,
+ exportSchema = true,
+)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun subscriptions(): SubscriptionDao
+ abstract fun episodes(): EpisodeDao
+ abstract fun queue(): QueueDao
+
+ companion object {
+ @Volatile private var instance: AppDatabase? = null
+
+ fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
+ instance ?: build(context.applicationContext).also { instance = it }
+ }
+
+ private fun build(context: Context) =
+ Room.databaseBuilder(context, AppDatabase::class.java, "listen.db")
+ // No fallbackToDestructiveMigration. A listener's playback positions and play
+ // history are not reproducible from anywhere, so a missing migration must fail
+ // loudly rather than silently wipe the library.
+ .build()
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
new file mode 100644
index 0000000..2376644
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -0,0 +1,132 @@
+package org.terst.listen.data
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import androidx.room.Update
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface SubscriptionDao {
+ @Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE")
+ fun observeAll(): Flow<List<SubscriptionEntity>>
+
+ @Query("SELECT * FROM subscriptions ORDER BY title COLLATE NOCASE")
+ suspend fun getAll(): List<SubscriptionEntity>
+
+ @Query("SELECT * FROM subscriptions WHERE id = :id")
+ suspend fun getById(id: String): SubscriptionEntity?
+
+ @Query("SELECT * FROM subscriptions WHERE feedUrl = :feedUrl")
+ suspend fun getByFeedUrl(feedUrl: String): SubscriptionEntity?
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insert(subscription: SubscriptionEntity): Long
+
+ @Update
+ suspend fun update(subscription: SubscriptionEntity)
+
+ @Query("DELETE FROM subscriptions WHERE id = :id")
+ suspend fun delete(id: String)
+
+ /** Recorded so a feed that has quietly stopped working is visible in the UI, not just in logs. */
+ @Query("UPDATE subscriptions SET lastError = :error, lastFetchedAtMs = :atMs WHERE id = :id")
+ suspend fun recordFailure(id: String, error: String, atMs: Long)
+
+ @Query(
+ """UPDATE subscriptions
+ SET lastError = NULL, lastFetchedAtMs = :atMs, etag = :etag, lastModified = :lastModified
+ WHERE id = :id"""
+ )
+ suspend fun recordSuccess(id: String, atMs: Long, etag: String?, lastModified: String?)
+}
+
+@Dao
+interface EpisodeDao {
+ @Query("SELECT * FROM episodes WHERE subscriptionId = :subscriptionId ORDER BY publishedAtMs DESC")
+ fun observeForSubscription(subscriptionId: String): Flow<List<EpisodeEntity>>
+
+ @Query("SELECT * FROM episodes WHERE subscriptionId = :subscriptionId")
+ suspend fun getForSubscription(subscriptionId: String): List<EpisodeEntity>
+
+ @Query("SELECT * FROM episodes WHERE id = :id")
+ suspend fun getById(id: String): EpisodeEntity?
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ suspend fun insertAll(episodes: List<EpisodeEntity>)
+
+ @Update
+ suspend fun updateAll(episodes: List<EpisodeEntity>)
+
+ @Query("UPDATE episodes SET positionMs = :positionMs WHERE id = :id")
+ suspend fun updatePosition(id: String, positionMs: Long)
+
+ @Query("UPDATE episodes SET playedAtMs = :atMs, positionMs = 0 WHERE id = :id")
+ suspend fun markPlayed(id: String, atMs: Long)
+
+ @Query("UPDATE episodes SET playedAtMs = NULL WHERE id = :id")
+ suspend fun markUnplayed(id: String)
+
+ @Query(
+ """UPDATE episodes SET downloadState = :state, localPath = :localPath,
+ downloadedAtMs = :downloadedAtMs WHERE id = :id"""
+ )
+ suspend fun updateDownload(id: String, state: DownloadState, localPath: String?, downloadedAtMs: Long?)
+
+ /** Candidates for retention: played, downloaded, and past their grace period. */
+ @Query(
+ """SELECT * FROM episodes
+ WHERE downloadState = :downloaded AND playedAtMs IS NOT NULL AND playedAtMs < :before"""
+ )
+ suspend fun playedDownloadsBefore(
+ before: Long,
+ downloaded: DownloadState = DownloadState.DOWNLOADED,
+ ): List<EpisodeEntity>
+
+ @Query("SELECT * FROM episodes WHERE playedAtMs IS NULL ORDER BY publishedAtMs DESC LIMIT :limit")
+ fun observeUnplayed(limit: Int): Flow<List<EpisodeEntity>>
+}
+
+@Dao
+interface QueueDao {
+ @Query(
+ """SELECT e.* FROM episodes e
+ INNER JOIN queue q ON q.episodeId = e.id
+ ORDER BY q.position"""
+ )
+ fun observeQueue(): Flow<List<EpisodeEntity>>
+
+ @Query(
+ """SELECT e.* FROM episodes e
+ INNER JOIN queue q ON q.episodeId = e.id
+ ORDER BY q.position"""
+ )
+ suspend fun getQueue(): List<EpisodeEntity>
+
+ @Query("SELECT * FROM queue ORDER BY position")
+ suspend fun getEntries(): List<QueueEntity>
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsertAll(entries: List<QueueEntity>)
+
+ @Query("DELETE FROM queue WHERE episodeId = :episodeId")
+ suspend fun remove(episodeId: String)
+
+ @Query("DELETE FROM queue")
+ suspend fun clear()
+
+ @Query("SELECT COUNT(*) FROM queue")
+ suspend fun size(): Int
+
+ /**
+ * Rewrites the whole queue in one transaction. Positions are contiguous ordinals, so a
+ * reorder that half-applied would leave duplicates or gaps the UI would render wrongly.
+ */
+ @Transaction
+ suspend fun replaceAll(episodeIds: List<String>) {
+ clear()
+ upsertAll(episodeIds.mapIndexed { index, id -> QueueEntity(id, index) })
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt
new file mode 100644
index 0000000..d66bb99
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -0,0 +1,114 @@
+package org.terst.listen.data
+
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+/** Where a subscription's episodes come from, which decides how their media is fetched. */
+enum class SubscriptionKind { PODCAST, YOUTUBE_CHANNEL }
+
+enum class DownloadState { NONE, QUEUED, DOWNLOADING, DOWNLOADED, FAILED }
+
+@Entity(
+ tableName = "subscriptions",
+ indices = [Index(value = ["feedUrl"], unique = true)],
+)
+data class SubscriptionEntity(
+ @PrimaryKey val id: String,
+ val feedUrl: String,
+ val kind: SubscriptionKind = SubscriptionKind.PODCAST,
+ val title: String,
+ val description: String? = null,
+ val author: String? = null,
+ val imageUrl: String? = null,
+ val link: String? = null,
+ val addedAtMs: Long,
+
+ // Conditional-GET state. Most feeds are unchanged most of the time; sending these turns a
+ // refresh into a 304 with no body.
+ val etag: String? = null,
+ val lastModified: String? = null,
+
+ val lastFetchedAtMs: Long? = null,
+ /**
+ * Why the last refresh failed, or null if it succeeded.
+ *
+ * Recorded rather than logged: a feed that silently stops updating looks identical to a feed
+ * with no new episodes, and that class of invisible failure has bitten every project here.
+ */
+ val lastError: String? = null,
+
+ /** Per-show playback speed, or null to use the global default. */
+ val playbackSpeed: Float? = null,
+ /** Tier 2 of the skip feature: manual offsets, always available when detection is wrong. */
+ val skipIntroMs: Long = 0,
+ val skipOutroMs: Long = 0,
+)
+
+@Entity(
+ tableName = "episodes",
+ foreignKeys = [
+ ForeignKey(
+ entity = SubscriptionEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["subscriptionId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [
+ Index(value = ["subscriptionId"]),
+ Index(value = ["subscriptionId", "guid"], unique = true),
+ Index(value = ["publishedAtMs"]),
+ ],
+)
+data class EpisodeEntity(
+ @PrimaryKey val id: String,
+ val subscriptionId: String,
+ /** Stable identity from the feed. Playback position hangs off this. */
+ val guid: String,
+ val title: String,
+ val description: String? = null,
+ val mediaUrl: String,
+ val mediaType: String,
+ val mediaLengthBytes: Long? = null,
+ val durationMs: Long? = null,
+ val publishedAtMs: Long? = null,
+ val seasonNumber: Int? = null,
+ val episodeNumber: Int? = null,
+ val imageUrl: String? = null,
+ val chaptersUrl: String? = null,
+ val discoveredAtMs: Long,
+
+ /** How far in the listener got. Survives process death; see PositionWriter. */
+ val positionMs: Long = 0,
+ /** Null while unplayed. Set when finished, and what retention counts its grace period from. */
+ val playedAtMs: Long? = null,
+
+ val downloadState: DownloadState = DownloadState.NONE,
+ val localPath: String? = null,
+ val downloadedAtMs: Long? = null,
+)
+
+/**
+ * One entry in the single Up Next queue.
+ *
+ * Position is a plain ordinal rewritten on reorder. A personal queue is short enough that the
+ * simplicity is worth more than avoiding the rewrite.
+ */
+@Entity(
+ tableName = "queue",
+ foreignKeys = [
+ ForeignKey(
+ entity = EpisodeEntity::class,
+ parentColumns = ["id"],
+ childColumns = ["episodeId"],
+ onDelete = ForeignKey.CASCADE,
+ ),
+ ],
+ indices = [Index(value = ["position"])],
+)
+data class QueueEntity(
+ @PrimaryKey val episodeId: String,
+ val position: Int,
+)
diff --git a/app/src/main/java/org/terst/listen/data/EpisodeMatcher.kt b/app/src/main/java/org/terst/listen/data/EpisodeMatcher.kt
new file mode 100644
index 0000000..f11b602
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/data/EpisodeMatcher.kt
@@ -0,0 +1,107 @@
+package org.terst.listen.data
+
+import org.terst.listen.feed.ParsedEpisode
+import java.util.UUID
+
+/**
+ * Reconciles a freshly parsed feed against what is already stored.
+ *
+ * The hard requirement is that listener state -- position, played, downloads -- survives whatever
+ * the publisher does to their feed. Feeds get migrated, re-hosted, and regenerated, and when a
+ * guid changes the naive result is a duplicate episode and a listener who has lost their place
+ * in a three-hour recording.
+ */
+object EpisodeMatcher {
+
+ data class Result(val inserts: List<EpisodeEntity>, val updates: List<EpisodeEntity>)
+
+ fun match(
+ subscriptionId: String,
+ existing: List<EpisodeEntity>,
+ parsed: List<ParsedEpisode>,
+ nowMs: Long,
+ ): Result {
+ val byGuid = existing.associateBy { it.guid }
+ val byMediaUrl = existing.groupBy { it.mediaUrl }
+ val byTitleAndDate = existing.filter { it.publishedAtMs != null }
+ .groupBy { titleDateKey(it.title, it.publishedAtMs) }
+
+ // A stored episode may only be claimed once, or two feed items pointing at the same file
+ // would both update it and one real episode would go missing.
+ val claimed = mutableSetOf<String>()
+ val inserts = mutableListOf<EpisodeEntity>()
+ val updates = mutableListOf<EpisodeEntity>()
+
+ for (item in parsed) {
+ val match = findMatch(item, byGuid, byMediaUrl, byTitleAndDate, claimed)
+ if (match == null) {
+ inserts += item.toEntity(subscriptionId, nowMs)
+ } else {
+ claimed += match.id
+ val merged = match.mergedWith(item)
+ if (merged != match) updates += merged
+ }
+ }
+ return Result(inserts, updates)
+ }
+
+ /**
+ * Three identifiers in descending order of trustworthiness. Each is checked against episodes
+ * not already claimed by an earlier feed item.
+ */
+ private fun findMatch(
+ item: ParsedEpisode,
+ byGuid: Map<String, EpisodeEntity>,
+ byMediaUrl: Map<String, List<EpisodeEntity>>,
+ byTitleAndDate: Map<String, List<EpisodeEntity>>,
+ claimed: Set<String>,
+ ): EpisodeEntity? {
+ byGuid[item.guid]?.takeIf { it.id !in claimed }?.let { return it }
+ byMediaUrl[item.mediaUrl]?.firstOrNull { it.id !in claimed }?.let { return it }
+ // Titles repeat across a series, so this only applies when both sides carry a date.
+ if (item.publishedAtMs == null || item.title == null) return null
+ return byTitleAndDate[titleDateKey(item.title, item.publishedAtMs)]
+ ?.firstOrNull { it.id !in claimed }
+ }
+
+ private fun titleDateKey(title: String?, publishedAtMs: Long?) =
+ "${title?.trim()?.lowercase()}@$publishedAtMs"
+
+ /**
+ * Feed metadata is overwritten; listener state is carried across untouched. The stored id and
+ * discovery date are kept too -- the id because the queue and downloads reference it, the
+ * date because it records when the episode first appeared, not when it was last seen.
+ */
+ private fun EpisodeEntity.mergedWith(item: ParsedEpisode) = copy(
+ guid = item.guid,
+ title = item.title ?: title,
+ description = item.description ?: description,
+ mediaUrl = item.mediaUrl,
+ mediaType = item.mediaType,
+ mediaLengthBytes = item.mediaLengthBytes ?: mediaLengthBytes,
+ durationMs = item.durationMs ?: durationMs,
+ publishedAtMs = item.publishedAtMs ?: publishedAtMs,
+ seasonNumber = item.seasonNumber ?: seasonNumber,
+ episodeNumber = item.episodeNumber ?: episodeNumber,
+ imageUrl = item.imageUrl ?: imageUrl,
+ chaptersUrl = item.chaptersUrl ?: chaptersUrl,
+ )
+
+ private fun ParsedEpisode.toEntity(subscriptionId: String, nowMs: Long) = EpisodeEntity(
+ id = UUID.randomUUID().toString(),
+ subscriptionId = subscriptionId,
+ guid = guid,
+ title = title.orEmpty().ifBlank { "Untitled episode" },
+ description = description,
+ mediaUrl = mediaUrl,
+ mediaType = mediaType,
+ mediaLengthBytes = mediaLengthBytes,
+ durationMs = durationMs,
+ publishedAtMs = publishedAtMs,
+ seasonNumber = seasonNumber,
+ episodeNumber = episodeNumber,
+ imageUrl = imageUrl,
+ chaptersUrl = chaptersUrl,
+ discoveredAtMs = nowMs,
+ )
+}
diff --git a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
new file mode 100644
index 0000000..756ac37
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
@@ -0,0 +1,100 @@
+package org.terst.listen.data
+
+import org.terst.listen.feed.FeedFetchResult
+import org.terst.listen.feed.FeedFetcher
+import org.terst.listen.feed.ParsedFeed
+import java.util.UUID
+
+/** Outcome of refreshing one subscription, reported rather than logged. */
+sealed interface RefreshOutcome {
+ data class Updated(val newEpisodes: Int, val updatedEpisodes: Int) : RefreshOutcome
+ data object Unchanged : RefreshOutcome
+ data class Failed(val message: String) : RefreshOutcome
+}
+
+class SubscriptionRepository(
+ private val subscriptions: SubscriptionDao,
+ private val episodes: EpisodeDao,
+ private val fetcher: FeedFetcher,
+ private val now: () -> Long = System::currentTimeMillis,
+) {
+
+ /**
+ * Subscribes to a feed, or returns the existing subscription if already present.
+ *
+ * Idempotent because OPML import and the share sheet both routinely offer feeds already
+ * subscribed to, and neither should produce a duplicate.
+ */
+ suspend fun subscribe(feedUrl: String, kind: SubscriptionKind = SubscriptionKind.PODCAST):
+ Result<SubscriptionEntity> {
+ subscriptions.getByFeedUrl(feedUrl)?.let { return Result.success(it) }
+
+ return when (val fetched = fetcher.fetch(feedUrl, null, null)) {
+ is FeedFetchResult.Failed -> Result.failure(IllegalStateException(fetched.message))
+ // A 304 without stored validators should not happen; treat it as a failure rather
+ // than inventing an empty subscription.
+ FeedFetchResult.NotModified ->
+ Result.failure(IllegalStateException("Server reported not-modified for a new subscription"))
+ is FeedFetchResult.Fetched -> {
+ val entity = fetched.feed.toSubscription(feedUrl, kind, now())
+ subscriptions.insert(entity)
+ val stored = subscriptions.getByFeedUrl(feedUrl) ?: entity
+ applyEpisodes(stored.id, fetched.feed)
+ subscriptions.recordSuccess(stored.id, now(), fetched.etag, fetched.lastModified)
+ Result.success(stored)
+ }
+ }
+ }
+
+ suspend fun refresh(subscription: SubscriptionEntity): RefreshOutcome =
+ when (val result = fetcher.fetch(subscription.feedUrl, subscription.etag, subscription.lastModified)) {
+ FeedFetchResult.NotModified -> {
+ subscriptions.recordSuccess(subscription.id, now(), subscription.etag, subscription.lastModified)
+ RefreshOutcome.Unchanged
+ }
+
+ is FeedFetchResult.Failed -> {
+ subscriptions.recordFailure(subscription.id, result.message, now())
+ RefreshOutcome.Failed(result.message)
+ }
+
+ is FeedFetchResult.Fetched -> {
+ val counts = applyEpisodes(subscription.id, result.feed)
+ subscriptions.update(
+ subscription.copy(
+ title = result.feed.title ?: subscription.title,
+ description = result.feed.description ?: subscription.description,
+ author = result.feed.author ?: subscription.author,
+ imageUrl = result.feed.imageUrl ?: subscription.imageUrl,
+ link = result.feed.link ?: subscription.link,
+ )
+ )
+ subscriptions.recordSuccess(subscription.id, now(), result.etag, result.lastModified)
+ counts
+ }
+ }
+
+ suspend fun refreshAll(): Map<String, RefreshOutcome> =
+ subscriptions.getAll().associate { it.id to refresh(it) }
+
+ private suspend fun applyEpisodes(subscriptionId: String, feed: ParsedFeed): RefreshOutcome.Updated {
+ val stored = episodes.getForSubscription(subscriptionId)
+ val result = EpisodeMatcher.match(subscriptionId, stored, feed.episodes, now())
+ if (result.inserts.isNotEmpty()) episodes.insertAll(result.inserts)
+ if (result.updates.isNotEmpty()) episodes.updateAll(result.updates)
+ return RefreshOutcome.Updated(result.inserts.size, result.updates.size)
+ }
+
+ private fun ParsedFeed.toSubscription(feedUrl: String, kind: SubscriptionKind, nowMs: Long) =
+ SubscriptionEntity(
+ id = UUID.randomUUID().toString(),
+ feedUrl = feedUrl,
+ kind = kind,
+ title = title?.takeIf { it.isNotBlank() } ?: feedUrl,
+ description = description,
+ author = author,
+ imageUrl = imageUrl,
+ link = link,
+ addedAtMs = nowMs,
+ )
+}
diff --git a/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt b/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt
new file mode 100644
index 0000000..631f520
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/feed/FeedFetcher.kt
@@ -0,0 +1,77 @@
+package org.terst.listen.feed
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.IOException
+
+sealed interface FeedFetchResult {
+ data class Fetched(val feed: ParsedFeed, val etag: String?, val lastModified: String?) : FeedFetchResult
+ data object NotModified : FeedFetchResult
+ data class Failed(val message: String) : FeedFetchResult
+}
+
+/**
+ * Fetches a feed, honouring HTTP validators.
+ *
+ * Failures are returned, never thrown away. A subscription that has quietly stopped updating is
+ * indistinguishable from one with no new episodes unless the reason is carried back and recorded.
+ */
+class FeedFetcher(private val client: OkHttpClient) {
+
+ suspend fun fetch(url: String, etag: String?, lastModified: String?): FeedFetchResult =
+ withContext(Dispatchers.IO) {
+ val request = Request.Builder()
+ .url(url)
+ .header("User-Agent", USER_AGENT)
+ .apply {
+ // Sending these turns the common case -- nothing published since last time --
+ // into a bodyless 304 rather than a full re-download of every feed.
+ etag?.let { header("If-None-Match", it) }
+ lastModified?.let { header("If-Modified-Since", it) }
+ }
+ .build()
+
+ try {
+ client.newCall(request).execute().use { response ->
+ when {
+ response.code == NOT_MODIFIED -> FeedFetchResult.NotModified
+ !response.isSuccessful ->
+ FeedFetchResult.Failed("HTTP ${response.code} ${response.message}".trim())
+ else -> parse(response.body?.byteStream(), response.header("ETag"),
+ response.header("Last-Modified"))
+ }
+ }
+ } catch (e: IOException) {
+ FeedFetchResult.Failed(e.message ?: e.javaClass.simpleName)
+ } catch (e: IllegalArgumentException) {
+ FeedFetchResult.Failed("Not a usable feed URL: ${e.message}")
+ }
+ }
+
+ private fun parse(stream: java.io.InputStream?, etag: String?, lastModified: String?): FeedFetchResult {
+ if (stream == null) return FeedFetchResult.Failed("Empty response")
+ // Deliberately broad. This parses arbitrary bytes from an arbitrary server, and a
+ // refresh sweeps every subscription in one pass -- one publisher serving something
+ // malformed must degrade to a recorded error on that feed, not take down the batch.
+ @Suppress("TooGenericExceptionCaught")
+ val feed = try {
+ PodcastFeedParser.parse(stream)
+ } catch (e: Exception) {
+ return FeedFetchResult.Failed("Could not parse feed: ${e.message ?: e.javaClass.simpleName}")
+ }
+ // A 200 carrying a parked domain or a login wall parses as XML with nothing in it. A real
+ // show that has not published yet still has a title, so requiring one of the two
+ // distinguishes "no episodes yet" from "this is not a feed".
+ if (feed.title == null && feed.episodes.isEmpty()) {
+ return FeedFetchResult.Failed("Response was not a podcast feed")
+ }
+ return FeedFetchResult.Fetched(feed, etag, lastModified)
+ }
+
+ private companion object {
+ const val NOT_MODIFIED = 304
+ const val USER_AGENT = "Listen/1.0 (Android podcast client)"
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/data/EpisodeMatcherTest.kt b/app/src/test/java/org/terst/listen/data/EpisodeMatcherTest.kt
new file mode 100644
index 0000000..7012c30
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/EpisodeMatcherTest.kt
@@ -0,0 +1,207 @@
+package org.terst.listen.data
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.terst.listen.feed.ParsedEpisode
+import org.junit.Test
+
+/**
+ * Episode identity is what a playback position hangs off. Publishers rewrite feeds -- new CDN,
+ * new hosting, a migration that regenerates every guid -- and when that happens the app must
+ * still recognise the episode rather than present a fresh copy and lose where the listener was.
+ */
+class EpisodeMatcherTest {
+
+ private val subId = "sub-1"
+
+ private fun stored(
+ guid: String,
+ mediaUrl: String = "https://cdn.example.com/1.mp3",
+ title: String = "Episode One",
+ publishedAtMs: Long? = 1_000_000L,
+ positionMs: Long = 0,
+ ) = EpisodeEntity(
+ id = "ep-$guid",
+ subscriptionId = subId,
+ guid = guid,
+ title = title,
+ mediaUrl = mediaUrl,
+ mediaType = "audio/mpeg",
+ publishedAtMs = publishedAtMs,
+ discoveredAtMs = 0,
+ positionMs = positionMs,
+ )
+
+ private fun parsed(
+ guid: String,
+ mediaUrl: String = "https://cdn.example.com/1.mp3",
+ title: String = "Episode One",
+ publishedAtMs: Long? = 1_000_000L,
+ durationMs: Long? = null,
+ ) = ParsedEpisode(
+ guid = guid,
+ title = title,
+ description = null,
+ mediaUrl = mediaUrl,
+ mediaType = "audio/mpeg",
+ mediaLengthBytes = null,
+ durationMs = durationMs,
+ publishedAtMs = publishedAtMs,
+ seasonNumber = null,
+ episodeNumber = null,
+ imageUrl = null,
+ chaptersUrl = null,
+ )
+
+ @Test
+ fun `a new episode is an insert`() {
+ val result = EpisodeMatcher.match(subId, emptyList(), listOf(parsed("a")), nowMs = 5)
+ assertEquals(1, result.inserts.size)
+ assertTrue(result.updates.isEmpty())
+ assertEquals("a", result.inserts.single().guid)
+ assertEquals(5L, result.inserts.single().discoveredAtMs)
+ }
+
+ @Test
+ fun `an unchanged episode produces no work`() {
+ val existing = listOf(stored("a"))
+ val result = EpisodeMatcher.match(subId, existing, listOf(parsed("a")), nowMs = 5)
+ assertTrue(result.inserts.isEmpty())
+ assertTrue("expected no update for identical content", result.updates.isEmpty())
+ }
+
+ @Test
+ fun `matches on guid even when the media url has moved`() {
+ val existing = listOf(stored("a", mediaUrl = "https://old-cdn.example.com/1.mp3"))
+ val result = EpisodeMatcher.match(
+ subId, existing, listOf(parsed("a", mediaUrl = "https://new-cdn.example.com/1.mp3")), nowMs = 5
+ )
+ assertTrue("a moved file is not a new episode", result.inserts.isEmpty())
+ assertEquals("https://new-cdn.example.com/1.mp3", result.updates.single().mediaUrl)
+ }
+
+ /**
+ * The case that costs a listener their place: a publisher regenerates every guid. The
+ * enclosure URL still identifies the same file.
+ */
+ @Test
+ fun `falls back to media url when the guid changes`() {
+ val existing = listOf(stored("old-guid", positionMs = 942_000))
+ val result = EpisodeMatcher.match(subId, existing, listOf(parsed("brand-new-guid")), nowMs = 5)
+ assertTrue("a regenerated guid must not create a duplicate", result.inserts.isEmpty())
+ val updated = result.updates.single()
+ assertEquals("brand-new-guid", updated.guid)
+ assertEquals("the listener's position must survive a guid change", 942_000L, updated.positionMs)
+ }
+
+ /** Last resort: both identifiers changed, but it is plainly the same episode. */
+ @Test
+ fun `falls back to title and date when guid and url both change`() {
+ val existing = listOf(stored("old", mediaUrl = "https://old/1.mp3", positionMs = 500))
+ val result = EpisodeMatcher.match(
+ subId, existing, listOf(parsed("new", mediaUrl = "https://new/2.mp3")), nowMs = 5
+ )
+ assertTrue(result.inserts.isEmpty())
+ assertEquals(500L, result.updates.single().positionMs)
+ }
+
+ /** Same title, different date, is a different episode -- a weekly show with a fixed title. */
+ @Test
+ fun `same title on a different date is a new episode`() {
+ val existing = listOf(stored("old", mediaUrl = "https://old/1.mp3", publishedAtMs = 1_000_000))
+ val result = EpisodeMatcher.match(
+ subId,
+ existing,
+ listOf(parsed("new", mediaUrl = "https://new/2.mp3", publishedAtMs = 9_000_000)),
+ nowMs = 5,
+ )
+ assertEquals(1, result.inserts.size)
+ assertTrue(result.updates.isEmpty())
+ }
+
+ /** Titles are not unique enough to match on alone when neither side carries a date. */
+ @Test
+ fun `undated episodes do not match on title alone`() {
+ val existing = listOf(stored("old", mediaUrl = "https://old/1.mp3", publishedAtMs = null))
+ val result = EpisodeMatcher.match(
+ subId,
+ existing,
+ listOf(parsed("new", mediaUrl = "https://new/2.mp3", publishedAtMs = null)),
+ nowMs = 5,
+ )
+ assertEquals(1, result.inserts.size)
+ }
+
+ @Test
+ fun `updates metadata that changed`() {
+ val existing = listOf(stored("a", title = "Old Title"))
+ val result = EpisodeMatcher.match(
+ subId, existing, listOf(parsed("a", title = "Corrected Title", durationMs = 1234)), nowMs = 5
+ )
+ val updated = result.updates.single()
+ assertEquals("Corrected Title", updated.title)
+ assertEquals(1234L, updated.durationMs)
+ }
+
+ /**
+ * Listener state is the app's only irreplaceable data. A feed refresh must never touch it.
+ */
+ @Test
+ fun `a refresh never disturbs listener state`() {
+ val existing = listOf(
+ stored("a", title = "Old").copy(
+ positionMs = 123_456,
+ playedAtMs = 777,
+ downloadState = DownloadState.DOWNLOADED,
+ localPath = "/data/a.mp3",
+ downloadedAtMs = 888,
+ id = "stable-id",
+ )
+ )
+ val updated = EpisodeMatcher.match(
+ subId, existing, listOf(parsed("a", title = "New")), nowMs = 5
+ ).updates.single()
+
+ assertEquals("stable-id", updated.id)
+ assertEquals(123_456L, updated.positionMs)
+ assertEquals(777L, updated.playedAtMs)
+ assertEquals(DownloadState.DOWNLOADED, updated.downloadState)
+ assertEquals("/data/a.mp3", updated.localPath)
+ assertEquals(888L, updated.downloadedAtMs)
+ assertEquals("the discovery date is when we first saw it, not now", 0L, updated.discoveredAtMs)
+ }
+
+ /** A truncated feed page is normal; absence is not deletion. */
+ @Test
+ fun `episodes missing from the feed are left alone`() {
+ val existing = listOf(stored("a"), stored("b"))
+ val result = EpisodeMatcher.match(subId, existing, listOf(parsed("a")), nowMs = 5)
+ assertTrue(result.inserts.isEmpty())
+ assertTrue(result.updates.isEmpty())
+ }
+
+ /** Two feed items resolving to one stored episode must not both claim it. */
+ @Test
+ fun `one stored episode is claimed only once`() {
+ val existing = listOf(stored("a", mediaUrl = "https://cdn/1.mp3"))
+ val result = EpisodeMatcher.match(
+ subId,
+ existing,
+ listOf(parsed("a", mediaUrl = "https://cdn/1.mp3"), parsed("z", mediaUrl = "https://cdn/1.mp3")),
+ nowMs = 5,
+ )
+ assertEquals("the second item is a distinct episode", 1, result.inserts.size)
+ assertTrue(result.updates.size <= 1)
+ }
+
+ @Test
+ fun `generated ids are unique per episode`() {
+ val result = EpisodeMatcher.match(
+ subId,
+ emptyList(),
+ listOf(parsed("a", mediaUrl = "https://cdn/1.mp3"), parsed("b", mediaUrl = "https://cdn/2.mp3")),
+ nowMs = 5,
+ )
+ assertEquals(2, result.inserts.map { it.id }.distinct().size)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
new file mode 100644
index 0000000..e28786c
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -0,0 +1,138 @@
+package org.terst.listen.data
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+
+/**
+ * In-memory stand-ins so repository behaviour is testable on the JVM.
+ *
+ * They implement the same contracts Room generates, including the parts that matter here:
+ * insert ignores an existing primary key, and update replaces by id.
+ */
+class FakeSubscriptionDao : SubscriptionDao {
+ val rows = mutableListOf<SubscriptionEntity>()
+
+ override fun observeAll(): Flow<List<SubscriptionEntity>> = flowOf(rows.toList())
+ override suspend fun getAll() = rows.toList()
+ override suspend fun getById(id: String) = rows.firstOrNull { it.id == id }
+ override suspend fun getByFeedUrl(feedUrl: String) = rows.firstOrNull { it.feedUrl == feedUrl }
+
+ override suspend fun insert(subscription: SubscriptionEntity): Long {
+ if (rows.any { it.id == subscription.id || it.feedUrl == subscription.feedUrl }) return -1
+ rows += subscription
+ return rows.size.toLong()
+ }
+
+ override suspend fun update(subscription: SubscriptionEntity) {
+ val index = rows.indexOfFirst { it.id == subscription.id }
+ if (index >= 0) rows[index] = subscription
+ }
+
+ override suspend fun delete(id: String) {
+ rows.removeAll { it.id == id }
+ }
+
+ override suspend fun recordFailure(id: String, error: String, atMs: Long) {
+ replace(id) { it.copy(lastError = error, lastFetchedAtMs = atMs) }
+ }
+
+ override suspend fun recordSuccess(id: String, atMs: Long, etag: String?, lastModified: String?) {
+ replace(id) { it.copy(lastError = null, lastFetchedAtMs = atMs, etag = etag, lastModified = lastModified) }
+ }
+
+ private fun replace(id: String, transform: (SubscriptionEntity) -> SubscriptionEntity) {
+ val index = rows.indexOfFirst { it.id == id }
+ if (index >= 0) rows[index] = transform(rows[index])
+ }
+}
+
+class FakeEpisodeDao : EpisodeDao {
+ val rows = mutableListOf<EpisodeEntity>()
+
+ override fun observeForSubscription(subscriptionId: String): Flow<List<EpisodeEntity>> =
+ flowOf(rows.filter { it.subscriptionId == subscriptionId })
+
+ override suspend fun getForSubscription(subscriptionId: String) =
+ rows.filter { it.subscriptionId == subscriptionId }
+
+ override suspend fun getById(id: String) = rows.firstOrNull { it.id == id }
+
+ override suspend fun insertAll(episodes: List<EpisodeEntity>) {
+ episodes.forEach { candidate ->
+ val clash = rows.any {
+ it.id == candidate.id ||
+ (it.subscriptionId == candidate.subscriptionId && it.guid == candidate.guid)
+ }
+ if (!clash) rows += candidate
+ }
+ }
+
+ override suspend fun updateAll(episodes: List<EpisodeEntity>) {
+ episodes.forEach { updated ->
+ val index = rows.indexOfFirst { it.id == updated.id }
+ if (index >= 0) rows[index] = updated
+ }
+ }
+
+ override suspend fun updatePosition(id: String, positionMs: Long) {
+ replace(id) { it.copy(positionMs = positionMs) }
+ }
+
+ override suspend fun markPlayed(id: String, atMs: Long) {
+ replace(id) { it.copy(playedAtMs = atMs, positionMs = 0) }
+ }
+
+ override suspend fun markUnplayed(id: String) {
+ replace(id) { it.copy(playedAtMs = null) }
+ }
+
+ override suspend fun updateDownload(
+ id: String,
+ state: DownloadState,
+ localPath: String?,
+ downloadedAtMs: Long?,
+ ) {
+ replace(id) { it.copy(downloadState = state, localPath = localPath, downloadedAtMs = downloadedAtMs) }
+ }
+
+ override suspend fun playedDownloadsBefore(before: Long, downloaded: DownloadState) =
+ rows.filter {
+ it.downloadState == downloaded && it.playedAtMs != null && it.playedAtMs!! < before
+ }
+
+ override fun observeUnplayed(limit: Int): Flow<List<EpisodeEntity>> =
+ flowOf(rows.filter { it.playedAtMs == null }.take(limit))
+
+ private fun replace(id: String, transform: (EpisodeEntity) -> EpisodeEntity) {
+ val index = rows.indexOfFirst { it.id == id }
+ if (index >= 0) rows[index] = transform(rows[index])
+ }
+}
+
+class FakeQueueDao : QueueDao {
+ val entries = mutableListOf<QueueEntity>()
+ var episodeSource: () -> List<EpisodeEntity> = { emptyList() }
+
+ private fun ordered(): List<EpisodeEntity> {
+ val byId = episodeSource().associateBy { it.id }
+ return entries.sortedBy { it.position }.mapNotNull { byId[it.episodeId] }
+ }
+
+ override fun observeQueue(): Flow<List<EpisodeEntity>> = flowOf(ordered())
+ override suspend fun getQueue() = ordered()
+ override suspend fun getEntries() = entries.sortedBy { it.position }
+
+ override suspend fun upsertAll(entries: List<QueueEntity>) {
+ entries.forEach { entry ->
+ this.entries.removeAll { it.episodeId == entry.episodeId }
+ this.entries += entry
+ }
+ }
+
+ override suspend fun remove(episodeId: String) {
+ entries.removeAll { it.episodeId == episodeId }
+ }
+
+ override suspend fun clear() = entries.clear()
+ override suspend fun size() = entries.size
+}
diff --git a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
new file mode 100644
index 0000000..2008363
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
@@ -0,0 +1,165 @@
+package org.terst.listen.data
+
+import kotlinx.coroutines.test.runTest
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.feed.FeedFetcher
+
+/**
+ * Exercised against fake DAOs rather than Room, so the reconciliation and failure-recording
+ * behaviour is testable off-device. Room's own behaviour is covered by the migration and DAO
+ * instrumented tests.
+ */
+class SubscriptionRepositoryTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var subs: FakeSubscriptionDao
+ private lateinit var eps: FakeEpisodeDao
+ private lateinit var repo: SubscriptionRepository
+ private var clock = 1_000L
+
+ private fun feed(items: String = oneItem, title: String = "A Show") = """<?xml version="1.0"?>
+ <rss version="2.0"><channel>
+ <title>$title</title><link>https://example.com</link><description>d</description>
+ $items
+ </channel></rss>""".trimIndent()
+
+ private val oneItem = """<item><title>One</title><guid>g1</guid>
+ <enclosure url="https://cdn/1.mp3" length="10" type="audio/mpeg"/></item>"""
+
+ private val twoItems = oneItem + """<item><title>Two</title><guid>g2</guid>
+ <enclosure url="https://cdn/2.mp3" length="10" type="audio/mpeg"/></item>"""
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ subs = FakeSubscriptionDao()
+ eps = FakeEpisodeDao()
+ repo = SubscriptionRepository(subs, eps, FeedFetcher(OkHttpClient())) { clock }
+ }
+
+ @After
+ fun tearDown() = server.shutdown()
+
+ private fun url() = server.url("/feed.xml").toString()
+
+ @Test
+ fun `subscribing stores the feed and its episodes`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ val result = repo.subscribe(url())
+ assertTrue(result.isSuccess)
+ assertEquals("A Show", subs.rows.single().title)
+ assertEquals(1, eps.rows.size)
+ }
+
+ @Test
+ fun `subscribing twice does not duplicate`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ val second = repo.subscribe(url())
+ assertTrue(second.isSuccess)
+ assertEquals("a repeated subscribe must be idempotent", 1, subs.rows.size)
+ }
+
+ @Test
+ fun `a failing feed does not create a subscription`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(404))
+ val result = repo.subscribe(url())
+ assertTrue(result.isFailure)
+ assertTrue(subs.rows.isEmpty())
+ }
+
+ @Test
+ fun `refresh adds newly published episodes`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setBody(feed(twoItems)))
+
+ val outcome = repo.refresh(subs.rows.single())
+ assertEquals(RefreshOutcome.Updated(newEpisodes = 1, updatedEpisodes = 0), outcome)
+ assertEquals(2, eps.rows.size)
+ }
+
+ @Test
+ fun `a 304 is reported as unchanged`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()).setHeader("ETag", "\"v1\""))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setResponseCode(304))
+
+ assertEquals(RefreshOutcome.Unchanged, repo.refresh(subs.rows.single()))
+ assertEquals("a 304 must not drop stored episodes", 1, eps.rows.size)
+ }
+
+ /** The invisible-failure case: a feed that stops working must say so in the subscription. */
+ @Test
+ fun `a failed refresh is recorded against the subscription`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setResponseCode(500))
+
+ val outcome = repo.refresh(subs.rows.single())
+ assertTrue(outcome is RefreshOutcome.Failed)
+ val stored = subs.rows.single()
+ assertNotNull("the reason must be stored, not just logged", stored.lastError)
+ assertTrue(stored.lastError!!.contains("500"))
+ }
+
+ @Test
+ fun `a later success clears the recorded failure`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setResponseCode(500))
+ repo.refresh(subs.rows.single())
+ assertNotNull(subs.rows.single().lastError)
+
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.refresh(subs.rows.single())
+ assertNull("a recovered feed must not keep showing an error", subs.rows.single().lastError)
+ }
+
+ @Test
+ fun `a failed refresh keeps the existing episodes`() = runTest {
+ server.enqueue(MockResponse().setBody(feed(twoItems)))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setResponseCode(500))
+ repo.refresh(subs.rows.single())
+ assertEquals("a transient outage must not empty the library", 2, eps.rows.size)
+ }
+
+ @Test
+ fun `refresh stores new validators`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setBody(feed()).setHeader("ETag", "\"v2\""))
+ repo.refresh(subs.rows.single())
+ assertEquals("\"v2\"", subs.rows.single().etag)
+ }
+
+ @Test
+ fun `refresh picks up a renamed show`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setBody(feed(title = "Renamed Show")))
+ repo.refresh(subs.rows.single())
+ assertEquals("Renamed Show", subs.rows.single().title)
+ }
+
+ @Test
+ fun `refreshAll reports per subscription`() = runTest {
+ server.enqueue(MockResponse().setBody(feed()))
+ repo.subscribe(url())
+ server.enqueue(MockResponse().setResponseCode(500))
+ val outcomes = repo.refreshAll()
+ assertEquals(1, outcomes.size)
+ assertTrue(outcomes.values.single() is RefreshOutcome.Failed)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt b/app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt
new file mode 100644
index 0000000..75b58d7
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/feed/FeedFetcherTest.kt
@@ -0,0 +1,137 @@
+package org.terst.listen.feed
+
+import kotlinx.coroutines.test.runTest
+import okhttp3.OkHttpClient
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+
+class FeedFetcherTest {
+
+ private lateinit var server: MockWebServer
+ private lateinit var fetcher: FeedFetcher
+
+ private val feedXml = """<?xml version="1.0"?>
+ <rss version="2.0"><channel>
+ <title>A Show</title><link>https://example.com</link><description>d</description>
+ <item><title>One</title><guid>g1</guid>
+ <enclosure url="https://cdn/1.mp3" length="10" type="audio/mpeg"/></item>
+ </channel></rss>""".trimIndent()
+
+ @Before
+ fun setUp() {
+ server = MockWebServer()
+ server.start()
+ fetcher = FeedFetcher(OkHttpClient())
+ }
+
+ @After
+ fun tearDown() = server.shutdown()
+
+ @Test
+ fun `fetches and parses a feed`() = runTest {
+ server.enqueue(MockResponse().setBody(feedXml))
+ val result = fetcher.fetch(server.url("/feed.xml").toString(), null, null)
+ val fresh = result as FeedFetchResult.Fetched
+ assertEquals("A Show", fresh.feed.title)
+ assertEquals(1, fresh.feed.episodes.size)
+ }
+
+ @Test
+ fun `captures validators for the next request`() = runTest {
+ server.enqueue(
+ MockResponse().setBody(feedXml)
+ .setHeader("ETag", "\"abc\"")
+ .setHeader("Last-Modified", "Tue, 02 Sep 2026 06:00:00 GMT")
+ )
+ val fresh = fetcher.fetch(server.url("/f").toString(), null, null) as FeedFetchResult.Fetched
+ assertEquals("\"abc\"", fresh.etag)
+ assertEquals("Tue, 02 Sep 2026 06:00:00 GMT", fresh.lastModified)
+ }
+
+ /**
+ * Most feeds are unchanged most of the time. Sending validators turns a routine refresh into
+ * a bodyless 304, which matters when this runs across every subscription on a phone.
+ */
+ @Test
+ fun `sends stored validators`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(304))
+ fetcher.fetch(server.url("/f").toString(), "\"abc\"", "Tue, 02 Sep 2026 06:00:00 GMT")
+ val request = server.takeRequest()
+ assertEquals("\"abc\"", request.getHeader("If-None-Match"))
+ assertEquals("Tue, 02 Sep 2026 06:00:00 GMT", request.getHeader("If-Modified-Since"))
+ }
+
+ @Test
+ fun `304 reports not modified rather than an empty feed`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(304))
+ val result = fetcher.fetch(server.url("/f").toString(), "\"abc\"", null)
+ assertTrue("a 304 must not look like a feed that lost all its episodes",
+ result is FeedFetchResult.NotModified)
+ }
+
+ @Test
+ fun `omits validators when none are stored`() = runTest {
+ server.enqueue(MockResponse().setBody(feedXml))
+ fetcher.fetch(server.url("/f").toString(), null, null)
+ val request = server.takeRequest()
+ assertNull(request.getHeader("If-None-Match"))
+ assertNull(request.getHeader("If-Modified-Since"))
+ }
+
+ /** A failure has to be reportable, so it can be recorded against the subscription. */
+ @Test
+ fun `a server error is a failure carrying its status`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(500))
+ val result = fetcher.fetch(server.url("/f").toString(), null, null)
+ val failed = result as FeedFetchResult.Failed
+ assertTrue("message should name the status: ${failed.message}", failed.message.contains("500"))
+ }
+
+ @Test
+ fun `a 404 is a failure`() = runTest {
+ server.enqueue(MockResponse().setResponseCode(404))
+ assertTrue(fetcher.fetch(server.url("/f").toString(), null, null) is FeedFetchResult.Failed)
+ }
+
+ @Test
+ fun `an unreachable host is a failure not a crash`() = runTest {
+ val url = server.url("/f").toString()
+ server.shutdown()
+ assertTrue(fetcher.fetch(url, null, null) is FeedFetchResult.Failed)
+ }
+
+ /** A 200 carrying HTML is a redirect to a parked domain or a login wall, not a feed. */
+ @Test
+ fun `a response with no episodes and no title is a failure`() = runTest {
+ server.enqueue(MockResponse().setBody("<html><body>Domain parked</body></html>"))
+ val result = fetcher.fetch(server.url("/f").toString(), null, null)
+ assertTrue("HTML served as a feed should not count as success", result is FeedFetchResult.Failed)
+ }
+
+ /** A real show that has published nothing yet is still a valid feed. */
+ @Test
+ fun `an empty but titled feed is a success`() = runTest {
+ server.enqueue(
+ MockResponse().setBody(
+ """<?xml version="1.0"?><rss version="2.0"><channel><title>New Show</title>
+ <link>https://e.com</link><description>d</description></channel></rss>""".trimIndent()
+ )
+ )
+ val result = fetcher.fetch(server.url("/f").toString(), null, null)
+ assertTrue(result is FeedFetchResult.Fetched)
+ }
+
+ @Test
+ fun `identifies itself`() = runTest {
+ server.enqueue(MockResponse().setBody(feedXml))
+ fetcher.fetch(server.url("/f").toString(), null, null)
+ val ua = server.takeRequest().getHeader("User-Agent").orEmpty()
+ assertTrue("should send a real user agent, got '$ua'", ua.contains("Listen", ignoreCase = true))
+ }
+}