diff options
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) + } +} |
