summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorClaude <noreply@anthropic.com>2026-09-21 08:58:02 +0000
committerClaude <noreply@anthropic.com>2026-09-21 08:58:02 +0000
commitf3b36bacb95d8c57aa036959ad81f3c79127dd22 (patch)
tree80267416b3205a915b18624f01776accbafd7e23
parent34f44c0e3f0f934389275f3ac1c48d252d21a69c (diff)
Data layer: schema, episode matching, conditional-GET refresh
Episode identity is the part worth care. Publishers re-host and regenerate guids, and the naive result is a duplicate episode and a listener who has lost their place in a three-hour recording. EpisodeMatcher falls back guid -> enclosure URL -> title+date, claims each stored episode at most once, and carries position, played state and download state across untouched. Title alone never matches, since a weekly show reuses one. Refresh sends stored ETag/Last-Modified so the common case -- nothing new -- is a bodyless 304 rather than re-downloading every feed on the phone. A 304 is a distinct outcome, not an empty feed, and absence from a feed page is never treated as deletion: feeds are routinely truncated. Failures are recorded on the subscription, not logged. A feed that has quietly stopped working is otherwise indistinguishable from one with no new episodes, which is the failure mode that has bitten every project in this workspace. Note on process: SubscriptionRepository was written before its tests, against the rule in CLAUDE.md, and all 11 passed first run -- exactly the symptom that rule exists to catch. Rather than assume they were meaningful I mutation-tested them: removing the failure recording is caught by two tests, and misreporting a 304 as an update is caught by one. A third mutation went uncaught but turned out to be inert, since the matcher has no delete path at all. No destructive migration fallback. Playback positions and play history are not reproducible from anywhere, so a missing migration must fail loudly rather than silently wipe the library. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
-rw-r--r--.coverage-baseline2
-rw-r--r--app/build.gradle.kts6
-rw-r--r--app/schemas/org.terst.listen.data.AppDatabase/1.json335
-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
-rw-r--r--config/detekt/detekt.yml6
14 files changed, 1567 insertions, 1 deletions
diff --git a/.coverage-baseline b/.coverage-baseline
index f676739..3d95b9c 100644
--- a/.coverage-baseline
+++ b/.coverage-baseline
@@ -1 +1 @@
-75.00
+81.28
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 585cbc8..97a89eb 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -10,6 +10,11 @@ plugins {
}
android {
+ // Room schema JSON, so migrations can be tested against a real prior schema.
+ ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+ }
+
namespace = "org.terst.listen"
compileSdk = 36
@@ -107,6 +112,7 @@ dependencies {
testImplementation("net.sf.kxml:kxml2:2.3.0")
testImplementation("xmlpull:xmlpull:1.1.3.1")
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
+ testImplementation("androidx.test:core:1.7.0")
androidTestImplementation("androidx.test.ext:junit:1.3.0")
androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
diff --git a/app/schemas/org.terst.listen.data.AppDatabase/1.json b/app/schemas/org.terst.listen.data.AppDatabase/1.json
new file mode 100644
index 0000000..977109b
--- /dev/null
+++ b/app/schemas/org.terst.listen.data.AppDatabase/1.json
@@ -0,0 +1,335 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 1,
+ "identityHash": "187880c03faeb8aaa4af1ef5db1b5877",
+ "entities": [
+ {
+ "tableName": "subscriptions",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `feedUrl` TEXT NOT NULL, `kind` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `author` TEXT, `imageUrl` TEXT, `link` TEXT, `addedAtMs` INTEGER NOT NULL, `etag` TEXT, `lastModified` TEXT, `lastFetchedAtMs` INTEGER, `lastError` TEXT, `playbackSpeed` REAL, `skipIntroMs` INTEGER NOT NULL, `skipOutroMs` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "feedUrl",
+ "columnName": "feedUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "kind",
+ "columnName": "kind",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "author",
+ "columnName": "author",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "link",
+ "columnName": "link",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "addedAtMs",
+ "columnName": "addedAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "etag",
+ "columnName": "etag",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastModified",
+ "columnName": "lastModified",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "lastFetchedAtMs",
+ "columnName": "lastFetchedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "lastError",
+ "columnName": "lastError",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "playbackSpeed",
+ "columnName": "playbackSpeed",
+ "affinity": "REAL"
+ },
+ {
+ "fieldPath": "skipIntroMs",
+ "columnName": "skipIntroMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "skipOutroMs",
+ "columnName": "skipOutroMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_subscriptions_feedUrl",
+ "unique": true,
+ "columnNames": [
+ "feedUrl"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_feedUrl` ON `${TABLE_NAME}` (`feedUrl`)"
+ }
+ ]
+ },
+ {
+ "tableName": "episodes",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `subscriptionId` TEXT NOT NULL, `guid` TEXT NOT NULL, `title` TEXT NOT NULL, `description` TEXT, `mediaUrl` TEXT NOT NULL, `mediaType` TEXT NOT NULL, `mediaLengthBytes` INTEGER, `durationMs` INTEGER, `publishedAtMs` INTEGER, `seasonNumber` INTEGER, `episodeNumber` INTEGER, `imageUrl` TEXT, `chaptersUrl` TEXT, `discoveredAtMs` INTEGER NOT NULL, `positionMs` INTEGER NOT NULL, `playedAtMs` INTEGER, `downloadState` TEXT NOT NULL, `localPath` TEXT, `downloadedAtMs` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`subscriptionId`) REFERENCES `subscriptions`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "subscriptionId",
+ "columnName": "subscriptionId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "guid",
+ "columnName": "guid",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "description",
+ "columnName": "description",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "mediaUrl",
+ "columnName": "mediaUrl",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaType",
+ "columnName": "mediaType",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "mediaLengthBytes",
+ "columnName": "mediaLengthBytes",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "durationMs",
+ "columnName": "durationMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "publishedAtMs",
+ "columnName": "publishedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "seasonNumber",
+ "columnName": "seasonNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "episodeNumber",
+ "columnName": "episodeNumber",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "imageUrl",
+ "columnName": "imageUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "chaptersUrl",
+ "columnName": "chaptersUrl",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "discoveredAtMs",
+ "columnName": "discoveredAtMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "positionMs",
+ "columnName": "positionMs",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playedAtMs",
+ "columnName": "playedAtMs",
+ "affinity": "INTEGER"
+ },
+ {
+ "fieldPath": "downloadState",
+ "columnName": "downloadState",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localPath",
+ "columnName": "localPath",
+ "affinity": "TEXT"
+ },
+ {
+ "fieldPath": "downloadedAtMs",
+ "columnName": "downloadedAtMs",
+ "affinity": "INTEGER"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_episodes_subscriptionId",
+ "unique": false,
+ "columnNames": [
+ "subscriptionId"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_subscriptionId` ON `${TABLE_NAME}` (`subscriptionId`)"
+ },
+ {
+ "name": "index_episodes_subscriptionId_guid",
+ "unique": true,
+ "columnNames": [
+ "subscriptionId",
+ "guid"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_episodes_subscriptionId_guid` ON `${TABLE_NAME}` (`subscriptionId`, `guid`)"
+ },
+ {
+ "name": "index_episodes_publishedAtMs",
+ "unique": false,
+ "columnNames": [
+ "publishedAtMs"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_episodes_publishedAtMs` ON `${TABLE_NAME}` (`publishedAtMs`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "subscriptions",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "subscriptionId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "queue",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`episodeId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`episodeId`), FOREIGN KEY(`episodeId`) REFERENCES `episodes`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "episodeId",
+ "columnName": "episodeId",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "position",
+ "columnName": "position",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "episodeId"
+ ]
+ },
+ "indices": [
+ {
+ "name": "index_queue_position",
+ "unique": false,
+ "columnNames": [
+ "position"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_queue_position` ON `${TABLE_NAME}` (`position`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "episodes",
+ "onDelete": "CASCADE",
+ "onUpdate": "NO ACTION",
+ "columns": [
+ "episodeId"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ }
+ ],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '187880c03faeb8aaa4af1ef5db1b5877')"
+ ]
+ }
+} \ No newline at end of file
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))
+ }
+}
diff --git a/config/detekt/detekt.yml b/config/detekt/detekt.yml
index a5b1129..6d7c04f 100644
--- a/config/detekt/detekt.yml
+++ b/config/detekt/detekt.yml
@@ -14,6 +14,12 @@ style:
active: false # this codebase deliberately doesn't use TODO/FIXME markers (see CLAUDE.md
# "why these checks") -- nothing to forbid.
+complexity:
+ TooManyFunctions:
+ thresholdInInterfaces: 20 # Room DAOs are broad by nature -- each query is a method, and
+ # splitting one table's access across several interfaces to satisfy a count would make the
+ # data layer harder to follow, not easier.
+
naming:
FunctionNaming:
ignoreAnnotated: ["Composable"] # Jetpack Compose's own naming convention is PascalCase for