From 12a0f7f07179f4d2b98a5db3c2a6bca33b57644c Mon Sep 17 00:00:00 2001 From: Peter Stone Date: Sat, 26 Sep 2026 09:25:00 +0000 Subject: Seed the default filters on a fresh install too, not only on upgrade Caught while verifying on a device: after a reinstall the rules table was empty. A fresh install creates the schema at the current version and never runs a migration, so putting the seeding only in MIGRATION_4_5 gave the trailer and preview filters to upgraders and silently to nobody else. Peter's own phone would have been fine -- it is an upgrade from v4 -- but a reinstall later would have quietly lost them, which is the kind of gap that surfaces as "I thought you set that up". The SQL is now shared between the migration and a RoomDatabase.Callback, and covered by an instrumented test building a fresh database and asserting both filters arrive. No permission changes, no schema change. 425 unit tests green, 11 instrumented, detekt and lint clean. Coverage 65.18 -> 65.04, exception recorded. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B --- .../java/org/terst/listen/data/LibraryOrderTest.kt | 23 +++++++++ .../main/java/org/terst/listen/data/AppDatabase.kt | 58 ++++++++++++---------- 2 files changed, 56 insertions(+), 25 deletions(-) (limited to 'app/src') diff --git a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt index a181dd7..4117b79 100644 --- a/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt +++ b/app/src/androidTest/java/org/terst/listen/data/LibraryOrderTest.kt @@ -119,6 +119,29 @@ class LibraryOrderTest { assertEquals(listOf("b", "a"), order()) } + /** + * A fresh install gets the default filters too. + * + * Found on a device: a reinstall creates the schema at the current version and never runs a + * migration, so seeding only in MIGRATION_4_5 quietly gave them to upgraders alone. + */ + @Test + fun aFreshDatabaseIsSeededWithTheDefaultFilters() = runBlocking { + val fresh = Room.inMemoryDatabaseBuilder( + InstrumentationRegistry.getInstrumentation().targetContext, + AppDatabase::class.java, + ).addCallback(AppDatabase.SEED_DEFAULT_FILTERS).build() + + try { + // Room creates the database lazily, so the callback has not run until it is used. + val rules = fresh.rules().getAll() + assertEquals(listOf("trailer", "preview"), rules.map { it.titleContains }) + assertEquals(listOf(RuleAction.MARK_PLAYED, RuleAction.MARK_PLAYED), rules.map { it.action }) + } finally { + fresh.close() + } + } + /** A show with nothing waiting sorts last, by title, rather than vanishing. */ @Test fun aShowWithNothingWaitingSortsLast() = runBlocking { 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 c33921b..fe73ce0 100644 --- a/app/src/main/java/org/terst/listen/data/AppDatabase.kt +++ b/app/src/main/java/org/terst/listen/data/AppDatabase.kt @@ -101,37 +101,44 @@ 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 + * Rules live in the database on the phone, so code 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. + * INSERT OR IGNORE on a fixed id, so deleting one does not bring it back. */ + private fun seedDefaultFilters(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), + ) + } + 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") - } + override fun migrate(db: SupportSQLiteDatabase) = seedDefaultFilters(db) + } - 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), - ) - } + /** + * The same filters for a fresh install, which never runs a migration. + * + * Found on a device: a reinstall creates the schema at the current version directly, so + * seeding only in MIGRATION_4_5 gave the filters to upgraders and silently to nobody + * else. + */ + val SEED_DEFAULT_FILTERS = object : RoomDatabase.Callback() { + override fun onCreate(db: SupportSQLiteDatabase) = seedDefaultFilters(db) } fun get(context: Context): AppDatabase = instance ?: synchronized(this) { @@ -143,6 +150,7 @@ abstract class AppDatabase : RoomDatabase() { // 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, MIGRATION_4_5) + .addCallback(SEED_DEFAULT_FILTERS) // 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. -- cgit v1.2.3