summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt65
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt41
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt56
-rw-r--r--app/src/main/java/org/terst/listen/data/Entities.kt44
-rw-r--r--app/src/main/java/org/terst/listen/data/Repositories.kt11
-rw-r--r--app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt26
-rw-r--r--app/src/main/java/org/terst/listen/playback/PlaybackSpeed.kt10
-rw-r--r--app/src/main/java/org/terst/listen/queue/HumanTime.kt37
-rw-r--r--app/src/main/java/org/terst/listen/queue/RuleApplier.kt54
-rw-r--r--app/src/main/java/org/terst/listen/queue/RuleEngine.kt81
-rw-r--r--app/src/main/java/org/terst/listen/queue/RuleSummary.kt57
-rw-r--r--app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt20
-rw-r--r--app/src/main/java/org/terst/listen/ui/ListenViewModel.kt6
-rw-r--r--app/src/main/java/org/terst/listen/ui/MainActivity.kt36
-rw-r--r--app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt6
-rw-r--r--app/src/main/java/org/terst/listen/ui/RuleConditionFields.kt142
-rw-r--r--app/src/main/java/org/terst/listen/ui/RuleEditScreen.kt173
-rw-r--r--app/src/main/java/org/terst/listen/ui/RulesScreen.kt152
-rw-r--r--app/src/main/java/org/terst/listen/ui/RulesViewModel.kt68
-rw-r--r--app/src/main/java/org/terst/listen/ui/SettingsScreen.kt21
-rw-r--r--app/src/main/java/org/terst/listen/work/DownloadDrainer.kt41
-rw-r--r--app/src/main/java/org/terst/listen/work/Workers.kt35
-rw-r--r--app/src/test/java/org/terst/listen/data/FakeDaos.kt38
-rw-r--r--app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt61
-rw-r--r--app/src/test/java/org/terst/listen/playback/PlaybackSpeedTest.kt28
-rw-r--r--app/src/test/java/org/terst/listen/queue/RuleApplierTest.kt218
-rw-r--r--app/src/test/java/org/terst/listen/queue/RuleEngineTest.kt206
-rw-r--r--app/src/test/java/org/terst/listen/queue/RuleSummaryTest.kt111
-rw-r--r--app/src/test/java/org/terst/listen/work/DownloadDrainerTest.kt128
29 files changed, 1958 insertions, 14 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
new file mode 100644
index 0000000..c1c276a
--- /dev/null
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -0,0 +1,65 @@
+package org.terst.listen.data
+
+import androidx.room.testing.MigrationTestHelper
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * Every schema version, migrated forward against a database built at the previous one.
+ *
+ * `fallbackToDestructiveMigration` is deliberately off (see [AppDatabase]): a listener's playback
+ * positions and play history exist nowhere else, so a wrong migration is not an inconvenience,
+ * it is permanent data loss. That makes this the one test that has to run on a device.
+ *
+ * Each case seeds real rows before migrating and reads them back afterwards, because a migration
+ * that creates the right columns and drops the rows still validates.
+ */
+@RunWith(AndroidJUnit4::class)
+class MigrationTest {
+
+ private val name = "migration-test.db"
+
+ @get:Rule
+ val helper = MigrationTestHelper(
+ InstrumentationRegistry.getInstrumentation(),
+ AppDatabase::class.java,
+ )
+
+ @Test
+ fun migrates1To2KeepingEpisodesAndPositions() {
+ helper.createDatabase(name, 1).use { db ->
+ db.execSQL(
+ """INSERT INTO subscriptions
+ (id, feedUrl, kind, title, addedAtMs, skipIntroMs, skipOutroMs)
+ VALUES ('s1', 'https://example.com/feed.xml', 'PODCAST', 'A Show', 1000, 0, 0)"""
+ )
+ db.execSQL(
+ """INSERT INTO episodes
+ (id, subscriptionId, guid, title, mediaUrl, mediaType, discoveredAtMs,
+ positionMs, downloadState)
+ VALUES ('e1', 's1', 'g1', 'One', 'https://cdn/1.mp3', 'audio/mpeg', 1000,
+ 123456, 'NONE')"""
+ )
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 2, true, AppDatabase.MIGRATION_1_2)
+
+ db.query("SELECT positionMs, appliedRuleId, speedOverride FROM episodes WHERE id = 'e1'")
+ .use { cursor ->
+ assertEquals(1, cursor.count)
+ cursor.moveToFirst()
+ assertEquals("the crown jewel must survive", 123456L, cursor.getLong(0))
+ assertEquals(true, cursor.isNull(1))
+ assertEquals(true, cursor.isNull(2))
+ }
+
+ db.query("SELECT COUNT(*) FROM rules").use { cursor ->
+ cursor.moveToFirst()
+ assertEquals(0, cursor.getInt(0))
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/data/AppDatabase.kt b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
index bf7411d..51c57b2 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -6,17 +6,26 @@ import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
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
+ @TypeConverter fun toRuleAction(value: String) = RuleAction.valueOf(value)
+ @TypeConverter fun fromRuleAction(value: RuleAction) = value.name
}
@Database(
- entities = [SubscriptionEntity::class, EpisodeEntity::class, QueueEntity::class],
- version = 1,
+ entities = [
+ SubscriptionEntity::class,
+ EpisodeEntity::class,
+ QueueEntity::class,
+ RuleEntity::class,
+ ],
+ version = 2,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -24,16 +33,44 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun subscriptions(): SubscriptionDao
abstract fun episodes(): EpisodeDao
abstract fun queue(): QueueDao
+ abstract fun rules(): RuleDao
companion object {
@Volatile private var instance: AppDatabase? = null
+ /**
+ * Auto-queue rules, and the record of which rule claimed an episode.
+ *
+ * Written by hand rather than as an auto-migration: the exported schema is the only
+ * description of what is on a phone already, and a hand-written statement validated
+ * against it is easier to reason about than generated SQL nobody reads.
+ */
+ val MIGRATION_1_2 = object : Migration(1, 2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ """CREATE TABLE IF NOT EXISTS `rules` (
+ `id` TEXT NOT NULL, `name` TEXT NOT NULL, `position` INTEGER NOT NULL,
+ `enabled` INTEGER NOT NULL, `subscriptionId` TEXT, `titleContains` TEXT,
+ `titleExcludes` TEXT, `minDurationMs` INTEGER, `maxDurationMs` INTEGER,
+ `maxAgeMs` INTEGER, `seasonNumber` INTEGER, `action` TEXT NOT NULL,
+ `autoDownload` INTEGER NOT NULL, `speed` REAL, PRIMARY KEY(`id`))"""
+ )
+ db.execSQL("CREATE INDEX IF NOT EXISTS `index_rules_position` ON `rules` (`position`)")
+ db.execSQL("ALTER TABLE `episodes` ADD COLUMN `appliedRuleId` TEXT")
+ db.execSQL("ALTER TABLE `episodes` ADD COLUMN `appliedRuleReason` TEXT")
+ db.execSQL("ALTER TABLE `episodes` ADD COLUMN `speedOverride` REAL")
+ }
+ }
+
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")
+ // Listed one by one rather than spread from an array: every migration here is a
+ // deliberate addition, and the list is the record of them.
+ .addMigrations(MIGRATION_1_2)
// 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.
diff --git a/app/src/main/java/org/terst/listen/data/Daos.kt b/app/src/main/java/org/terst/listen/data/Daos.kt
index ea8899b..67f405f 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -111,6 +111,23 @@ interface EpisodeDao {
)
suspend fun markPlayedUpTo(subscriptionId: String, publishedAtMs: Long, atMs: Long)
+ /** Records which auto-queue rule claimed an episode, why, and any speed it asked for. */
+ @Query(
+ """UPDATE episodes SET appliedRuleId = :ruleId, appliedRuleReason = :reason,
+ speedOverride = :speed WHERE id = :id"""
+ )
+ suspend fun recordRule(id: String, ruleId: String, reason: String, speed: Float?)
+
+ /** Downloads a rule asked for, oldest first, so a batch arrives in listening order. */
+ @Query(
+ """SELECT * FROM episodes WHERE downloadState = :queued
+ ORDER BY COALESCE(publishedAtMs, discoveredAtMs) LIMIT :limit"""
+ )
+ suspend fun queuedDownloads(
+ limit: Int,
+ queued: DownloadState = DownloadState.QUEUED,
+ ): List<EpisodeEntity>
+
/** Candidates for retention: played, downloaded, and past their grace period. */
@Query(
"""SELECT * FROM episodes
@@ -123,6 +140,45 @@ interface EpisodeDao {
}
@Dao
+interface RuleDao {
+ @Query("SELECT * FROM rules ORDER BY position")
+ fun observeAll(): Flow<List<RuleEntity>>
+
+ @Query("SELECT * FROM rules ORDER BY position")
+ suspend fun getAll(): List<RuleEntity>
+
+ @Query("SELECT * FROM rules WHERE id = :id")
+ suspend fun getById(id: String): RuleEntity?
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun upsert(rule: RuleEntity)
+
+ @Query("DELETE FROM rules WHERE id = :id")
+ suspend fun delete(id: String)
+
+ /** Where a newly created rule goes: last, so it cannot pre-empt rules already trusted. */
+ @Query("SELECT COALESCE(MAX(position), -1) + 1 FROM rules")
+ suspend fun nextPosition(): Int
+
+ @Query("UPDATE rules SET position = :position WHERE id = :id")
+ suspend fun setPosition(id: String, position: Int)
+
+ @Query("UPDATE rules SET enabled = :enabled WHERE id = :id")
+ suspend fun setEnabled(id: String, enabled: Boolean)
+
+ /**
+ * Rewrites the whole order in one transaction.
+ *
+ * Position decides which rule wins, so a half-applied reorder would silently change what the
+ * automation does rather than just how the list looks.
+ */
+ @Transaction
+ suspend fun reorder(ids: List<String>) {
+ ids.forEachIndexed { index, id -> setPosition(id, index) }
+ }
+}
+
+@Dao
interface QueueDao {
@Query(
"""SELECT e.* FROM episodes e
diff --git a/app/src/main/java/org/terst/listen/data/Entities.kt b/app/src/main/java/org/terst/listen/data/Entities.kt
index d66bb99..6e88fd0 100644
--- a/app/src/main/java/org/terst/listen/data/Entities.kt
+++ b/app/src/main/java/org/terst/listen/data/Entities.kt
@@ -88,6 +88,50 @@ data class EpisodeEntity(
val downloadState: DownloadState = DownloadState.NONE,
val localPath: String? = null,
val downloadedAtMs: Long? = null,
+
+ /** Which auto-queue rule claimed this episode, and the account that rule gave for it. */
+ val appliedRuleId: String? = null,
+ val appliedRuleReason: String? = null,
+ /** A speed set for this episode alone by a rule, taking precedence over the show's. */
+ val speedOverride: Float? = null,
+)
+
+/** What an auto-queue rule does to an episode that matches it. */
+enum class RuleAction { QUEUE_TOP, QUEUE_BOTTOM, MARK_PLAYED }
+
+/**
+ * One auto-queue rule: conditions on a newly discovered episode, and what to do when they hold.
+ *
+ * Every condition is nullable and null means "don't care", so a rule with none matches every
+ * episode. Rules are evaluated in [position] order and the first match wins -- an episode gets
+ * one outcome, not a pile of conflicting ones.
+ */
+@Entity(tableName = "rules", indices = [Index(value = ["position"])])
+data class RuleEntity(
+ @PrimaryKey val id: String,
+ val name: String,
+ val position: Int,
+ val enabled: Boolean = true,
+
+ /** Null for every show. */
+ val subscriptionId: String? = null,
+ val titleContains: String? = null,
+ val titleExcludes: String? = null,
+ val minDurationMs: Long? = null,
+ val maxDurationMs: Long? = null,
+ /** How recently the episode must have been published to qualify. */
+ val maxAgeMs: Long? = null,
+ val seasonNumber: Int? = null,
+
+ val action: RuleAction,
+ val autoDownload: Boolean = false,
+ /**
+ * Playback speed for episodes this rule catches, or null to leave it alone.
+ *
+ * Lands on the episode rather than on the show: one matching episode must not silently
+ * re-set the speed of everything else the show publishes.
+ */
+ val speed: Float? = null,
)
/**
diff --git a/app/src/main/java/org/terst/listen/data/Repositories.kt b/app/src/main/java/org/terst/listen/data/Repositories.kt
index 94c8fdf..cbc4807 100644
--- a/app/src/main/java/org/terst/listen/data/Repositories.kt
+++ b/app/src/main/java/org/terst/listen/data/Repositories.kt
@@ -10,6 +10,7 @@ import org.terst.listen.feed.FeedFetcher
import org.terst.listen.feed.FeedResolver
import org.terst.listen.queue.QueueOrdering
import org.terst.listen.queue.RetentionPolicy
+import org.terst.listen.queue.RuleApplier
import java.io.File
import java.util.concurrent.TimeUnit
@@ -42,6 +43,7 @@ class AppGraph(private val context: Context) {
val subscriptions: SubscriptionDao get() = db.subscriptions()
val episodes: EpisodeDao get() = db.episodes()
val queueDao: QueueDao get() = db.queue()
+ val ruleDao: RuleDao get() = db.rules()
val libraryDir: File by lazy {
File(context.filesDir, "audio").also { it.mkdirs() }
@@ -49,8 +51,15 @@ class AppGraph(private val context: Context) {
val resolver: FeedResolver by lazy { FeedResolver(client) }
+ val ruleApplier: RuleApplier by lazy { RuleApplier(ruleDao, episodes, queue) }
+
val repository: SubscriptionRepository by lazy {
- SubscriptionRepository(subscriptions, episodes, FeedFetcher(client))
+ SubscriptionRepository(
+ subscriptions,
+ episodes,
+ FeedFetcher(client),
+ onDiscovered = { ruleApplier.apply(it) },
+ )
}
val downloader: EpisodeDownloader by lazy {
diff --git a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
index b1b2f18..6ffe874 100644
--- a/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
+++ b/app/src/main/java/org/terst/listen/data/SubscriptionRepository.kt
@@ -20,6 +20,13 @@ class SubscriptionRepository(
private val subscriptions: SubscriptionDao,
private val episodes: EpisodeDao,
private val fetcher: FeedFetcher,
+ /**
+ * Told about episodes a refresh has just discovered, so the auto-queue rules can act on them.
+ *
+ * A collaborator rather than a call into the queue, so this class never learns what a queue
+ * is. Deliberately not invoked by [subscribe] or [addSingleEpisode]: see their comments.
+ */
+ private val onDiscovered: suspend (List<EpisodeEntity>) -> Unit = {},
private val now: () -> Long = System::currentTimeMillis,
) {
@@ -43,7 +50,7 @@ class SubscriptionRepository(
val entity = fetched.feed.toSubscription(feedUrl, kind, now())
subscriptions.insert(entity)
val stored = subscriptions.getByFeedUrl(feedUrl) ?: entity
- applyEpisodes(stored.id, fetched.feed)
+ applyEpisodes(stored.id, fetched.feed, report = false)
subscriptions.recordSuccess(stored.id, now(), fetched.etag, fetched.lastModified)
Result.success(stored)
}
@@ -63,7 +70,7 @@ class SubscriptionRepository(
}
is FeedFetchResult.Fetched -> {
- val counts = applyEpisodes(subscription.id, result.feed)
+ val counts = applyEpisodes(subscription.id, result.feed, report = true)
subscriptions.update(
subscription.copy(
title = result.feed.title ?: subscription.title,
@@ -81,11 +88,24 @@ class SubscriptionRepository(
suspend fun refreshAll(): Map<String, RefreshOutcome> =
subscriptions.getAll().associate { it.id to refresh(it) }
- private suspend fun applyEpisodes(subscriptionId: String, feed: ParsedFeed): RefreshOutcome.Updated {
+ /**
+ * Reconciles a fetched feed against what is stored.
+ *
+ * [report] is false for the first import of a new subscription. That import discovers the
+ * whole back catalogue -- a real feed in testing produced 2,753 episodes in one go -- and
+ * handing that to the rule engine would enqueue the lot. Automation acts on what a show
+ * publishes from the moment you subscribe onwards.
+ */
+ private suspend fun applyEpisodes(
+ subscriptionId: String,
+ feed: ParsedFeed,
+ report: Boolean,
+ ): 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)
+ if (report && result.inserts.isNotEmpty()) onDiscovered(result.inserts)
return RefreshOutcome.Updated(result.inserts.size, result.updates.size)
}
diff --git a/app/src/main/java/org/terst/listen/playback/PlaybackSpeed.kt b/app/src/main/java/org/terst/listen/playback/PlaybackSpeed.kt
new file mode 100644
index 0000000..1b23fec
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/playback/PlaybackSpeed.kt
@@ -0,0 +1,10 @@
+package org.terst.listen.playback
+
+/** Which speed an episode plays at: a rule's choice, else the show's, else normal. */
+object PlaybackSpeed {
+
+ const val DEFAULT = 1.0f
+
+ fun forEpisode(episodeOverride: Float?, showSpeed: Float?): Float =
+ episodeOverride ?: showSpeed ?: DEFAULT
+}
diff --git a/app/src/main/java/org/terst/listen/queue/HumanTime.kt b/app/src/main/java/org/terst/listen/queue/HumanTime.kt
new file mode 100644
index 0000000..42ba93f
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/queue/HumanTime.kt
@@ -0,0 +1,37 @@
+package org.terst.listen.queue
+
+import java.util.concurrent.TimeUnit
+
+/**
+ * Durations and ages in the words a listener uses, shared by the rule engine's explanations and
+ * the rule list's summaries so the two never describe the same number differently.
+ */
+internal object HumanTime {
+
+ private const val UNKNOWN = "unknown"
+
+ /** "1h 35m", "14m", "45s". */
+ fun duration(ms: Long?): String {
+ if (ms == null) return UNKNOWN
+ val hours = TimeUnit.MILLISECONDS.toHours(ms)
+ val minutes = TimeUnit.MILLISECONDS.toMinutes(ms) - TimeUnit.HOURS.toMinutes(hours)
+ return when {
+ hours > 0 && minutes > 0 -> "${hours}h ${minutes}m"
+ hours > 0 -> "${hours}h"
+ minutes > 0 -> "${minutes}m"
+ else -> "${TimeUnit.MILLISECONDS.toSeconds(ms)}s"
+ }
+ }
+
+ /** The coarsest unit that still says something: "30d", "5h", "12m". */
+ fun span(ms: Long?): String {
+ if (ms == null) return UNKNOWN
+ val days = TimeUnit.MILLISECONDS.toDays(ms)
+ val hours = TimeUnit.MILLISECONDS.toHours(ms)
+ return when {
+ days > 0 -> "${days}d"
+ hours > 0 -> "${hours}h"
+ else -> "${TimeUnit.MILLISECONDS.toMinutes(ms)}m"
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/queue/RuleApplier.kt b/app/src/main/java/org/terst/listen/queue/RuleApplier.kt
new file mode 100644
index 0000000..ef0e87b
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/queue/RuleApplier.kt
@@ -0,0 +1,54 @@
+package org.terst.listen.queue
+
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeDao
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.QueueRepository
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleDao
+
+/** Carries out what [RuleEngine] decided. Storage lives here so the engine stays pure. */
+class RuleApplier(
+ private val rules: RuleDao,
+ private val episodes: EpisodeDao,
+ private val queue: QueueRepository,
+ private val now: () -> Long = System::currentTimeMillis,
+) {
+
+ /** Applies the rule set to newly discovered episodes, and reports how many a rule claimed. */
+ suspend fun apply(newEpisodes: List<EpisodeEntity>): Int {
+ val ruleSet = rules.getAll()
+ if (newEpisodes.isEmpty() || ruleSet.isEmpty()) return 0
+ val nowMs = now()
+
+ // Oldest first, so a refresh that brings three episodes queues them in listening order
+ // rather than in whatever order the feed happened to list them.
+ val claimed = newEpisodes
+ .sortedBy { it.publishedAtMs ?: it.discoveredAtMs }
+ .mapNotNull { episode ->
+ RuleEngine.firstMatch(ruleSet, episode, nowMs)?.let { episode to it }
+ }
+
+ claimed.forEach { (episode, match) -> record(episode, match, nowMs) }
+ claimed.filter { it.second.rule.action == RuleAction.QUEUE_BOTTOM }
+ .forEach { queue.append(it.first.id) }
+ // Back to front: each one lands directly after the playing episode and pushes the
+ // previous arrival down, so applying them in reverse leaves them in publish order.
+ claimed.filter { it.second.rule.action == RuleAction.QUEUE_TOP }
+ .asReversed()
+ .forEach { queue.playNext(it.first.id) }
+
+ return claimed.size
+ }
+
+ private suspend fun record(episode: EpisodeEntity, match: RuleMatch, nowMs: Long) {
+ val rule = match.rule
+ episodes.recordRule(episode.id, rule.id, match.reason, rule.speed)
+ when {
+ rule.action == RuleAction.MARK_PLAYED -> episodes.markPlayed(episode.id, nowMs)
+ // Fetching something a rule just declared finished would be a waste of the wifi.
+ rule.autoDownload ->
+ episodes.updateDownload(episode.id, DownloadState.QUEUED, null, null)
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/queue/RuleEngine.kt b/app/src/main/java/org/terst/listen/queue/RuleEngine.kt
new file mode 100644
index 0000000..c668ff9
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/queue/RuleEngine.kt
@@ -0,0 +1,81 @@
+package org.terst.listen.queue
+
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.RuleEntity
+
+/** The rule that caught an episode, and the plain-words account of why. */
+data class RuleMatch(val rule: RuleEntity, val reason: String)
+
+/**
+ * Decides which auto-queue rule, if any, claims a newly discovered episode.
+ *
+ * Pure and storage-free for the same reason [QueueOrdering] is: the matching is the contract, and
+ * it is worth testing without a database in the way.
+ *
+ * The reason string is part of that contract rather than a nicety. A queue that fills itself and
+ * cannot say why is the thing that makes people stop trusting automation, so every condition that
+ * held says so in the words a listener would use.
+ */
+object RuleEngine {
+
+ private const val NO_CONDITIONS = "matches every episode"
+
+ /** The first enabled rule, in position order, whose every stated condition holds. */
+ fun firstMatch(rules: List<RuleEntity>, episode: EpisodeEntity, nowMs: Long): RuleMatch? =
+ rules.asSequence()
+ .filter { it.enabled }
+ .sortedBy { it.position }
+ .mapNotNull { match(it, episode, nowMs) }
+ .firstOrNull()
+
+ private fun match(rule: RuleEntity, episode: EpisodeEntity, nowMs: Long): RuleMatch? {
+ val reasons = mutableListOf<String>()
+ for ((holds, reason) in conditions(rule, episode, nowMs)) {
+ if (!holds) return null
+ reasons += reason
+ }
+ return RuleMatch(rule, reasons.joinToString("; ").ifEmpty { NO_CONDITIONS })
+ }
+
+ /**
+ * Every stated condition, paired with how it would read if it held.
+ *
+ * A condition the episode has no answer for -- no declared duration, no publish date -- fails
+ * rather than passes. Plenty of feeds omit `itunes:duration`, and treating the gap as a pass
+ * would let an "under twenty minutes" rule enqueue three-hour episodes.
+ */
+ private fun conditions(
+ rule: RuleEntity,
+ episode: EpisodeEntity,
+ nowMs: Long,
+ ): List<Pair<Boolean, String>> = buildList {
+ rule.subscriptionId?.let {
+ add((episode.subscriptionId == it) to "this show only")
+ }
+ rule.titleContains?.let {
+ add(episode.title.contains(it, ignoreCase = true) to """title contains "$it"""")
+ }
+ rule.titleExcludes?.let {
+ add(!episode.title.contains(it, ignoreCase = true) to """title has no "$it"""")
+ }
+ rule.minDurationMs?.let { limit ->
+ val actual = episode.durationMs
+ add((actual != null && actual >= limit) to
+ "duration ${HumanTime.duration(actual)} is at least ${HumanTime.duration(limit)}")
+ }
+ rule.maxDurationMs?.let { limit ->
+ val actual = episode.durationMs
+ add((actual != null && actual <= limit) to
+ "duration ${HumanTime.duration(actual)} is under ${HumanTime.duration(limit)}")
+ }
+ rule.maxAgeMs?.let { limit ->
+ val published = episode.publishedAtMs
+ val age = published?.let { nowMs - it }
+ add((age != null && age <= limit) to
+ "published ${HumanTime.span(age)} ago, within ${HumanTime.span(limit)}")
+ }
+ rule.seasonNumber?.let {
+ add((episode.seasonNumber == it) to "season $it")
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/queue/RuleSummary.kt b/app/src/main/java/org/terst/listen/queue/RuleSummary.kt
new file mode 100644
index 0000000..2f33091
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/queue/RuleSummary.kt
@@ -0,0 +1,57 @@
+package org.terst.listen.queue
+
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleEntity
+
+/**
+ * A rule in one line: what it catches, and what it does about it.
+ *
+ * Lives here rather than in the UI because the rule list is the only place the automation is
+ * inspectable at a glance, and a summary that quietly omitted a condition would make a rule look
+ * like it does something other than what it does.
+ */
+object RuleSummary {
+
+ private const val ANY = "Any episode"
+
+ fun describe(rule: RuleEntity, showTitle: String?): String =
+ "${conditions(rule, showTitle)} → ${action(rule)}"
+
+ private fun conditions(rule: RuleEntity, showTitle: String?): String {
+ val parts = buildList {
+ rule.subscriptionId?.let { add(showTitle ?: "One show") }
+ rule.titleContains?.let { add("""title has "$it"""") }
+ rule.titleExcludes?.let { add("""title has no "$it"""") }
+ duration(rule)?.let(::add)
+ rule.maxAgeMs?.let { add("newer than ${HumanTime.span(it)}") }
+ rule.seasonNumber?.let { add("season $it") }
+ }
+ return parts.joinToString(" · ").ifEmpty { ANY }
+ }
+
+ private fun duration(rule: RuleEntity): String? {
+ val min = rule.minDurationMs
+ val max = rule.maxDurationMs
+ return when {
+ min != null && max != null -> "${HumanTime.duration(min)}–${HumanTime.duration(max)}"
+ max != null -> "under ${HumanTime.duration(max)}"
+ min != null -> "over ${HumanTime.duration(min)}"
+ else -> null
+ }
+ }
+
+ private fun action(rule: RuleEntity): String {
+ val parts = buildList {
+ add(
+ when (rule.action) {
+ RuleAction.QUEUE_TOP -> "queue at the top"
+ RuleAction.QUEUE_BOTTOM -> "queue at the bottom"
+ RuleAction.MARK_PLAYED -> "mark played"
+ }
+ )
+ if (rule.autoDownload && rule.action != RuleAction.MARK_PLAYED) add("download")
+ rule.speed?.let { add("$it×") }
+ }
+ return parts.joinToString(", ")
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
index 72434a0..b1a3c32 100644
--- a/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/EpisodeScreen.kt
@@ -25,6 +25,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -53,6 +54,7 @@ fun EpisodeScreen(
) {
var episode by remember { mutableStateOf<EpisodeEntity?>(null) }
LaunchedEffect(episodeId) { episode = vm.episode(episodeId) }
+ val rules by vm.rules.collectAsState()
val current = episode
Scaffold(
@@ -74,6 +76,7 @@ fun EpisodeScreen(
}
EpisodeDetail(
episode = current,
+ ruleName = rules.firstOrNull { rule -> rule.id == current.appliedRuleId }?.name,
modifier = Modifier.padding(padding),
actions = EpisodeDetailActions(
play = { player.play(current); nav.openPlayer() },
@@ -92,6 +95,7 @@ fun EpisodeScreen(
@Composable
private fun EpisodeDetail(
episode: EpisodeEntity,
+ ruleName: String?,
modifier: Modifier,
actions: EpisodeDetailActions,
) {
@@ -132,6 +136,8 @@ private fun EpisodeDetail(
Text("Mark this and older as played")
}
+ RuleAttribution(episode, ruleName)
+
episode.description?.let {
Text(stripHtml(it), style = MaterialTheme.typography.bodyMedium)
}
@@ -156,3 +162,17 @@ internal fun stripHtml(raw: String): String = raw
.replace(Regex("""\n{3,}"""), "\n\n")
.lines().joinToString("\n") { it.trim() }
.trim()
+
+/**
+ * Why this episode is where it is.
+ *
+ * The whole reason the rule engine records a reason: automation that fills a queue without
+ * saying why is the thing that makes people stop trusting it. The rule may since have been
+ * deleted, so the account stands on its own without the name.
+ */
+@Composable
+private fun RuleAttribution(episode: EpisodeEntity, ruleName: String?) {
+ val reason = episode.appliedRuleReason ?: return
+ val label = if (ruleName != null) "Handled by \"$ruleName\" — $reason" else "Handled by a rule — $reason"
+ Text(label, style = MaterialTheme.typography.bodySmall)
+}
diff --git a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
index 64dfec9..678ff47 100644
--- a/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/ListenViewModel.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.launch
import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
import org.terst.listen.data.RefreshOutcome
+import org.terst.listen.data.RuleEntity
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.data.SubscriptionKind
import org.terst.listen.feed.FeedResolver
@@ -36,6 +37,11 @@ class ListenViewModel(app: Application) : AndroidViewModel(app) {
graph.queue.observe()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
+ /** Only so an episode can name the rule that claimed it; editing lives in RulesViewModel. */
+ val rules: StateFlow<List<RuleEntity>> =
+ graph.ruleDao.observeAll()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
+
private val _banner = MutableStateFlow<Banner?>(null)
val banner: StateFlow<Banner?> = _banner.asStateFlow()
diff --git a/app/src/main/java/org/terst/listen/ui/MainActivity.kt b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
index e483acc..12d18b5 100644
--- a/app/src/main/java/org/terst/listen/ui/MainActivity.kt
+++ b/app/src/main/java/org/terst/listen/ui/MainActivity.kt
@@ -18,10 +18,11 @@ class MainActivity : ComponentActivity() {
private val viewModel: ListenViewModel by viewModels()
private val playerViewModel: PlayerViewModel by viewModels()
+ private val rulesViewModel: RulesViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- setContent { ListenTheme { ListenApp(viewModel, playerViewModel) } }
+ setContent { ListenTheme { ListenApp(viewModel, playerViewModel, rulesViewModel) } }
}
}
@@ -39,7 +40,11 @@ fun ListenTheme(content: @Composable () -> Unit) {
}
@Composable
-private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) {
+private fun ListenApp(
+ viewModel: ListenViewModel,
+ player: PlayerViewModel,
+ rulesViewModel: RulesViewModel,
+) {
val nav = rememberNavController()
NavHost(navController = nav, startDestination = "library") {
@@ -60,9 +65,10 @@ private fun ListenApp(viewModel: ListenViewModel, player: PlayerViewModel) {
}
episodeRoute(nav, viewModel, player)
composable("settings") {
- SettingsScreen(vm = viewModel,
- player = player, onBack = { nav.popBackStack() })
+ SettingsScreen(vm = viewModel, player = player,
+ onOpenRules = { nav.navigate("rules") }, onBack = { nav.popBackStack() })
}
+ rulesRoutes(nav, rulesViewModel)
composable("queue") {
QueueScreen(
vm = viewModel,
@@ -123,3 +129,25 @@ private fun androidx.navigation.NavGraphBuilder.episodeRoute(
}
}
}
+
+/** The auto-queue rule list and the editor for one rule, kept out of the graph above for length. */
+private fun androidx.navigation.NavGraphBuilder.rulesRoutes(
+ nav: androidx.navigation.NavHostController,
+ rules: RulesViewModel,
+) {
+ composable("rules") {
+ RulesScreen(
+ vm = rules,
+ onEdit = { nav.navigate("rule/$it") },
+ onBack = { nav.popBackStack() },
+ )
+ }
+ composable("rule/{id}") { entry ->
+ val id = entry.arguments?.getString("id")
+ if (id == null) {
+ nav.popBackStack()
+ } else {
+ RuleEditScreen(vm = rules, ruleId = id, onDone = { nav.popBackStack() })
+ }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
index 50bbfb7..106bae4 100644
--- a/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
+++ b/app/src/main/java/org/terst/listen/ui/PlayerViewModel.kt
@@ -12,6 +12,7 @@ import org.terst.listen.data.EpisodeEntity
import org.terst.listen.data.Graph
import org.terst.listen.data.SubscriptionEntity
import org.terst.listen.playback.NowPlaying
+import org.terst.listen.playback.PlaybackSpeed
import org.terst.listen.playback.PlayerConnection
import org.terst.listen.playback.SleepTimer
import org.terst.listen.playback.toMediaItem
@@ -60,7 +61,7 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
super.onCleared()
}
- /** Starts an episode, resuming where the listener left off and at the show's speed. */
+ /** Starts an episode, resuming where the listener left off and at the right speed. */
fun play(episode: EpisodeEntity) {
viewModelScope.launch {
// Anything played becomes the head of the queue. Without that, finishing an
@@ -70,7 +71,7 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
val show = graph.subscriptions.getById(episode.subscriptionId)
_currentShow.value = show
- val speed = show?.playbackSpeed ?: DEFAULT_SPEED
+ val speed = PlaybackSpeed.forEpisode(episode.speedOverride, show?.playbackSpeed)
connection.play(
item = episode.toMediaItem(showTitle = show?.title, author = show?.author, showImageUrl = show?.imageUrl),
storedPositionMs = episode.positionMs,
@@ -160,7 +161,6 @@ class PlayerViewModel(app: Application) : AndroidViewModel(app) {
private companion object {
const val PROGRESS_TICK_MS = 1_000L
- const val DEFAULT_SPEED = 1.0f
// Asymmetric on purpose: back is for catching a missed sentence, forward for skipping
// an ad break.
// Identical to the service's seek increments, so the in-app bar and the lock
diff --git a/app/src/main/java/org/terst/listen/ui/RuleConditionFields.kt b/app/src/main/java/org/terst/listen/ui/RuleConditionFields.kt
new file mode 100644
index 0000000..aac38f2
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/RuleConditionFields.kt
@@ -0,0 +1,142 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExposedDropdownMenuBox
+import androidx.compose.material3.ExposedDropdownMenuDefaults
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MenuAnchorType
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import org.terst.listen.data.RuleEntity
+import org.terst.listen.data.SubscriptionEntity
+import java.util.concurrent.TimeUnit
+
+/**
+ * The "matches" half of the rule editor.
+ *
+ * Split out of [RuleEditScreen] rather than left inline: the editor covers seven conditions and
+ * three actions, and one file holding all of it was long enough that the two halves stopped
+ * being findable.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun ShowPicker(
+ rule: RuleEntity,
+ subscriptions: List<SubscriptionEntity>,
+ onChange: (RuleEntity) -> Unit,
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val selected = subscriptions.firstOrNull { it.id == rule.subscriptionId }?.title ?: ANY_SHOW
+
+ ExposedDropdownMenuBox(expanded = expanded, onExpandedChange = { expanded = it }) {
+ OutlinedTextField(
+ value = selected,
+ onValueChange = {},
+ readOnly = true,
+ label = { Text("Show") },
+ trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
+ modifier = Modifier
+ .menuAnchor(MenuAnchorType.PrimaryNotEditable)
+ .fillMaxWidth(),
+ )
+ ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
+ DropdownMenuItem(
+ text = { Text(ANY_SHOW) },
+ onClick = { onChange(rule.copy(subscriptionId = null)); expanded = false },
+ )
+ subscriptions.forEach { subscription ->
+ DropdownMenuItem(
+ text = { Text(subscription.title) },
+ onClick = { onChange(rule.copy(subscriptionId = subscription.id)); expanded = false },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+fun TextConditions(rule: RuleEntity, onChange: (RuleEntity) -> Unit) {
+ OutlinedTextField(
+ value = rule.titleContains.orEmpty(),
+ onValueChange = { onChange(rule.copy(titleContains = it.blankToNull())) },
+ label = { Text("Title contains") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ OutlinedTextField(
+ value = rule.titleExcludes.orEmpty(),
+ onValueChange = { onChange(rule.copy(titleExcludes = it.blankToNull())) },
+ label = { Text("Title does not contain") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+}
+
+@Composable
+fun NumberConditions(rule: RuleEntity, onChange: (RuleEntity) -> Unit) {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ NumberField("Longer than (min)", rule.minDurationMs.asMinutes(), Modifier.weight(1f)) {
+ onChange(rule.copy(minDurationMs = it.fromMinutes()))
+ }
+ NumberField("Shorter than (min)", rule.maxDurationMs.asMinutes(), Modifier.weight(1f)) {
+ onChange(rule.copy(maxDurationMs = it.fromMinutes()))
+ }
+ }
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ NumberField("Newer than (days)", rule.maxAgeMs.asDays(), Modifier.weight(1f)) {
+ onChange(rule.copy(maxAgeMs = it.toLongOrNull()?.let(TimeUnit.DAYS::toMillis)))
+ }
+ NumberField("Season", rule.seasonNumber?.toString().orEmpty(), Modifier.weight(1f)) {
+ onChange(rule.copy(seasonNumber = it.toIntOrNull()))
+ }
+ }
+ Text(
+ "An episode whose feed does not declare a duration or a date never matches a rule that " +
+ "asks about one.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+}
+
+@Composable
+private fun NumberField(
+ label: String,
+ value: String,
+ modifier: Modifier,
+ onValueChange: (String) -> Unit,
+) {
+ OutlinedTextField(
+ value = value,
+ // Digits only: a stray character would silently parse to null and quietly drop the
+ // condition rather than showing an error.
+ onValueChange = { entered -> onValueChange(entered.filter(Char::isDigit)) },
+ label = { Text(label) },
+ singleLine = true,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
+ modifier = modifier,
+ )
+}
+
+private const val ANY_SHOW = "Any show"
+
+private fun String.blankToNull(): String? = takeIf { it.isNotBlank() }
+
+private fun Long?.asMinutes(): String =
+ this?.let { TimeUnit.MILLISECONDS.toMinutes(it).toString() }.orEmpty()
+
+private fun Long?.asDays(): String =
+ this?.let { TimeUnit.MILLISECONDS.toDays(it).toString() }.orEmpty()
+
+private fun String.fromMinutes(): Long? = toLongOrNull()?.let(TimeUnit.MINUTES::toMillis)
diff --git a/app/src/main/java/org/terst/listen/ui/RuleEditScreen.kt b/app/src/main/java/org/terst/listen/ui/RuleEditScreen.kt
new file mode 100644
index 0000000..5215c88
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/RuleEditScreen.kt
@@ -0,0 +1,173 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.DeleteOutline
+import androidx.compose.material3.Button
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleEntity
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.queue.RuleSummary
+
+/** Speeds a rule can pin an episode to. Matches the settings screen's list. */
+private val RULE_SPEEDS = listOf(1.0f, 1.25f, 1.5f, 1.75f, 2.0f)
+
+private val ACTIONS = listOf(
+ RuleAction.QUEUE_TOP to "Queue at top",
+ RuleAction.QUEUE_BOTTOM to "Queue at bottom",
+ RuleAction.MARK_PLAYED to "Mark played",
+)
+
+/**
+ * One rule's conditions and action.
+ *
+ * Edits are held locally and written on Save rather than per keystroke: a half-typed rule that
+ * saved itself would act on the next refresh with conditions nobody meant.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RuleEditScreen(vm: RulesViewModel, ruleId: String, onDone: () -> Unit) {
+ val rules by vm.rules.collectAsState()
+ val subscriptions by vm.subscriptions.collectAsState()
+ val stored = rules.firstOrNull { it.id == ruleId }
+
+ var draft by remember(ruleId) { mutableStateOf<RuleEntity?>(null) }
+ LaunchedEffect(stored?.id) { if (draft == null) draft = stored }
+
+ val rule = draft ?: return
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Rule") },
+ navigationIcon = {
+ IconButton(onClick = onDone) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ actions = {
+ IconButton(onClick = { vm.delete(ruleId); onDone() }) {
+ Icon(Icons.Default.DeleteOutline, contentDescription = "Delete rule")
+ }
+ },
+ )
+ },
+ ) { padding ->
+ RuleEditBody(
+ rule = rule,
+ subscriptions = subscriptions,
+ modifier = Modifier.padding(padding),
+ onChange = { draft = it },
+ onSave = { vm.save(rule); onDone() },
+ )
+ }
+}
+
+@Composable
+private fun RuleEditBody(
+ rule: RuleEntity,
+ subscriptions: List<SubscriptionEntity>,
+ modifier: Modifier,
+ onChange: (RuleEntity) -> Unit,
+ onSave: () -> Unit,
+) {
+ Column(
+ modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ OutlinedTextField(
+ value = rule.name,
+ onValueChange = { onChange(rule.copy(name = it)) },
+ label = { Text("Name") },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+
+ Text("Matches", style = MaterialTheme.typography.titleMedium)
+ ShowPicker(rule, subscriptions, onChange)
+ TextConditions(rule, onChange)
+ NumberConditions(rule, onChange)
+
+ HorizontalDivider()
+ Text("Then", style = MaterialTheme.typography.titleMedium)
+ ActionPicker(rule, onChange)
+
+ HorizontalDivider()
+ Text(
+ RuleSummary.describe(rule, subscriptions.firstOrNull { it.id == rule.subscriptionId }?.title),
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Button(onClick = onSave, modifier = Modifier.fillMaxWidth()) { Text("Save") }
+ }
+}
+
+@Composable
+private fun ActionPicker(rule: RuleEntity, onChange: (RuleEntity) -> Unit) {
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ ACTIONS.forEach { (action, label) ->
+ Chip(label, rule.action == action) { onChange(rule.copy(action = action)) }
+ }
+ }
+
+ // Neither downloading nor a speed means anything for an episode a rule just marked played.
+ if (rule.action == RuleAction.MARK_PLAYED) return
+
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Switch(
+ checked = rule.autoDownload,
+ onCheckedChange = { onChange(rule.copy(autoDownload = it)) },
+ )
+ Text("Download on wifi", Modifier.padding(start = 12.dp))
+ }
+ SpeedPicker(rule, onChange)
+}
+
+@Composable
+private fun SpeedPicker(rule: RuleEntity, onChange: (RuleEntity) -> Unit) {
+ Text("Speed for these episodes", style = MaterialTheme.typography.bodyMedium)
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ Chip("Show's own", rule.speed == null) { onChange(rule.copy(speed = null)) }
+ RULE_SPEEDS.forEach { speed ->
+ Chip("$speed×", rule.speed == speed) { onChange(rule.copy(speed = speed)) }
+ }
+ }
+}
+
+@Composable
+private fun Chip(label: String, selected: Boolean, onClick: () -> Unit) {
+ if (selected) {
+ Button(onClick = onClick) { Text(label) }
+ } else {
+ OutlinedButton(onClick = onClick) { Text(label) }
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/RulesScreen.kt b/app/src/main/java/org/terst/listen/ui/RulesScreen.kt
new file mode 100644
index 0000000..afada54
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/RulesScreen.kt
@@ -0,0 +1,152 @@
+package org.terst.listen.ui
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.DragHandle
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FloatingActionButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import org.terst.listen.data.RuleEntity
+import org.terst.listen.queue.RuleSummary
+import sh.calvin.reorderable.ReorderableCollectionItemScope
+import sh.calvin.reorderable.ReorderableItem
+import sh.calvin.reorderable.rememberReorderableLazyListState
+
+/**
+ * The rule set, in the order it is evaluated.
+ *
+ * Order is the whole point of the drag handle here, not presentation: the first matching rule
+ * claims the episode, so moving a rule up changes what the automation does.
+ */
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun RulesScreen(vm: RulesViewModel, onEdit: (String) -> Unit, onBack: () -> Unit) {
+ val rules by vm.rules.collectAsState()
+ val subscriptions by vm.subscriptions.collectAsState()
+ val showTitles = remember(subscriptions) { subscriptions.associate { it.id to it.title } }
+
+ // Same pattern as the queue: the drag animates against a local copy and only the released
+ // order is written, since a gesture reports intermediate positions continuously.
+ var localOrder by remember { mutableStateOf(rules) }
+ LaunchedEffect(rules) { localOrder = rules }
+
+ val lazyListState = rememberLazyListState()
+ val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
+ localOrder = localOrder.toMutableList().apply { add(to.index, removeAt(from.index)) }
+ }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Auto-queue rules") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
+ }
+ },
+ )
+ },
+ floatingActionButton = {
+ FloatingActionButton(onClick = { vm.create(onEdit) }) {
+ Icon(Icons.Default.Add, contentDescription = "Add a rule")
+ }
+ },
+ ) { padding ->
+ if (localOrder.isEmpty()) {
+ EmptyRules(Modifier.padding(padding))
+ return@Scaffold
+ }
+ LazyColumn(state = lazyListState, modifier = Modifier.padding(padding).fillMaxSize()) {
+ items(localOrder, key = { it.id }) { rule ->
+ ReorderableItem(reorderableState, key = rule.id) {
+ RuleRow(
+ rule = rule,
+ showTitle = rule.subscriptionId?.let(showTitles::get),
+ onEdit = { onEdit(rule.id) },
+ onToggle = { vm.setEnabled(rule.id, it) },
+ onDragStopped = { vm.reorder(localOrder.map { it.id }) },
+ )
+ }
+ HorizontalDivider()
+ }
+ }
+ }
+}
+
+@Composable
+private fun EmptyRules(modifier: Modifier) {
+ Column(
+ modifier.fillMaxSize().padding(24.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ Text("No rules yet", style = MaterialTheme.typography.titleMedium)
+ Text(
+ "A rule decides what happens to an episode the moment a refresh finds it: queue it, " +
+ "download it, or mark it played so it never reaches the queue. Rules run in " +
+ "order and the first one that matches wins.",
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ Text(
+ "Rules apply to episodes published from now on. Subscribing to a show does not run " +
+ "them over its back catalogue.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
+@Composable
+private fun ReorderableCollectionItemScope.RuleRow(
+ rule: RuleEntity,
+ showTitle: String?,
+ onEdit: () -> Unit,
+ onToggle: (Boolean) -> Unit,
+ onDragStopped: () -> Unit,
+) {
+ Row(
+ Modifier.fillMaxWidth().clickable(onClick = onEdit).padding(end = 8.dp, top = 4.dp, bottom = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ modifier = Modifier.draggableHandle(onDragStopped = onDragStopped),
+ onClick = {},
+ ) {
+ Icon(Icons.Default.DragHandle, contentDescription = "Reorder")
+ }
+ Column(Modifier.weight(1f)) {
+ Text(rule.name, style = MaterialTheme.typography.titleSmall)
+ Text(
+ RuleSummary.describe(rule, showTitle),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ Switch(checked = rule.enabled, onCheckedChange = onToggle)
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt
new file mode 100644
index 0000000..5080011
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/ui/RulesViewModel.kt
@@ -0,0 +1,68 @@
+package org.terst.listen.ui
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.stateIn
+import kotlinx.coroutines.launch
+import org.terst.listen.data.Graph
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleEntity
+import org.terst.listen.data.SubscriptionEntity
+import java.util.UUID
+
+/** The auto-queue rule set: listing, ordering, and editing one rule at a time. */
+class RulesViewModel(app: Application) : AndroidViewModel(app) {
+
+ private val graph = Graph.get(app)
+
+ val rules: StateFlow<List<RuleEntity>> =
+ graph.ruleDao.observeAll()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
+
+ /** For the "only this show" condition, and for naming a scoped rule in the list. */
+ val subscriptions: StateFlow<List<SubscriptionEntity>> =
+ graph.subscriptions.observeAll()
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(STOP_TIMEOUT_MS), emptyList())
+
+ /**
+ * Adds a rule at the end of the list.
+ *
+ * Last, never first: position decides which rule wins, and a new rule appearing at the top
+ * would silently pre-empt rules already being relied on.
+ */
+ fun create(onCreated: (String) -> Unit) {
+ viewModelScope.launch {
+ val rule = RuleEntity(
+ id = UUID.randomUUID().toString(),
+ name = "New rule",
+ position = graph.ruleDao.nextPosition(),
+ action = RuleAction.QUEUE_BOTTOM,
+ )
+ graph.ruleDao.upsert(rule)
+ onCreated(rule.id)
+ }
+ }
+
+ fun save(rule: RuleEntity) {
+ viewModelScope.launch { graph.ruleDao.upsert(rule) }
+ }
+
+ fun delete(id: String) {
+ viewModelScope.launch { graph.ruleDao.delete(id) }
+ }
+
+ fun setEnabled(id: String, enabled: Boolean) {
+ viewModelScope.launch { graph.ruleDao.setEnabled(id, enabled) }
+ }
+
+ fun reorder(ids: List<String>) {
+ viewModelScope.launch { graph.ruleDao.reorder(ids) }
+ }
+
+ private companion object {
+ const val STOP_TIMEOUT_MS = 5_000L
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
index 6ddcc92..fc8900b 100644
--- a/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
+++ b/app/src/main/java/org/terst/listen/ui/SettingsScreen.kt
@@ -39,7 +39,12 @@ private val OPML_MIME_TYPES = arrayOf("text/x-opml", "text/xml", "application/xm
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-fun SettingsScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> Unit) {
+fun SettingsScreen(
+ vm: ListenViewModel,
+ player: PlayerViewModel,
+ onOpenRules: () -> Unit,
+ onBack: () -> Unit,
+) {
val nowPlaying by player.nowPlaying.collectAsState()
val sleepRemaining by player.sleepRemainingMs.collectAsState()
@@ -73,6 +78,8 @@ fun SettingsScreen(vm: ListenViewModel, player: PlayerViewModel, onBack: () -> U
HorizontalDivider()
SleepSection(remainingMs = sleepRemaining, onArm = player::armSleepTimer, onCancel = player::cancelSleepTimer)
HorizontalDivider()
+ RulesSection(onOpenRules)
+ HorizontalDivider()
BuildSection()
HorizontalDivider()
SubscriptionsSection(
@@ -120,6 +127,18 @@ private fun SleepSection(remainingMs: Long?, onArm: (Long) -> Unit, onCancel: ()
}
@Composable
+private fun RulesSection(onOpenRules: () -> Unit) {
+ Section("Automation") {
+ OutlinedButton(onClick = onOpenRules) { Text("Auto-queue rules") }
+ Text(
+ "Decide what happens to an episode the moment a refresh finds it. Rules run in " +
+ "order and the first match wins.",
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+}
+
+@Composable
private fun SubscriptionsSection(onImport: () -> Unit, onExport: () -> Unit) {
Section("Subscriptions") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
diff --git a/app/src/main/java/org/terst/listen/work/DownloadDrainer.kt b/app/src/main/java/org/terst/listen/work/DownloadDrainer.kt
new file mode 100644
index 0000000..29cb610
--- /dev/null
+++ b/app/src/main/java/org/terst/listen/work/DownloadDrainer.kt
@@ -0,0 +1,41 @@
+package org.terst.listen.work
+
+import org.terst.listen.data.EpisodeDao
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.SubscriptionDao
+import org.terst.listen.data.SubscriptionKind
+import java.io.File
+
+/**
+ * Fetches the downloads an auto-queue rule asked for.
+ *
+ * Extracted from the Worker so it is testable, for the same reason [RetentionSweeper] was: this
+ * is the only place the app pulls tens of megabytes without anyone having asked for it, and
+ * getting the batching wrong means a phone quietly downloading a back catalogue.
+ *
+ * A failed fetch is left FAILED rather than requeued. Retrying forever on a feed whose media has
+ * moved would burn the battery against a 404, and a failed row is visible in the episode list.
+ */
+class DownloadDrainer(
+ private val episodes: EpisodeDao,
+ private val subscriptions: SubscriptionDao,
+ private val download: suspend (EpisodeEntity, SubscriptionKind) -> Result<File>,
+) {
+
+ /** Returns how many episodes were actually downloaded. */
+ suspend fun drain(limit: Int = DEFAULT_BATCH): Int {
+ var downloaded = 0
+ for (episode in episodes.queuedDownloads(limit)) {
+ // No default kind: a YouTube episode fetched over plain HTTP stores an HTML page
+ // under an audio name and fails to play with no indication why.
+ val kind = subscriptions.getById(episode.subscriptionId)?.kind ?: continue
+ if (download(episode, kind).isSuccess) downloaded++
+ }
+ return downloaded
+ }
+
+ private companion object {
+ /** One job's worth. A catch-all rule can queue far more than a phone should fetch at once. */
+ const val DEFAULT_BATCH = 10
+ }
+}
diff --git a/app/src/main/java/org/terst/listen/work/Workers.kt b/app/src/main/java/org/terst/listen/work/Workers.kt
index 75c6625..7a7b483 100644
--- a/app/src/main/java/org/terst/listen/work/Workers.kt
+++ b/app/src/main/java/org/terst/listen/work/Workers.kt
@@ -33,6 +33,24 @@ class RefreshWorker(context: Context, params: WorkerParameters) : CoroutineWorke
}
}
+/**
+ * Fetches the downloads auto-queue rules asked for; the decisions live in DownloadDrainer.
+ *
+ * Separate from the refresh rather than part of it: a refresh should finish in seconds whatever
+ * the rules decided, and a worker that spent twenty minutes downloading would be killed
+ * mid-flight with the feed state half-written.
+ */
+class DownloadWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
+
+ override suspend fun doWork(): Result {
+ val graph = Graph.get(applicationContext)
+ val downloaded = DownloadDrainer(graph.episodes, graph.subscriptions) { episode, kind ->
+ graph.downloader.download(episode, kind)
+ }.drain()
+ return Result.success(androidx.work.Data.Builder().putInt("downloaded", downloaded).build())
+ }
+}
+
/** Reclaims played downloads; the decisions live in RetentionSweeper, which is tested. */
class RetentionWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
@@ -47,8 +65,10 @@ class RetentionWorker(context: Context, params: WorkerParameters) : CoroutineWor
object Scheduling {
private const val REFRESH_WORK = "listen-refresh"
+ private const val DOWNLOAD_WORK = "listen-download"
private const val RETENTION_WORK = "listen-retention"
private const val REFRESH_HOURS = 6L
+ private const val DOWNLOAD_HOURS = 2L
private const val RETENTION_HOURS = 12L
/**
@@ -74,6 +94,21 @@ object Scheduling {
)
manager.enqueueUniquePeriodicWork(
+ DOWNLOAD_WORK,
+ ExistingPeriodicWorkPolicy.KEEP,
+ PeriodicWorkRequestBuilder<DownloadWorker>(DOWNLOAD_HOURS, TimeUnit.HOURS)
+ .setConstraints(
+ Constraints.Builder()
+ // Unmetered and not-low battery: this is the one job that spends real
+ // data and real power on something nobody explicitly asked for.
+ .setRequiredNetworkType(NetworkType.UNMETERED)
+ .setRequiresBatteryNotLow(true)
+ .build()
+ )
+ .build(),
+ )
+
+ manager.enqueueUniquePeriodicWork(
RETENTION_WORK,
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<RetentionWorker>(RETENTION_HOURS, TimeUnit.HOURS)
diff --git a/app/src/test/java/org/terst/listen/data/FakeDaos.kt b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
index 59443c0..3aee040 100644
--- a/app/src/test/java/org/terst/listen/data/FakeDaos.kt
+++ b/app/src/test/java/org/terst/listen/data/FakeDaos.kt
@@ -112,6 +112,15 @@ class FakeEpisodeDao : EpisodeDao {
replace(id) { it.copy(downloadState = state, localPath = localPath, downloadedAtMs = downloadedAtMs) }
}
+ override suspend fun recordRule(id: String, ruleId: String, reason: String, speed: Float?) {
+ replace(id) { it.copy(appliedRuleId = ruleId, appliedRuleReason = reason, speedOverride = speed) }
+ }
+
+ override suspend fun queuedDownloads(limit: Int, queued: DownloadState) =
+ rows.filter { it.downloadState == queued }
+ .sortedBy { it.publishedAtMs ?: it.discoveredAtMs }
+ .take(limit)
+
override suspend fun playedDownloadsBefore(before: Long, downloaded: DownloadState) =
rows.filter {
it.downloadState == downloaded && it.playedAtMs != null && it.playedAtMs!! < before
@@ -126,6 +135,35 @@ class FakeEpisodeDao : EpisodeDao {
}
}
+class FakeRuleDao : RuleDao {
+ val rows = mutableListOf<RuleEntity>()
+
+ override fun observeAll(): Flow<List<RuleEntity>> = flowOf(rows.sortedBy { it.position })
+ override suspend fun getAll() = rows.sortedBy { it.position }
+ override suspend fun getById(id: String) = rows.firstOrNull { it.id == id }
+
+ override suspend fun upsert(rule: RuleEntity) {
+ rows.removeAll { it.id == rule.id }
+ rows += rule
+ }
+
+ override suspend fun delete(id: String) {
+ rows.removeAll { it.id == id }
+ }
+
+ override suspend fun nextPosition() = (rows.maxOfOrNull { it.position } ?: -1) + 1
+
+ override suspend fun setPosition(id: String, position: Int) {
+ val index = rows.indexOfFirst { it.id == id }
+ if (index >= 0) rows[index] = rows[index].copy(position = position)
+ }
+
+ override suspend fun setEnabled(id: String, enabled: Boolean) {
+ val index = rows.indexOfFirst { it.id == id }
+ if (index >= 0) rows[index] = rows[index].copy(enabled = enabled)
+ }
+}
+
class FakeQueueDao : QueueDao {
val entries = mutableListOf<QueueEntity>()
var episodeSource: () -> List<EpisodeEntity> = { emptyList() }
diff --git a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
index 0d56c56..13457c7 100644
--- a/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
+++ b/app/src/test/java/org/terst/listen/data/SubscriptionRepositoryTest.kt
@@ -215,6 +215,67 @@ class SubscriptionRepositoryTest {
assertTrue("a blank title would render as an empty row", eps.rows.single().title.isNotBlank())
}
+ // --- handing new episodes to the automation ---------------------------------------------
+
+ private fun repoReporting(discovered: MutableList<List<EpisodeEntity>>) =
+ SubscriptionRepository(
+ subs,
+ eps,
+ FeedFetcher(OkHttpClient()),
+ onDiscovered = { discovered += it },
+ ) { clock }
+
+ @Test
+ fun `refresh hands newly discovered episodes over`() = runTest {
+ val discovered = mutableListOf<List<EpisodeEntity>>()
+ val reporting = repoReporting(discovered)
+ server.enqueue(MockResponse().setBody(feed()))
+ reporting.subscribe(url())
+ discovered.clear()
+
+ server.enqueue(MockResponse().setBody(feed(twoItems)))
+ reporting.refresh(subs.rows.single())
+
+ assertEquals(listOf("Two"), discovered.single().map { it.title })
+ }
+
+ /**
+ * The one that matters. A first subscribe discovers the entire back catalogue -- a real feed
+ * in testing produced 2,753 episodes in one go -- and a catch-all rule would enqueue all of
+ * it. Automation acts on what a show publishes from now on, not on its history.
+ */
+ @Test
+ fun `a first subscribe does not hand over its back catalogue`() = runTest {
+ val discovered = mutableListOf<List<EpisodeEntity>>()
+ server.enqueue(MockResponse().setBody(feed(twoItems)))
+
+ repoReporting(discovered).subscribe(url())
+
+ assertEquals("subscribing must not trigger the rule engine", emptyList<Any>(), discovered)
+ }
+
+ /** Adding a video by hand is already a decision; a rule must not overrule it. */
+ @Test
+ fun `a manually added video is not handed over`() = runTest {
+ val discovered = mutableListOf<List<EpisodeEntity>>()
+ repoReporting(discovered).addSingleEpisode(video("aaa", "First"))
+ assertEquals(emptyList<Any>(), discovered)
+ }
+
+ @Test
+ fun `a refresh that finds nothing new hands over nothing`() = runTest {
+ val discovered = mutableListOf<List<EpisodeEntity>>()
+ val reporting = repoReporting(discovered)
+ server.enqueue(MockResponse().setBody(feed()))
+ reporting.subscribe(url())
+ discovered.clear()
+
+ server.enqueue(MockResponse().setBody(feed()))
+ reporting.refresh(subs.rows.single())
+
+ assertEquals(emptyList<Any>(), discovered)
+ }
+
@Test
fun `refreshAll reports per subscription`() = runTest {
server.enqueue(MockResponse().setBody(feed()))
diff --git a/app/src/test/java/org/terst/listen/playback/PlaybackSpeedTest.kt b/app/src/test/java/org/terst/listen/playback/PlaybackSpeedTest.kt
new file mode 100644
index 0000000..7a1fe6d
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/playback/PlaybackSpeedTest.kt
@@ -0,0 +1,28 @@
+package org.terst.listen.playback
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+/**
+ * Which speed wins when three of them are in play: a rule's, the show's, and the default.
+ *
+ * Worth its own test because the precedence is the whole point of a rule being able to set one.
+ * A rule that says "bonus episodes at 1x" is useless if the show's 1.75x quietly overrides it.
+ */
+class PlaybackSpeedTest {
+
+ @Test
+ fun `nothing set falls back to normal speed`() {
+ assertEquals(1.0f, PlaybackSpeed.forEpisode(episodeOverride = null, showSpeed = null))
+ }
+
+ @Test
+ fun `the show's speed is used when no rule set one`() {
+ assertEquals(1.75f, PlaybackSpeed.forEpisode(episodeOverride = null, showSpeed = 1.75f))
+ }
+
+ @Test
+ fun `a rule's speed beats the show's`() {
+ assertEquals(1.0f, PlaybackSpeed.forEpisode(episodeOverride = 1.0f, showSpeed = 1.75f))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/queue/RuleApplierTest.kt b/app/src/test/java/org/terst/listen/queue/RuleApplierTest.kt
new file mode 100644
index 0000000..2724c60
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/queue/RuleApplierTest.kt
@@ -0,0 +1,218 @@
+package org.terst.listen.queue
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertNull
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakeQueueDao
+import org.terst.listen.data.FakeRuleDao
+import org.terst.listen.data.QueueRepository
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleEntity
+import java.util.concurrent.TimeUnit
+
+/** Carrying out what the engine decided: queueing, marking played, downloading, attribution. */
+class RuleApplierTest {
+
+ private val now = 1_700_000_000_000L
+
+ private lateinit var ruleDao: FakeRuleDao
+ private lateinit var episodeDao: FakeEpisodeDao
+ private lateinit var queueDao: FakeQueueDao
+ private lateinit var applier: RuleApplier
+
+ @Before
+ fun setUp() {
+ ruleDao = FakeRuleDao()
+ episodeDao = FakeEpisodeDao()
+ queueDao = FakeQueueDao()
+ queueDao.episodeSource = { episodeDao.rows.toList() }
+ applier = RuleApplier(ruleDao, episodeDao, QueueRepository(queueDao)) { now }
+ }
+
+ private fun episode(
+ id: String,
+ title: String = "Episode $id",
+ durationMs: Long? = TimeUnit.MINUTES.toMillis(30),
+ publishedAtMs: Long? = now,
+ ) = EpisodeEntity(
+ id = id,
+ subscriptionId = "show-1",
+ guid = id,
+ title = title,
+ mediaUrl = "https://cdn/$id.mp3",
+ mediaType = "audio/mpeg",
+ durationMs = durationMs,
+ publishedAtMs = publishedAtMs,
+ discoveredAtMs = now,
+ )
+
+ private fun store(vararg episodes: EpisodeEntity): List<EpisodeEntity> {
+ episodeDao.rows += episodes
+ return episodes.toList()
+ }
+
+ private suspend fun rule(
+ action: RuleAction = RuleAction.QUEUE_BOTTOM,
+ autoDownload: Boolean = false,
+ speed: Float? = null,
+ titleContains: String? = null,
+ maxDurationMs: Long? = null,
+ ) = RuleEntity(
+ id = "rule-1",
+ name = "A rule",
+ position = 0,
+ titleContains = titleContains,
+ maxDurationMs = maxDurationMs,
+ action = action,
+ autoDownload = autoDownload,
+ speed = speed,
+ ).also { ruleDao.upsert(it) }
+
+ private suspend fun queued() = queueDao.getEntries().map { it.episodeId }
+
+ @Test
+ fun `with no rules nothing happens`() = runTest {
+ val new = store(episode("a"))
+ assertEquals(0, applier.apply(new))
+ assertEquals(emptyList<String>(), queued())
+ }
+
+ @Test
+ fun `queue bottom appends`() = runTest {
+ rule(action = RuleAction.QUEUE_BOTTOM)
+ queueDao.episodeSource = { episodeDao.rows.toList() }
+ val existing = store(episode("head"))
+ QueueRepository(queueDao).append(existing.first().id)
+
+ applier.apply(store(episode("a")))
+
+ assertEquals(listOf("head", "a"), queued())
+ }
+
+ @Test
+ fun `queue top goes after whatever is playing, never in front of it`() = runTest {
+ rule(action = RuleAction.QUEUE_TOP)
+ val existing = store(episode("head"), episode("tail"))
+ QueueRepository(queueDao).append(existing[0].id)
+ QueueRepository(queueDao).append(existing[1].id)
+
+ applier.apply(store(episode("a")))
+
+ assertEquals(listOf("head", "a", "tail"), queued())
+ }
+
+ @Test
+ fun `mark played does not queue`() = runTest {
+ rule(action = RuleAction.MARK_PLAYED)
+
+ applier.apply(store(episode("a")))
+
+ assertEquals(emptyList<String>(), queued())
+ assertEquals(now, episodeDao.rows.first { it.id == "a" }.playedAtMs)
+ }
+
+ @Test
+ fun `auto download queues the download`() = runTest {
+ rule(autoDownload = true)
+
+ applier.apply(store(episode("a")))
+
+ assertEquals(DownloadState.QUEUED, episodeDao.rows.first { it.id == "a" }.downloadState)
+ }
+
+ /** Downloading something the same rule just declared finished would be a waste of the wifi. */
+ @Test
+ fun `an episode marked played is not downloaded, even when the rule asks`() = runTest {
+ rule(action = RuleAction.MARK_PLAYED, autoDownload = true)
+
+ applier.apply(store(episode("a")))
+
+ assertEquals(DownloadState.NONE, episodeDao.rows.first { it.id == "a" }.downloadState)
+ }
+
+ @Test
+ fun `a speed action lands on the episode, not the show`() = runTest {
+ rule(speed = 1.5f)
+
+ applier.apply(store(episode("a")))
+
+ assertEquals(1.5f, episodeDao.rows.first { it.id == "a" }.speedOverride)
+ }
+
+ @Test
+ fun `the rule that fired is recorded against the episode`() = runTest {
+ rule(titleContains = "news")
+
+ applier.apply(store(episode("a", title = "The news roundup")))
+
+ val stored = episodeDao.rows.first { it.id == "a" }
+ assertEquals("rule-1", stored.appliedRuleId)
+ assertNotNull(stored.appliedRuleReason)
+ assertEquals(true, stored.appliedRuleReason?.contains("news"))
+ }
+
+ @Test
+ fun `an episode no rule matches is left untouched`() = runTest {
+ rule(maxDurationMs = TimeUnit.MINUTES.toMillis(10))
+
+ assertEquals(0, applier.apply(store(episode("a", durationMs = TimeUnit.HOURS.toMillis(2)))))
+
+ val stored = episodeDao.rows.first { it.id == "a" }
+ assertNull(stored.appliedRuleId)
+ assertEquals(emptyList<String>(), queued())
+ }
+
+ /**
+ * A refresh routinely brings several episodes at once, and they should end up in the order
+ * they were published rather than whatever order the feed happened to list them in.
+ */
+ @Test
+ fun `a batch queued at the bottom stays in publish order`() = runTest {
+ rule(action = RuleAction.QUEUE_BOTTOM)
+ val day = TimeUnit.DAYS.toMillis(1)
+ val batch = store(
+ episode("newest", publishedAtMs = now),
+ episode("oldest", publishedAtMs = now - 2 * day),
+ episode("middle", publishedAtMs = now - day),
+ )
+
+ applier.apply(batch)
+
+ assertEquals(listOf("oldest", "middle", "newest"), queued())
+ }
+
+ /** Same order at the top of the queue, which takes applying them back to front. */
+ @Test
+ fun `a batch queued at the top stays in publish order`() = runTest {
+ rule(action = RuleAction.QUEUE_TOP)
+ val day = TimeUnit.DAYS.toMillis(1)
+ val head = store(episode("head")).first()
+ QueueRepository(queueDao).append(head.id)
+ val batch = store(
+ episode("newest", publishedAtMs = now),
+ episode("oldest", publishedAtMs = now - 2 * day),
+ episode("middle", publishedAtMs = now - day),
+ )
+
+ applier.apply(batch)
+
+ assertEquals(listOf("head", "oldest", "middle", "newest"), queued())
+ }
+
+ @Test
+ fun `it reports how many episodes a rule claimed`() = runTest {
+ rule(titleContains = "news")
+
+ val claimed = applier.apply(
+ store(episode("a", title = "The news roundup"), episode("b", title = "Interview"))
+ )
+
+ assertEquals(1, claimed)
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/queue/RuleEngineTest.kt b/app/src/test/java/org/terst/listen/queue/RuleEngineTest.kt
new file mode 100644
index 0000000..5eb1e06
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/queue/RuleEngineTest.kt
@@ -0,0 +1,206 @@
+package org.terst.listen.queue
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleEntity
+import java.util.concurrent.TimeUnit
+
+/**
+ * Matching is the whole contract of the rule engine, so it is pure and tested directly.
+ *
+ * The reason string is tested as hard as the matching is. Opaque automation is the thing that
+ * makes people abandon a queue: a rule that quietly ate an episode and cannot say why is worse
+ * than no rule at all.
+ */
+class RuleEngineTest {
+
+ private val now = 1_700_000_000_000L
+
+ private fun episode(
+ title: String = "Episode one",
+ subscriptionId: String = "show-1",
+ durationMs: Long? = TimeUnit.MINUTES.toMillis(30),
+ publishedAtMs: Long? = now - TimeUnit.DAYS.toMillis(1),
+ seasonNumber: Int? = null,
+ ) = EpisodeEntity(
+ id = "ep-1",
+ subscriptionId = subscriptionId,
+ guid = "guid-1",
+ title = title,
+ mediaUrl = "https://example.com/1.mp3",
+ mediaType = "audio/mpeg",
+ durationMs = durationMs,
+ publishedAtMs = publishedAtMs,
+ seasonNumber = seasonNumber,
+ discoveredAtMs = now,
+ )
+
+ /** A rule that matches everything; each test copies it with the one condition it is about. */
+ private val base = RuleEntity(
+ id = "rule-1",
+ name = "A rule",
+ position = 0,
+ action = RuleAction.QUEUE_BOTTOM,
+ )
+
+ @Test
+ fun `no rules matches nothing`() {
+ assertNull(RuleEngine.firstMatch(emptyList(), episode(), now))
+ }
+
+ @Test
+ fun `a rule with no conditions matches everything`() {
+ val match = RuleEngine.firstMatch(listOf(base), episode(), now)
+ assertEquals("rule-1", match?.rule?.id)
+ assertEquals("matches every episode", match?.reason)
+ }
+
+ @Test
+ fun `disabled rules are skipped`() {
+ val rules = listOf(base.copy(id = "off", enabled = false), base.copy(id = "on", position = 1))
+ assertEquals("on", RuleEngine.firstMatch(rules, episode(), now)?.rule?.id)
+ }
+
+ /** Position decides, not list order: the DAO's ordering is not the engine's to trust. */
+ @Test
+ fun `the lowest position wins, whatever order the rules arrive in`() {
+ val rules = listOf(base.copy(id = "second", position = 5), base.copy(id = "first", position = 1))
+ assertEquals("first", RuleEngine.firstMatch(rules, episode(), now)?.rule?.id)
+ }
+
+ @Test
+ fun `a rule scoped to one show ignores other shows`() {
+ val rules = listOf(base.copy(subscriptionId = "show-2"))
+ assertNull(RuleEngine.firstMatch(rules, episode(subscriptionId = "show-1"), now))
+ assertEquals(
+ "rule-1",
+ RuleEngine.firstMatch(rules, episode(subscriptionId = "show-2"), now)?.rule?.id,
+ )
+ }
+
+ @Test
+ fun `title matching ignores case`() {
+ val rules = listOf(base.copy(titleContains = "NEWS"))
+ assertEquals(
+ "rule-1",
+ RuleEngine.firstMatch(rules, episode(title = "The news roundup"), now)?.rule?.id,
+ )
+ }
+
+ @Test
+ fun `title exclusion rejects the episode`() {
+ val rules = listOf(base.copy(titleExcludes = "trailer"))
+ assertNull(RuleEngine.firstMatch(rules, episode(title = "Season 4 Trailer"), now))
+ assertEquals("rule-1", RuleEngine.firstMatch(rules, episode(title = "Part one"), now)?.rule?.id)
+ }
+
+ @Test
+ fun `duration bounds are inclusive at the edges`() {
+ val rules = listOf(
+ base.copy(
+ minDurationMs = TimeUnit.MINUTES.toMillis(20),
+ maxDurationMs = TimeUnit.MINUTES.toMillis(40),
+ )
+ )
+ assertNull(RuleEngine.firstMatch(rules, episode(durationMs = TimeUnit.MINUTES.toMillis(19)), now))
+ assertEquals(
+ "rule-1",
+ RuleEngine.firstMatch(rules, episode(durationMs = TimeUnit.MINUTES.toMillis(20)), now)?.rule?.id,
+ )
+ assertEquals(
+ "rule-1",
+ RuleEngine.firstMatch(rules, episode(durationMs = TimeUnit.MINUTES.toMillis(40)), now)?.rule?.id,
+ )
+ assertNull(RuleEngine.firstMatch(rules, episode(durationMs = TimeUnit.MINUTES.toMillis(41)), now))
+ }
+
+ /**
+ * Plenty of feeds omit `itunes:duration`. Treating a missing value as passing would let a
+ * "under 20 minutes" rule enqueue three-hour episodes, so an unknown fails the condition.
+ */
+ @Test
+ fun `an unknown duration fails a duration condition`() {
+ val rules = listOf(base.copy(maxDurationMs = TimeUnit.MINUTES.toMillis(20)))
+ assertNull(RuleEngine.firstMatch(rules, episode(durationMs = null), now))
+ }
+
+ @Test
+ fun `max age is measured from the publish date`() {
+ val rules = listOf(base.copy(maxAgeMs = TimeUnit.DAYS.toMillis(7)))
+ assertEquals(
+ "rule-1",
+ RuleEngine.firstMatch(rules, episode(publishedAtMs = now - TimeUnit.DAYS.toMillis(3)), now)?.rule?.id,
+ )
+ assertNull(
+ RuleEngine.firstMatch(rules, episode(publishedAtMs = now - TimeUnit.DAYS.toMillis(30)), now)
+ )
+ }
+
+ /** Same reasoning as duration: an unknown date must not pass a freshness condition. */
+ @Test
+ fun `an unknown publish date fails an age condition`() {
+ val rules = listOf(base.copy(maxAgeMs = TimeUnit.DAYS.toMillis(7)))
+ assertNull(RuleEngine.firstMatch(rules, episode(publishedAtMs = null), now))
+ }
+
+ @Test
+ fun `season must match exactly`() {
+ val rules = listOf(base.copy(seasonNumber = 3))
+ assertEquals("rule-1", RuleEngine.firstMatch(rules, episode(seasonNumber = 3), now)?.rule?.id)
+ assertNull(RuleEngine.firstMatch(rules, episode(seasonNumber = 2), now))
+ assertNull(RuleEngine.firstMatch(rules, episode(seasonNumber = null), now))
+ }
+
+ @Test
+ fun `every condition must hold, not just one`() {
+ val rules = listOf(base.copy(titleContains = "news", maxDurationMs = TimeUnit.MINUTES.toMillis(20)))
+ assertNull(
+ RuleEngine.firstMatch(
+ rules,
+ episode(title = "The news roundup", durationMs = TimeUnit.MINUTES.toMillis(45)),
+ now,
+ )
+ )
+ }
+
+ @Test
+ fun `the reason names each condition that matched`() {
+ val rules = listOf(
+ base.copy(
+ titleContains = "news",
+ maxDurationMs = TimeUnit.MINUTES.toMillis(20),
+ maxAgeMs = TimeUnit.DAYS.toMillis(7),
+ )
+ )
+ val reason = RuleEngine.firstMatch(
+ rules,
+ episode(
+ title = "The news roundup",
+ durationMs = TimeUnit.MINUTES.toMillis(14),
+ publishedAtMs = now - TimeUnit.DAYS.toMillis(2),
+ ),
+ now,
+ )?.reason.orEmpty()
+
+ assertTrue(reason, reason.contains("""title contains "news""""))
+ assertTrue(reason, reason.contains("14m"))
+ assertTrue(reason, reason.contains("20m"))
+ assertTrue(reason, reason.contains("2d"))
+ }
+
+ @Test
+ fun `durations over an hour read in hours and minutes`() {
+ val rules = listOf(base.copy(minDurationMs = TimeUnit.HOURS.toMillis(1)))
+ val reason = RuleEngine.firstMatch(
+ rules,
+ episode(durationMs = TimeUnit.MINUTES.toMillis(95)),
+ now,
+ )?.reason.orEmpty()
+
+ assertTrue(reason, reason.contains("1h 35m"))
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/queue/RuleSummaryTest.kt b/app/src/test/java/org/terst/listen/queue/RuleSummaryTest.kt
new file mode 100644
index 0000000..452844a
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/queue/RuleSummaryTest.kt
@@ -0,0 +1,111 @@
+package org.terst.listen.queue
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.terst.listen.data.RuleAction
+import org.terst.listen.data.RuleEntity
+import java.util.concurrent.TimeUnit
+
+/**
+ * The one-line account of a rule, shown under its name in the list.
+ *
+ * Tested rather than left to the UI because it is the only place the rule set is inspectable at
+ * a glance, and a summary that quietly omits a condition would make a rule look like it does
+ * something other than what it does.
+ */
+class RuleSummaryTest {
+
+ /** A do-nothing rule; each test copies it with the one thing it is about. */
+ private val base = RuleEntity(
+ id = "r",
+ name = "A rule",
+ position = 0,
+ action = RuleAction.QUEUE_BOTTOM,
+ )
+
+ @Test
+ fun `a rule with no conditions says so rather than looking empty`() {
+ assertEquals("Any episode → queue at the bottom", RuleSummary.describe(base, null))
+ }
+
+ @Test
+ fun `a show-scoped rule names the show`() {
+ assertEquals(
+ "The Daily → queue at the bottom",
+ RuleSummary.describe(base.copy(subscriptionId = "s1"), "The Daily"),
+ )
+ }
+
+ /** An unsubscribed show leaves the rule intact but nameless; it must still read sensibly. */
+ @Test
+ fun `a show-scoped rule with no title still reads`() {
+ assertEquals(
+ "One show → queue at the bottom",
+ RuleSummary.describe(base.copy(subscriptionId = "s1"), null),
+ )
+ }
+
+ @Test
+ fun `title conditions read as they were typed`() {
+ assertEquals(
+ """title has "news" · title has no "trailer" → queue at the top""",
+ RuleSummary.describe(
+ base.copy(titleContains = "news", titleExcludes = "trailer", action = RuleAction.QUEUE_TOP),
+ null,
+ ),
+ )
+ }
+
+ @Test
+ fun `a duration range reads as a range`() {
+ assertEquals(
+ "20m–40m → queue at the bottom",
+ RuleSummary.describe(
+ base.copy(
+ minDurationMs = TimeUnit.MINUTES.toMillis(20),
+ maxDurationMs = TimeUnit.MINUTES.toMillis(40),
+ ),
+ null,
+ ),
+ )
+ }
+
+ @Test
+ fun `one-sided durations read as under and over`() {
+ assertEquals(
+ "under 20m → queue at the bottom",
+ RuleSummary.describe(base.copy(maxDurationMs = TimeUnit.MINUTES.toMillis(20)), null),
+ )
+ assertEquals(
+ "over 1h → queue at the bottom",
+ RuleSummary.describe(base.copy(minDurationMs = TimeUnit.HOURS.toMillis(1)), null),
+ )
+ }
+
+ @Test
+ fun `age and season appear`() {
+ assertEquals(
+ "newer than 7d · season 3 → queue at the bottom",
+ RuleSummary.describe(base.copy(maxAgeMs = TimeUnit.DAYS.toMillis(7), seasonNumber = 3), null),
+ )
+ }
+
+ @Test
+ fun `mark played reads as skipping`() {
+ assertEquals(
+ "Any episode → mark played",
+ RuleSummary.describe(base.copy(action = RuleAction.MARK_PLAYED), null),
+ )
+ }
+
+ @Test
+ fun `download and speed are part of the action`() {
+ assertEquals(
+ "Any episode → queue at the top, download, 1.5×",
+ RuleSummary.describe(
+ base.copy(action = RuleAction.QUEUE_TOP, autoDownload = true, speed = 1.5f),
+ null,
+ ),
+ )
+ }
+}
diff --git a/app/src/test/java/org/terst/listen/work/DownloadDrainerTest.kt b/app/src/test/java/org/terst/listen/work/DownloadDrainerTest.kt
new file mode 100644
index 0000000..3470f2c
--- /dev/null
+++ b/app/src/test/java/org/terst/listen/work/DownloadDrainerTest.kt
@@ -0,0 +1,128 @@
+package org.terst.listen.work
+
+import kotlinx.coroutines.test.runTest
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.terst.listen.data.DownloadState
+import org.terst.listen.data.EpisodeEntity
+import org.terst.listen.data.FakeEpisodeDao
+import org.terst.listen.data.FakeSubscriptionDao
+import org.terst.listen.data.SubscriptionEntity
+import org.terst.listen.data.SubscriptionKind
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+/**
+ * Draining the downloads a rule queued.
+ *
+ * Extracted from the Worker for the same reason [RetentionSweeper] was: this is the only place
+ * the app fetches tens of megabytes without anyone asking it to, and getting the batching wrong
+ * means a phone quietly pulling a back catalogue.
+ */
+class DownloadDrainerTest {
+
+ private val now = 1_700_000_000_000L
+
+ private lateinit var episodes: FakeEpisodeDao
+ private lateinit var subscriptions: FakeSubscriptionDao
+ private val attempted = mutableListOf<Pair<String, SubscriptionKind>>()
+ private var succeed = true
+
+ @Before
+ fun setUp() {
+ episodes = FakeEpisodeDao()
+ subscriptions = FakeSubscriptionDao()
+ attempted.clear()
+ succeed = true
+ }
+
+ private fun drainer() = DownloadDrainer(episodes, subscriptions) { episode, kind ->
+ attempted += episode.id to kind
+ if (succeed) {
+ episodes.updateDownload(episode.id, DownloadState.DOWNLOADED, "/tmp/${episode.id}", now)
+ Result.success(File("/tmp/${episode.id}"))
+ } else {
+ episodes.updateDownload(episode.id, DownloadState.FAILED, null, null)
+ Result.failure(IllegalStateException("offline"))
+ }
+ }
+
+ private suspend fun show(id: String, kind: SubscriptionKind = SubscriptionKind.PODCAST) {
+ subscriptions.insert(
+ SubscriptionEntity(id = id, feedUrl = "https://feed/$id", kind = kind, title = id, addedAtMs = now)
+ )
+ }
+
+ private fun queued(id: String, subscriptionId: String = "show-1", publishedAtMs: Long = now) {
+ episodes.rows += EpisodeEntity(
+ id = id,
+ subscriptionId = subscriptionId,
+ guid = id,
+ title = id,
+ mediaUrl = "https://cdn/$id.mp3",
+ mediaType = "audio/mpeg",
+ publishedAtMs = publishedAtMs,
+ discoveredAtMs = now,
+ downloadState = DownloadState.QUEUED,
+ )
+ }
+
+ @Test
+ fun `nothing queued does nothing`() = runTest {
+ show("show-1")
+ assertEquals(0, drainer().drain())
+ assertEquals(emptyList<Any>(), attempted)
+ }
+
+ @Test
+ fun `queued episodes are fetched oldest first`() = runTest {
+ show("show-1")
+ val day = TimeUnit.DAYS.toMillis(1)
+ queued("newest", publishedAtMs = now)
+ queued("oldest", publishedAtMs = now - 2 * day)
+ queued("middle", publishedAtMs = now - day)
+
+ assertEquals(3, drainer().drain())
+ assertEquals(listOf("oldest", "middle", "newest"), attempted.map { it.first })
+ }
+
+ /** A catch-all rule can queue a great many at once; one job should not try to pull them all. */
+ @Test
+ fun `a batch is capped`() = runTest {
+ show("show-1")
+ repeat(10) { queued("e$it", publishedAtMs = now - it) }
+
+ assertEquals(3, drainer().drain(limit = 3))
+ assertEquals(3, attempted.size)
+ }
+
+ @Test
+ fun `the source is chosen by the subscription, not guessed`() = runTest {
+ show("show-1", SubscriptionKind.YOUTUBE_CHANNEL)
+ queued("a")
+
+ drainer().drain()
+
+ assertEquals(SubscriptionKind.YOUTUBE_CHANNEL, attempted.single().second)
+ }
+
+ @Test
+ fun `a failure is counted as not downloaded and leaves the row failed`() = runTest {
+ show("show-1")
+ queued("a")
+ succeed = false
+
+ assertEquals(0, drainer().drain())
+ assertEquals(DownloadState.FAILED, episodes.rows.single().downloadState)
+ }
+
+ /** A rule scoped to a show that has since been unsubscribed must not crash the job. */
+ @Test
+ fun `an episode with no surviving subscription is skipped`() = runTest {
+ queued("a", subscriptionId = "gone")
+
+ assertEquals(0, drainer().drain())
+ assertEquals(emptyList<Any>(), attempted)
+ }
+}