diff options
Diffstat (limited to 'app/src')
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)) + } +} |
