diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-25 09:23:02 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-25 09:23:02 +0000 |
| commit | f04a01c672c01f4913d4b9bbaf2fc4febbaec08c (patch) | |
| tree | 5ac86711fc5101896e58c70aca78177d3badcffc /app/src | |
| parent | d9585c40e7f3b6de8b5f0c37e27edec49d565aa8 (diff) | |
Auto-queue rules: decide what happens to an episode as it arrives
The first of Phase 2's three tracks. A rule is a set of conditions on a newly
discovered episode -- show, title contains/excludes, duration range, age, season
-- and an action: queue at the top or bottom, mark played, and optionally
download it or pin it to a speed. Rules are ordered, individually switchable,
and the first one that matches claims the episode.
Every claimed episode records which rule fired and the account that rule gave,
shown on the episode screen as `Handled by "News to the queue" - title contains
"news"`. That is not decoration. Automation that fills a queue and cannot say
why is the thing that makes people stop trusting it, so the reason string is
tested as hard as the matching is.
Three decisions worth keeping:
Rules never fire on a first subscribe. That import discovers the entire back
catalogue -- 2,753 episodes on a real feed -- and a catch-all rule would enqueue
all of it. `applyEpisodes` takes a `report` flag and `subscribe` passes false.
Verified live: subscribing to NPR News Now stored four episodes and queued none;
deleting the newest and refreshing queued exactly that one, with its reason
recorded.
A rule's speed lands on the episode, not the show. Writing it to
`SubscriptionEntity.playbackSpeed` would let one matching episode silently reset
the speed of everything else the show publishes.
Auto-downloads go through a new DownloadWorker rather than the refresh. A
refresh should finish in seconds whatever the rules decided; a worker that spent
twenty minutes pulling audio would be killed mid-flight with the feed state half
written. The drainer caps a batch at ten and leaves a failure FAILED rather than
retrying forever against a 404.
Schema v1 -> v2, this app's first migration, with MIGRATION_1_2 validated on a
device against the exported v1 schema and a seeded playback position read back
afterwards. Two pieces of plumbing were needed and both fail obscurely: the
schemas directory wired into androidTest assets, and kotlinx-serialization
raised to 1.8.1 because Room 2.8's bundle parser calls a method 1.7.3 lacks and
dies with AbstractMethodError at runtime. Both are written up in CLAUDE.md,
along with the trap that cost the most time -- changing an entity before bumping
the version rewrites the exported schema for the old version, destroying the
only record of what is on the phone.
No permission changes. 298 unit tests green, detekt and lint clean, coverage
61.60 -> 63.63.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'app/src')
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) + } +} |
