diff --git a/native/app/build.gradle.kts b/native/app/build.gradle.kts index cd6bcbe..4e75252 100644 --- a/native/app/build.gradle.kts +++ b/native/app/build.gradle.kts @@ -51,11 +51,20 @@ android { jvmTarget = "17" } + sourceSets { + getByName("androidTest").assets.srcDirs("$projectDir/schemas") + getByName("test").assets.srcDirs("$projectDir/schemas") + } + buildFeatures { compose = true } } +ksp { + arg("room.schemaLocation", "$projectDir/schemas") +} + detekt { // Analyse statique du code Kotlin. La config de base est enrichie par les // réglages adaptés à Compose dans config/detekt/detekt.yml. diff --git a/native/app/schemas/com.localstream.app.data.db.AppDatabase/1.json b/native/app/schemas/com.localstream.app.data.db.AppDatabase/1.json new file mode 100644 index 0000000..df8d49a --- /dev/null +++ b/native/app/schemas/com.localstream.app.data.db.AppDatabase/1.json @@ -0,0 +1,174 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "a1b2c3d4e5f67890123456789abcdef0", + "entities": [ + { + "tableName": "watched_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `watched` INTEGER NOT NULL, `watched_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAt", + "columnName": "watched_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playback_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `progress_pct` REAL NOT NULL, `position_ms` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPct", + "columnName": "progress_pct", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `video_name` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `video_name`), FOREIGN KEY(`playlist_id`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoName", + "columnName": "video_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "video_name" + ] + }, + "indices": [ + { + "name": "index_playlist_item_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_item_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a1b2c3d4e5f67890123456789abcdef0')" + ] + } +} diff --git a/native/app/schemas/com.localstream.app.data.db.AppDatabase/2.json b/native/app/schemas/com.localstream.app.data.db.AppDatabase/2.json new file mode 100644 index 0000000..889690a --- /dev/null +++ b/native/app/schemas/com.localstream.app.data.db.AppDatabase/2.json @@ -0,0 +1,204 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "b8d730e1876d131068ba95614e856256", + "entities": [ + { + "tableName": "watched_items", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `watched` INTEGER NOT NULL, `watched_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "watched", + "columnName": "watched", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "watchedAt", + "columnName": "watched_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playback_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `progress_pct` REAL NOT NULL, `position_ms` INTEGER NOT NULL, `last_played_at` INTEGER NOT NULL, `media_store_id` INTEGER, PRIMARY KEY(`name`))", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "progressPct", + "columnName": "progress_pct", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "positionMs", + "columnName": "position_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastPlayedAt", + "columnName": "last_played_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mediaStoreId", + "columnName": "media_store_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "name" + ] + } + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `created_at` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "playlist_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` TEXT NOT NULL, `video_name` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `video_name`), FOREIGN KEY(`playlist_id`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "playlistId", + "columnName": "playlist_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "videoName", + "columnName": "video_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "video_name" + ] + }, + "indices": [ + { + "name": "index_playlist_item_playlist_id", + "unique": false, + "columnNames": [ + "playlist_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_item_playlist_id` ON `${TABLE_NAME}` (`playlist_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "tmdb_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`query_key` TEXT NOT NULL, `json` TEXT NOT NULL, `fetched_at` INTEGER NOT NULL, PRIMARY KEY(`query_key`))", + "fields": [ + { + "fieldPath": "queryKey", + "columnName": "query_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fetchedAt", + "columnName": "fetched_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "query_key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b8d730e1876d131068ba95614e856256')" + ] + } +} \ No newline at end of file diff --git a/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt b/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt index af4f94a..ea34564 100644 --- a/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt +++ b/native/app/src/main/java/com/localstream/app/data/db/AppDatabase.kt @@ -4,6 +4,8 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase import com.localstream.app.data.db.dao.PlaybackStateDao import com.localstream.app.data.db.dao.PlaylistDao import com.localstream.app.data.db.dao.TmdbMetadataDao @@ -29,7 +31,7 @@ import com.localstream.app.data.db.entity.WatchedItemEntity TmdbMetadataEntity::class, ], version = 2, - exportSchema = false, + exportSchema = true, ) abstract class AppDatabase : RoomDatabase() { @@ -41,6 +43,21 @@ abstract class AppDatabase : RoomDatabase() { companion object { private const val DB_NAME = "localstream.db" + val MIGRATION_1_2 = object : Migration(1, 2) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS `tmdb_metadata` ( + `query_key` TEXT NOT NULL, + `json` TEXT NOT NULL, + `fetched_at` INTEGER NOT NULL, + PRIMARY KEY(`query_key`) + ) + """.trimIndent(), + ) + } + } + @Volatile private var INSTANCE: AppDatabase? = null @@ -51,7 +68,7 @@ abstract class AppDatabase : RoomDatabase() { AppDatabase::class.java, DB_NAME, ) - .fallbackToDestructiveMigration() + .addMigrations(MIGRATION_1_2) .build() .also { INSTANCE = it } } diff --git a/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt b/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt new file mode 100644 index 0000000..3248fe0 --- /dev/null +++ b/native/app/src/test/java/com/localstream/app/data/AppDatabaseMigrationTest.kt @@ -0,0 +1,42 @@ +package com.localstream.app.data + +import androidx.sqlite.db.SupportSQLiteDatabase +import com.localstream.app.data.db.AppDatabase +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.lang.reflect.Proxy + +class AppDatabaseMigrationTest { + + @Test + fun migration1To2_hasCorrectVersions() { + assertEquals(1, AppDatabase.MIGRATION_1_2.startVersion) + assertEquals(2, AppDatabase.MIGRATION_1_2.endVersion) + } + + @Test + fun migration1To2_executesCreateTableTmdbMetadata() { + val executedQueries = mutableListOf() + + val dbProxy = Proxy.newProxyInstance( + SupportSQLiteDatabase::class.java.classLoader, + arrayOf(SupportSQLiteDatabase::class.java), + ) { _, method, args -> + if (method.name == "execSQL" && args != null && args.isNotEmpty()) { + executedQueries.add(args[0].toString()) + } + null + } as SupportSQLiteDatabase + + AppDatabase.MIGRATION_1_2.migrate(dbProxy) + + assertEquals(1, executedQueries.size) + val sql = executedQueries.first() + assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS `tmdb_metadata`")) + assertTrue(sql.contains("`query_key` TEXT NOT NULL")) + assertTrue(sql.contains("`json` TEXT NOT NULL")) + assertTrue(sql.contains("`fetched_at` INTEGER NOT NULL")) + assertTrue(sql.contains("PRIMARY KEY(`query_key`)")) + } +}