summaryrefslogtreecommitdiff
path: root/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'app/src')
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt131
-rw-r--r--app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt49
-rw-r--r--app/src/main/java/org/terst/listen/data/AppDatabase.kt40
-rw-r--r--app/src/main/java/org/terst/listen/data/Daos.kt11
4 files changed, 226 insertions, 5 deletions
diff --git a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
new file mode 100644
index 0000000..a181dd7
--- /dev/null
+++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt
@@ -0,0 +1,131 @@
+package org.terst.listen.data
+
+import androidx.room.Room
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * The order shows appear in on the library screen.
+ *
+ * Instrumented because the ordering is SQL: the fake DAOs return insertion order, so this is
+ * exactly the kind of rule that cannot be pinned off-device.
+ *
+ * A show ranks by its newest episode that is still waiting for you -- unplayed *and* unqueued.
+ * Peter's words: once they're on the queue they're noise in the list.
+ */
+@RunWith(AndroidJUnit4::class)
+class LibraryOrderTest {
+
+ private lateinit var db: AppDatabase
+ private lateinit var subscriptions: SubscriptionDao
+ private lateinit var episodes: EpisodeDao
+ private lateinit var queue: QueueDao
+
+ private val day = 24 * 60 * 60 * 1000L
+
+ @Before
+ fun setUp() {
+ db = Room.inMemoryDatabaseBuilder(
+ InstrumentationRegistry.getInstrumentation().targetContext,
+ AppDatabase::class.java,
+ ).build()
+ subscriptions = db.subscriptions()
+ episodes = db.episodes()
+ queue = db.queue()
+ }
+
+ @After
+ fun tearDown() = db.close()
+
+ private suspend fun show(id: String) = subscriptions.insert(
+ SubscriptionEntity(id = id, feedUrl = "https://example.com/$id", title = id, addedAtMs = 0)
+ )
+
+ private suspend fun episode(id: String, showId: String, publishedAtMs: Long) =
+ episodes.insertAll(
+ listOf(
+ EpisodeEntity(
+ id = id,
+ subscriptionId = showId,
+ guid = id,
+ title = id,
+ mediaUrl = "https://cdn/$id.mp3",
+ mediaType = "audio/mpeg",
+ publishedAtMs = publishedAtMs,
+ discoveredAtMs = 0,
+ )
+ )
+ )
+
+ private suspend fun order() = subscriptions.observeAll().first().map { it.id }
+
+ @Test
+ fun ranksByTheNewestEpisodeStillWaiting() = runBlocking {
+ show("older")
+ show("newer")
+ episode("e1", "older", 1 * day)
+ episode("e2", "newer", 5 * day)
+
+ assertEquals(listOf("newer", "older"), order())
+ }
+
+ /**
+ * The change Peter asked for. Queueing a show's newest episode deals with it, so the show
+ * should stop being pulled to the top by something already waiting in Up Next.
+ */
+ @Test
+ fun aQueuedEpisodeStopsPullingItsShowUp() = runBlocking {
+ show("older")
+ show("newer")
+ episode("e1", "older", 1 * day)
+ episode("e2", "newer", 5 * day)
+ assertEquals(listOf("newer", "older"), order())
+
+ queue.replaceAll(listOf("e2"))
+
+ assertEquals("the queued show has nothing left waiting", listOf("older", "newer"), order())
+ }
+
+ /** An older unqueued episode still counts -- the show is ranked by what remains. */
+ @Test
+ fun anOlderUnqueuedEpisodeStillCounts() = runBlocking {
+ show("a")
+ show("b")
+ episode("a-new", "a", 9 * day)
+ episode("a-old", "a", 2 * day)
+ episode("b-mid", "b", 4 * day)
+
+ queue.replaceAll(listOf("a-new"))
+
+ assertEquals("a still has a 2-day-old episode, b has a 4-day-old one", listOf("b", "a"), order())
+ }
+
+ /** Played episodes were already excluded; queued ones join them. */
+ @Test
+ fun playedEpisodesStillDoNotCount() = runBlocking {
+ show("a")
+ show("b")
+ episode("a-new", "a", 9 * day)
+ episode("b-mid", "b", 4 * day)
+ episodes.markPlayed("a-new", 1)
+
+ assertEquals(listOf("b", "a"), order())
+ }
+
+ /** A show with nothing waiting sorts last, by title, rather than vanishing. */
+ @Test
+ fun aShowWithNothingWaitingSortsLast() = runBlocking {
+ show("empty")
+ show("active")
+ episode("e1", "active", 3 * day)
+
+ assertEquals(listOf("active", "empty"), order())
+ }
+}
diff --git a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
index 0eeab50..181e853 100644
--- a/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
+++ b/app/src/androidTest/java/org/terst/listen/data/MigrationTest.kt
@@ -148,4 +148,53 @@ class MigrationTest {
assertEquals(true, cursor.isNull(1))
}
}
+
+ /**
+ * The trailer and preview filters arrive as ordinary rules, after anything already there.
+ *
+ * Taking position 0 would let them pre-empt rules already being relied on, which is not a
+ * thing an upgrade should do quietly.
+ */
+ @Test
+ fun migrates4To5SeedingFiltersAfterExistingRules() {
+ helper.createDatabase(name, 4).use { db ->
+ db.execSQL(
+ """INSERT INTO rules (id, name, position, enabled, action, autoDownload)
+ VALUES ('mine', 'My rule', 0, 1, 'QUEUE_BOTTOM', 0)"""
+ )
+ }
+
+ val db = helper.runMigrationsAndValidate(name, 5, true, AppDatabase.MIGRATION_4_5)
+
+ db.query("SELECT id, titleContains, action, enabled FROM rules ORDER BY position").use { cursor ->
+ assertEquals(3, cursor.count)
+ cursor.moveToFirst()
+ assertEquals("the listener's own rule keeps its place at the top", "mine", cursor.getString(0))
+ cursor.moveToNext()
+ assertEquals("listen-default-trailer", cursor.getString(0))
+ assertEquals("trailer", cursor.getString(1))
+ assertEquals("MARK_PLAYED", cursor.getString(2))
+ assertEquals(1, cursor.getInt(3))
+ cursor.moveToNext()
+ assertEquals("listen-default-preview", cursor.getString(0))
+ assertEquals("preview", cursor.getString(1))
+ }
+ }
+
+ /** A fresh install migrating the whole way still ends up with exactly the two filters. */
+ @Test
+ fun migrates1To5SeedingExactlyTwoFilters() {
+ helper.createDatabase(name, 1).close()
+
+ val db = helper.runMigrationsAndValidate(
+ name, 5, true,
+ AppDatabase.MIGRATION_1_2, AppDatabase.MIGRATION_2_3,
+ AppDatabase.MIGRATION_3_4, AppDatabase.MIGRATION_4_5,
+ )
+
+ db.query("SELECT COUNT(*) FROM rules").use { cursor ->
+ cursor.moveToFirst()
+ assertEquals(2, 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 7897678..c33921b 100644
--- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt
+++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt
@@ -25,7 +25,7 @@ class Converters {
QueueEntity::class,
RuleEntity::class,
],
- version = 4,
+ version = 5,
exportSchema = true,
)
@TypeConverters(Converters::class)
@@ -98,6 +98,42 @@ abstract class AppDatabase : RoomDatabase() {
}
}
+ /**
+ * Seeds the trailer and preview filters Peter asked for.
+ *
+ * Rules live in the database on the phone, so a migration is the only way to put them
+ * there. They are ordinary rules: visible in the Rules screen, and editable, disableable
+ * or deletable like any other. Appended after whatever is already there rather than
+ * taking position 0, so they cannot pre-empt rules already being relied on, and they
+ * only act on newly discovered episodes -- nothing already in the library changes.
+ *
+ * INSERT OR IGNORE on a fixed id, so deleting one does not bring it back on the next
+ * upgrade.
+ */
+ val MIGRATION_4_5 = object : Migration(4, 5) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ seedFilter(db, "listen-default-trailer", "Trailers", "trailer")
+ seedFilter(db, "listen-default-preview", "Previews", "preview")
+ }
+
+ private fun seedFilter(
+ db: SupportSQLiteDatabase,
+ id: String,
+ name: String,
+ word: String,
+ ) {
+ db.execSQL(
+ "INSERT OR IGNORE INTO rules " +
+ "(id, name, position, enabled, subscriptionId, titleContains, " +
+ "titleExcludes, minDurationMs, maxDurationMs, maxAgeMs, seasonNumber, " +
+ "action, autoDownload, speed) " +
+ "SELECT ?, ?, COALESCE(MAX(position), -1) + 1, 1, NULL, ?, NULL, " +
+ "NULL, NULL, NULL, NULL, 'MARK_PLAYED', 0, NULL FROM rules",
+ arrayOf(id, name, word),
+ )
+ }
+ }
+
fun get(context: Context): AppDatabase = instance ?: synchronized(this) {
instance ?: build(context.applicationContext).also { instance = it }
}
@@ -106,7 +142,7 @@ abstract class AppDatabase : RoomDatabase() {
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, MIGRATION_2_3, MIGRATION_3_4)
+ .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
// 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 608cf77..abc7ea3 100644
--- a/app/src/main/java/org/terst/listen/data/Daos.kt
+++ b/app/src/main/java/org/terst/listen/data/Daos.kt
@@ -11,17 +11,22 @@ import kotlinx.coroutines.flow.Flow
@Dao
interface SubscriptionDao {
/**
- * Newest *unplayed* episode first.
+ * Newest episode *still waiting* first -- unplayed, and not already queued.
*
* Not newest episode: a show you are caught up on would sit at the top having nothing for
* you, which is the opposite of useful. Sorting on what is still unheard puts whatever
* actually wants listening to at the top, and drops a finished show down the list until it
- * publishes again. Shows with nothing unplayed sort last, which is the COALESCE to 0.
+ * publishes again. Shows with nothing waiting sort last, which is the COALESCE to 0.
+ *
+ * Queued episodes are excluded for the same reason played ones are: they have been dealt
+ * with. Peter's words -- once they're on the queue they're noise in the list.
*/
@Query(
"""SELECT s.* FROM subscriptions s
LEFT JOIN (SELECT subscriptionId, MAX(COALESCE(publishedAtMs, 0)) AS latest
- FROM episodes WHERE playedAtMs IS NULL GROUP BY subscriptionId) e
+ FROM episodes
+ WHERE playedAtMs IS NULL AND id NOT IN (SELECT episodeId FROM queue)
+ GROUP BY subscriptionId) e
ON e.subscriptionId = s.id
ORDER BY COALESCE(e.latest, 0) DESC, s.title COLLATE NOCASE"""
)