From bd1114d05ceb1c0ddec8d855d5b7e36ff17a68d4 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 4 Sep 2026 07:00:15 +0800 Subject: [PATCH 1/5] feat(core.db): store rating_class_alias on difficulties, drop charts view - Difficulty gains a nullable rating_class_alias column (schema 14 -> 15); the raw upstream value is interpreted only by ArcaeaRatingClassDisplay. - The `charts` DatabaseView is replaced by explicit join queries returning Chart as a plain POJO. Views force their definition to be recreated in every migration that touches the joined tables; DAO-level joins avoid that and let consumers of the query stay unchanged. The view is dropped in the migration so a future `CREATE VIEW charts` cannot collide with a stale definition. - Duplicate columns shared by play_results and the chart join (song_id, rating_class, date) resolve to the play_results value, as before. Refs #61 --- .../15.json | 689 ++++++++++++++++++ .../core/DatabaseMigrationsTest.kt | 45 ++ .../core/database/ArcaeaOfflineDatabase.kt | 6 +- .../core/database/daos/ChartDao.kt | 73 +- .../core/database/daos/RelationshipsDao.kt | 25 +- .../core/database/entities/Chart.kt | 23 +- .../core/database/entities/Difficulty.kt | 3 + .../core/database/helpers/ChartFactory.kt | 1 + .../database/migrations/Migration_14_15.kt | 16 + 9 files changed, 848 insertions(+), 33 deletions(-) create mode 100644 core/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/15.json create mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_14_15.kt diff --git a/core/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/15.json b/core/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/15.json new file mode 100644 index 00000000..9a71a1ca --- /dev/null +++ b/core/schemas/xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase/15.json @@ -0,0 +1,689 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "695f3a8be7efe40bdffb285ef54f4a81", + "entities": [ + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "packs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `description` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "packs_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lang` TEXT NOT NULL, `name` TEXT, `description` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `packs`(`id`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_packs_localized_id_lang", + "unique": true, + "columnNames": [ + "id", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_packs_localized_id_lang` ON `${TABLE_NAME}` (`id`, `lang`)" + }, + { + "name": "index_packs_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packs_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "packs", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "songs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idx` INTEGER NOT NULL, `id` TEXT NOT NULL, `title` TEXT NOT NULL, `artist` TEXT NOT NULL, `set` TEXT NOT NULL, `bpm` TEXT, `bpm_base` REAL, `audio_preview` INTEGER, `audio_preview_end` INTEGER, `side` INTEGER NOT NULL, `version` TEXT, `date` INTEGER, `bg` TEXT, `bg_inverse` TEXT, `bg_day` TEXT, `bg_night` TEXT, `source` TEXT, `source_copyright` TEXT, `deleted_in_game` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "idx", + "columnName": "idx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "set", + "columnName": "set", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "TEXT" + }, + { + "fieldPath": "bpmBase", + "columnName": "bpm_base", + "affinity": "REAL" + }, + { + "fieldPath": "audioPreview", + "columnName": "audio_preview", + "affinity": "INTEGER" + }, + { + "fieldPath": "audioPreviewEnd", + "columnName": "audio_preview_end", + "affinity": "INTEGER" + }, + { + "fieldPath": "side", + "columnName": "side", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + }, + { + "fieldPath": "bg", + "columnName": "bg", + "affinity": "TEXT" + }, + { + "fieldPath": "bgInverse", + "columnName": "bg_inverse", + "affinity": "TEXT" + }, + { + "fieldPath": "bgDay", + "columnName": "bg_day", + "affinity": "TEXT" + }, + { + "fieldPath": "bgNight", + "columnName": "bg_night", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + }, + { + "fieldPath": "sourceCopyright", + "columnName": "source_copyright", + "affinity": "TEXT" + }, + { + "fieldPath": "deletedInGame", + "columnName": "deleted_in_game", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "songs_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lang` TEXT NOT NULL, `title` TEXT, `source` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `songs`(`id`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "source", + "columnName": "source", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_songs_localized_id_lang", + "unique": true, + "columnNames": [ + "id", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_songs_localized_id_lang` ON `${TABLE_NAME}` (`id`, `lang`)" + }, + { + "name": "index_songs_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_songs_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "songs", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "difficulties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `rating_class_alias` INTEGER, `rating` INTEGER NOT NULL, `rating_plus` INTEGER NOT NULL, `chart_designer` TEXT, `jacket_designer` TEXT, `audio_override` INTEGER NOT NULL, `jacket_override` INTEGER NOT NULL, `jacket_night` TEXT, `title` TEXT, `artist` TEXT, `bg` TEXT, `bg_inverse` TEXT, `bpm` TEXT, `bpm_base` REAL, `version` TEXT, `date` INTEGER, PRIMARY KEY(`song_id`, `rating_class`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingClassAlias", + "columnName": "rating_class_alias", + "affinity": "INTEGER" + }, + { + "fieldPath": "rating", + "columnName": "rating", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ratingPlus", + "columnName": "rating_plus", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "chartDesigner", + "columnName": "chart_designer", + "affinity": "TEXT" + }, + { + "fieldPath": "jacketDesigner", + "columnName": "jacket_designer", + "affinity": "TEXT" + }, + { + "fieldPath": "audioOverride", + "columnName": "audio_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "jacketOverride", + "columnName": "jacket_override", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "jacketNight", + "columnName": "jacket_night", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + }, + { + "fieldPath": "bg", + "columnName": "bg", + "affinity": "TEXT" + }, + { + "fieldPath": "bgInverse", + "columnName": "bg_inverse", + "affinity": "TEXT" + }, + { + "fieldPath": "bpm", + "columnName": "bpm", + "affinity": "TEXT" + }, + { + "fieldPath": "bpmBase", + "columnName": "bpm_base", + "affinity": "REAL" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + } + }, + { + "tableName": "difficulties_localized", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `lang` TEXT NOT NULL, `title` TEXT, `artist` TEXT, PRIMARY KEY(`song_id`, `rating_class`), FOREIGN KEY(`song_id`, `rating_class`) REFERENCES `difficulties`(`song_id`, `rating_class`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lang", + "columnName": "lang", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "artist", + "columnName": "artist", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + }, + "indices": [ + { + "name": "index_difficulties_localized_song_id_rating_class_lang", + "unique": true, + "columnNames": [ + "song_id", + "rating_class", + "lang" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_difficulties_localized_song_id_rating_class_lang` ON `${TABLE_NAME}` (`song_id`, `rating_class`, `lang`)" + }, + { + "name": "index_difficulties_localized_lang", + "unique": false, + "columnNames": [ + "lang" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_difficulties_localized_lang` ON `${TABLE_NAME}` (`lang`)" + } + ], + "foreignKeys": [ + { + "table": "difficulties", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "song_id", + "rating_class" + ], + "referencedColumns": [ + "song_id", + "rating_class" + ] + } + ] + }, + { + "tableName": "charts_info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `constant` INTEGER NOT NULL, `notes` INTEGER, PRIMARY KEY(`song_id`, `rating_class`))", + "fields": [ + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "constant", + "columnName": "constant", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "song_id", + "rating_class" + ] + } + }, + { + "tableName": "play_results", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uuid` BLOB NOT NULL, `song_id` TEXT NOT NULL, `rating_class` INTEGER NOT NULL, `score` INTEGER NOT NULL, `pure` INTEGER, `far` INTEGER, `lost` INTEGER, `date` INTEGER, `max_recall` INTEGER, `modifier` INTEGER, `clear_type` INTEGER, `comment` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "song_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ratingClass", + "columnName": "rating_class", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "score", + "columnName": "score", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pure", + "columnName": "pure", + "affinity": "INTEGER" + }, + { + "fieldPath": "far", + "columnName": "far", + "affinity": "INTEGER" + }, + { + "fieldPath": "lost", + "columnName": "lost", + "affinity": "INTEGER" + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxRecall", + "columnName": "max_recall", + "affinity": "INTEGER" + }, + { + "fieldPath": "modifier", + "columnName": "modifier", + "affinity": "INTEGER" + }, + { + "fieldPath": "clearType", + "columnName": "clear_type", + "affinity": "INTEGER" + }, + { + "fieldPath": "comment", + "columnName": "comment", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_play_results_uuid", + "unique": true, + "columnNames": [ + "uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_play_results_uuid` ON `${TABLE_NAME}` (`uuid`)" + }, + { + "name": "index_play_results_song_id_rating_class", + "unique": false, + "columnNames": [ + "song_id", + "rating_class" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_play_results_song_id_rating_class` ON `${TABLE_NAME}` (`song_id`, `rating_class`)" + } + ] + }, + { + "tableName": "r30_entries", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uuid` BLOB NOT NULL, FOREIGN KEY(`uuid`) REFERENCES `play_results`(`uuid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_r30_entries_uuid", + "unique": false, + "columnNames": [ + "uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_r30_entries_uuid` ON `${TABLE_NAME}` (`uuid`)" + } + ], + "foreignKeys": [ + { + "table": "play_results", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "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, '695f3a8be7efe40bdffb285ef54f4a81')" + ] + } +} \ No newline at end of file diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt index 4ac1eeba..9d06a023 100644 --- a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt @@ -10,12 +10,14 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase +import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_14_15 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 import java.io.IOException @RunWith(AndroidJUnit4::class) class DatabaseMigrationsTest { private val testDatabaseName = "arcaea-offline" + private val testDatabaseName14To15 = "arcaea-offline-14-15" @get:Rule val helper: MigrationTestHelper = @@ -70,6 +72,49 @@ class DatabaseMigrationsTest { } } + @Test + @Throws(IOException::class) + fun migrate14To15() { + helper.createDatabase(testDatabaseName14To15, 14).apply { + // NOT NULL columns must be provided; nullable ones are omitted. + execSQL( + "INSERT INTO difficulties " + + "(song_id, rating_class, rating, rating_plus, audio_override, jacket_override) " + + "VALUES ('test', 3, 110, 0, 0, 0)", + ) + close() + } + + val db = + helper.runMigrationsAndValidate(testDatabaseName14To15, 15, true, Migration_14_15) + + // Old rows get a null alias. + db + .query("SELECT rating_class_alias FROM difficulties WHERE song_id = 'test'") + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertTrue(cursor.isNull(0)) + } + + // The column accepts alias values. + db.execSQL("UPDATE difficulties SET rating_class_alias = 1 WHERE song_id = 'test'") + db + .query("SELECT rating_class_alias FROM difficulties WHERE song_id = 'test'") + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(1, cursor.getInt(0)) + } + + // The dropped `charts` view must not linger: a future `CREATE VIEW charts` + // would fail while the stale definition still exists. + db + .query("SELECT COUNT(*) FROM sqlite_master WHERE type = 'view' AND name = 'charts''") + .use { cursor -> + assertTrue(cursor.moveToFirst()) + assertEquals(0, cursor.getInt(0)) + } + } + private fun execSqlScript( db: SupportSQLiteDatabase, filename: String, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt index 56070683..e7d41c0b 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt @@ -27,7 +27,6 @@ import xyz.sevive.arcaeaoffline.core.database.daos.R30EntryDao import xyz.sevive.arcaeaoffline.core.database.daos.RelationshipsDao import xyz.sevive.arcaeaoffline.core.database.daos.SongDao import xyz.sevive.arcaeaoffline.core.database.daos.SongLocalizedDao -import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.ChartInfo import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty import xyz.sevive.arcaeaoffline.core.database.entities.DifficultyLocalized @@ -40,6 +39,7 @@ import xyz.sevive.arcaeaoffline.core.database.entities.Song import xyz.sevive.arcaeaoffline.core.database.entities.SongLocalized import xyz.sevive.arcaeaoffline.core.database.migrations.AutoMigration_5_6 import xyz.sevive.arcaeaoffline.core.database.migrations.AutoMigration_9_10 +import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_14_15 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_6_7 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 @@ -56,7 +56,6 @@ import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 PlayResult::class, R30Entry::class, ], - views = [Chart::class], autoMigrations = [ AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6, spec = AutoMigration_5_6::class), @@ -67,7 +66,7 @@ import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 AutoMigration(from = 12, to = 13), AutoMigration(from = 13, to = 14), ], - version = 14, + version = 15, exportSchema = true, ) @TypeConverters( @@ -129,6 +128,7 @@ abstract class ArcaeaOfflineDatabase : RoomDatabase() { .addMigrations( Migration_6_7, Migration_7_8, + Migration_14_15, ).build() .also { instance = it } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/ChartDao.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/ChartDao.kt index 00e3f93c..7a8e1e56 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/ChartDao.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/ChartDao.kt @@ -8,18 +8,83 @@ import xyz.sevive.arcaeaoffline.core.database.entities.Chart @Dao interface ChartDao { - @Query("SELECT * FROM charts WHERE song_id = :songId AND rating_class = :ratingClass") + // The former `charts` DatabaseView definition lives on as these explicit + // joins; keep them in sync with Chart's fields when the query changes. + @Query( + """SELECT + s.idx AS song_idx, d.song_id, d.rating_class, d.rating_class_alias, + d.rating, d.rating_plus, + COALESCE(d.title, s.title) AS title, COALESCE(d.artist, s.artist) AS artist, + s.`set`, COALESCE(d.bpm, s.bpm) AS bpm, COALESCE(d.bpm_base, s.bpm_base) AS bpm_base, + s.audio_preview, s.audio_preview_end, s.side, + COALESCE(d.version, s.version) AS version, COALESCE(d.date, s.date) AS date, + COALESCE(d.bg, s.bg) AS bg, COALESCE(d.bg_inverse, s.bg_inverse) AS bg_inverse, + s.bg_day, s.bg_night, s.source, s.source_copyright, + d.chart_designer, d.jacket_designer, d.audio_override, d.jacket_override, + d.jacket_night, ci.constant, ci.notes + FROM difficulties d + INNER JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class + INNER JOIN songs s ON d.song_id = s.id + WHERE d.song_id = :songId AND d.rating_class = :ratingClass""", + ) fun find( songId: String, ratingClass: ArcaeaRatingClass, ): Flow - @Query("SELECT * FROM charts") + @Query( + """SELECT + s.idx AS song_idx, d.song_id, d.rating_class, d.rating_class_alias, + d.rating, d.rating_plus, + COALESCE(d.title, s.title) AS title, COALESCE(d.artist, s.artist) AS artist, + s.`set`, COALESCE(d.bpm, s.bpm) AS bpm, COALESCE(d.bpm_base, s.bpm_base) AS bpm_base, + s.audio_preview, s.audio_preview_end, s.side, + COALESCE(d.version, s.version) AS version, COALESCE(d.date, s.date) AS date, + COALESCE(d.bg, s.bg) AS bg, COALESCE(d.bg_inverse, s.bg_inverse) AS bg_inverse, + s.bg_day, s.bg_night, s.source, s.source_copyright, + d.chart_designer, d.jacket_designer, d.audio_override, d.jacket_override, + d.jacket_night, ci.constant, ci.notes + FROM difficulties d + INNER JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class + INNER JOIN songs s ON d.song_id = s.id""", + ) fun findAll(): Flow> - @Query("SELECT * FROM charts WHERE song_id = :songId") + @Query( + """SELECT + s.idx AS song_idx, d.song_id, d.rating_class, d.rating_class_alias, + d.rating, d.rating_plus, + COALESCE(d.title, s.title) AS title, COALESCE(d.artist, s.artist) AS artist, + s.`set`, COALESCE(d.bpm, s.bpm) AS bpm, COALESCE(d.bpm_base, s.bpm_base) AS bpm_base, + s.audio_preview, s.audio_preview_end, s.side, + COALESCE(d.version, s.version) AS version, COALESCE(d.date, s.date) AS date, + COALESCE(d.bg, s.bg) AS bg, COALESCE(d.bg_inverse, s.bg_inverse) AS bg_inverse, + s.bg_day, s.bg_night, s.source, s.source_copyright, + d.chart_designer, d.jacket_designer, d.audio_override, d.jacket_override, + d.jacket_night, ci.constant, ci.notes + FROM difficulties d + INNER JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class + INNER JOIN songs s ON d.song_id = s.id + WHERE d.song_id = :songId""", + ) fun findAllBySongId(songId: String): Flow> - @Query("SELECT * FROM charts WHERE song_id IN (:songIds)") + @Query( + """SELECT + s.idx AS song_idx, d.song_id, d.rating_class, d.rating_class_alias, + d.rating, d.rating_plus, + COALESCE(d.title, s.title) AS title, COALESCE(d.artist, s.artist) AS artist, + s.`set`, COALESCE(d.bpm, s.bpm) AS bpm, COALESCE(d.bpm_base, s.bpm_base) AS bpm_base, + s.audio_preview, s.audio_preview_end, s.side, + COALESCE(d.version, s.version) AS version, COALESCE(d.date, s.date) AS date, + COALESCE(d.bg, s.bg) AS bg, COALESCE(d.bg_inverse, s.bg_inverse) AS bg_inverse, + s.bg_day, s.bg_night, s.source, s.source_copyright, + d.chart_designer, d.jacket_designer, d.audio_override, d.jacket_override, + d.jacket_night, ci.constant, ci.notes + FROM difficulties d + INNER JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class + INNER JOIN songs s ON d.song_id = s.id + WHERE d.song_id IN (:songIds)""", + ) fun findAllBySongIds(songIds: List): Flow> } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/RelationshipsDao.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/RelationshipsDao.kt index e4be71c6..1735b918 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/RelationshipsDao.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/daos/RelationshipsDao.kt @@ -2,7 +2,6 @@ package xyz.sevive.arcaeaoffline.core.database.daos import androidx.room.Dao import androidx.room.Query -import androidx.room.RewriteQueriesToDropUnusedColumns import kotlinx.coroutines.flow.Flow import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.MinimumPlayResultPotentialFields @@ -10,14 +9,26 @@ import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult @Dao interface RelationshipsDao { - @RewriteQueriesToDropUnusedColumns + // Duplicate column names across play_results and the chart join (song_id, + // rating_class, date) resolve to the play_results value; chart.songId and + // chart.ratingClass are correct by join equality, chart.date is not read + // by consumers of this query. @Query( """SELECT - * -FROM - play_results - LEFT JOIN charts ON play_results.song_id = charts.song_id - AND charts.rating_class = play_results.rating_class""", + pr.*, + s.idx AS song_idx, d.rating, d.rating_plus, d.rating_class_alias, + COALESCE(d.title, s.title) AS title, COALESCE(d.artist, s.artist) AS artist, + s.`set`, COALESCE(d.bpm, s.bpm) AS bpm, COALESCE(d.bpm_base, s.bpm_base) AS bpm_base, + s.audio_preview, s.audio_preview_end, s.side, + COALESCE(d.version, s.version) AS version, + COALESCE(d.bg, s.bg) AS bg, COALESCE(d.bg_inverse, s.bg_inverse) AS bg_inverse, + s.bg_day, s.bg_night, s.source, s.source_copyright, + d.chart_designer, d.jacket_designer, d.audio_override, d.jacket_override, + d.jacket_night, ci.constant, ci.notes + FROM play_results pr + LEFT JOIN difficulties d ON pr.song_id = d.song_id AND pr.rating_class = d.rating_class + LEFT JOIN songs s ON d.song_id = s.id + LEFT JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class""", ) fun playResultsWithCharts(): Flow> diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Chart.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Chart.kt index 4d93106d..95df7fb1 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Chart.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Chart.kt @@ -1,31 +1,16 @@ package xyz.sevive.arcaeaoffline.core.database.entities import androidx.room.ColumnInfo -import androidx.room.DatabaseView import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass -@DatabaseView( - """ - SELECT - s.idx AS song_idx, d.song_id, d.rating_class, d.rating, d.rating_plus, - COALESCE(d.title, s.title) AS title, COALESCE(d.artist, s.artist) AS artist, - s.`set`, COALESCE(d.bpm, s.bpm) AS bpm, COALESCE(d.bpm_base, s.bpm_base) AS bpm_base, - s.audio_preview, s.audio_preview_end, s.side, - COALESCE(d.version, s.version) AS version, COALESCE(d.date, s.date) AS date, - COALESCE(d.bg, s.bg) AS bg, COALESCE(d.bg_inverse, s.bg_inverse) AS bg_inverse, - s.bg_day, s.bg_night, s.source, s.source_copyright, - d.chart_designer, d.jacket_designer, d.audio_override, d.jacket_override, - d.jacket_night, ci.constant, ci.notes - FROM difficulties d - INNER JOIN charts_info ci ON d.song_id = ci.song_id AND d.rating_class = ci.rating_class - INNER JOIN songs s ON d.song_id = s.id - """, - "charts", -) +// Not a Room entity/view anymore: the `charts` DatabaseView was replaced by +// explicit join queries in ChartDao, so schema migrations no longer need to +// recreate a view definition. data class Chart( @ColumnInfo(name = "song_idx") val songIdx: Int, @ColumnInfo(name = "song_id") val songId: String, @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, + @ColumnInfo(name = "rating_class_alias") val ratingClassAlias: Int? = null, val rating: Int, @ColumnInfo(name = "rating_plus") val ratingPlus: Boolean, val title: String, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Difficulty.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Difficulty.kt index 29fc1f49..89595bb9 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Difficulty.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Difficulty.kt @@ -8,6 +8,9 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass data class Difficulty( @ColumnInfo(name = "song_id") val songId: String, @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, + // Raw value from upstream songlist; interpretation is centralized in + // ArcaeaRatingClassDisplay.of(). Null means no alias. + @ColumnInfo(name = "rating_class_alias") val ratingClassAlias: Int? = null, val rating: Int, @ColumnInfo(name = "rating_plus") val ratingPlus: Boolean, @ColumnInfo(name = "chart_designer") val chartDesigner: String?, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/helpers/ChartFactory.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/helpers/ChartFactory.kt index 649c2787..3d6a7028 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/helpers/ChartFactory.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/helpers/ChartFactory.kt @@ -13,6 +13,7 @@ object ChartFactory { songIdx = song.idx, songId = song.id, ratingClass = difficulty.ratingClass, + ratingClassAlias = difficulty.ratingClassAlias, rating = difficulty.rating, ratingPlus = difficulty.ratingPlus, title = difficulty.title ?: song.title, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_14_15.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_14_15.kt new file mode 100644 index 00000000..010b9aba --- /dev/null +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_14_15.kt @@ -0,0 +1,16 @@ +package xyz.sevive.arcaeaoffline.core.database.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.execSQL + +object Migration_14_15 : Migration(14, 15) { + override fun migrate(connection: SQLiteConnection) { + // Nullable raw value from upstream songlist; null means no alias. + connection.execSQL("ALTER TABLE `difficulties` ADD COLUMN `rating_class_alias` INTEGER") + + // The `charts` view is no longer declared by the database; leaving the + // stale definition behind would break any future `CREATE VIEW charts`. + connection.execSQL("DROP VIEW IF EXISTS `charts`") + } +} From 3e5a3de3fbaf1784a3f1574b62b80461656ee093 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 4 Sep 2026 07:02:40 +0800 Subject: [PATCH 2/5] feat(core.parser): read ratingClassAlias from songlist difficulties Pass the raw upstream value through to Difficulty; interpretation stays in ArcaeaRatingClassDisplay. Adds the first songlist importer test with a trimmed fixture covering alias parsing and unknown-key tolerance. --- .../externals/ArcaeaListDefinitions.kt | 1 + .../importers/ArcaeaSonglistImporter.kt | 1 + .../importers/ArcaeaSonglistImporterTest.kt | 28 +++++++++++++++ .../resources/songlist_alias_fixture.json | 35 +++++++++++++++++++ 4 files changed, 65 insertions(+) create mode 100644 core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporterTest.kt create mode 100644 core/src/test/resources/songlist_alias_fixture.json diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/ArcaeaListDefinitions.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/ArcaeaListDefinitions.kt index e6061c41..7b42241a 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/ArcaeaListDefinitions.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/ArcaeaListDefinitions.kt @@ -84,6 +84,7 @@ data class ArcaeaSonglistBgDayNightItem( @Serializable data class ArcaeaSonglistDifficultyItem( val ratingClass: Int, + val ratingClassAlias: Int? = null, val chartDesigner: String, val jacketDesigner: String, val rating: Int, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporter.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporter.kt index 52024124..25b798e6 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporter.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporter.kt @@ -95,6 +95,7 @@ class ArcaeaSonglistImporter( Difficulty( songId = song.id, ratingClass = ArcaeaRatingClass.fromInt(difficulty.ratingClass), + ratingClassAlias = difficulty.ratingClassAlias, rating = difficulty.rating, ratingPlus = difficulty.ratingPlus ?: false, chartDesigner = difficulty.chartDesigner, diff --git a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporterTest.kt b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporterTest.kt new file mode 100644 index 00000000..4ef35d48 --- /dev/null +++ b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporterTest.kt @@ -0,0 +1,28 @@ +package xyz.sevive.arcaeaoffline.core.database.externals.importers + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass + +class ArcaeaSonglistImporterTest { + // The fixture mirrors upstream alias entries: ratingClassAlias only appears + // alongside ratingClass 3, and ships with the unknown-to-this-app legacy11 + // key, which must be ignored rather than fail parsing. + private val fixture = + javaClass.classLoader!! + .getResource("songlist_alias_fixture.json")!! + .readText() + + @Test + fun ratingClassAliasTest() { + val difficulties = ArcaeaSonglistImporter(fixture).difficulties() + assertEquals(2, difficulties.size) + + val present = difficulties.single { it.ratingClass == ArcaeaRatingClass.PRESENT } + assertNull(present.ratingClassAlias) + + val beyond = difficulties.single { it.ratingClass == ArcaeaRatingClass.BEYOND } + assertEquals(1, beyond.ratingClassAlias) + } +} diff --git a/core/src/test/resources/songlist_alias_fixture.json b/core/src/test/resources/songlist_alias_fixture.json new file mode 100644 index 00000000..8594f9a0 --- /dev/null +++ b/core/src/test/resources/songlist_alias_fixture.json @@ -0,0 +1,35 @@ +{ + "songs": [ + { + "idx": 0, + "id": "aliastest", + "title_localized": { "en": "Alias Test" }, + "artist": "Test Artist", + "bpm": "100", + "bpm_base": 100.0, + "set": "testset", + "audioPreview": 0, + "audioPreviewEnd": 0, + "side": 0, + "bg": "testbg", + "date": 1720000000, + "version": "7.0", + "difficulties": [ + { + "ratingClass": 1, + "chartDesigner": "Toaster", + "jacketDesigner": "jl", + "rating": 7 + }, + { + "ratingClass": 3, + "chartDesigner": "Toaster", + "jacketDesigner": "jl", + "rating": 12, + "ratingClassAlias": 1, + "legacy11": true + } + ] + } + ] +} From 55628c4fd7976161ce05c2d83526f655fc42898d Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 4 Sep 2026 07:06:49 +0800 Subject: [PATCH 3/5] feat(app.ui): app-wide INSCRIBED-aware labels and selector colors - toRatingClassSelectorItems now takes List and feeds the alias into ArcaeaRatingClassDisplay.of; ArcaeaChartSelector derives charts from the same difficulties query it already ran, and the calculator screen reads difficulties for the selector items. - ratingText gains an ArcaeaRatingClassDisplay-based overload so an INSCRIBED chart renders "INSCRIBED" instead of "BEYOND"; the chart card colors follow the display form as well. Refs #61 --- .../ui/components/ArcaeaChartCard.kt | 6 +++- .../ui/components/ArcaeaChartSelector.kt | 35 +++++++++++-------- .../components/ArcaeaRatingClassSelector.kt | 6 ++-- .../ui/helpers/ArcaeaFormatters.kt | 31 +++++++++++----- .../utilities/UtilitiesCalculatorScreen.kt | 11 ++++-- .../ui/helpers/ArcaeaFormattersTest.kt | 25 +++++++++++++ 6 files changed, 86 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt index f6a39b9b..ab969da1 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartCard.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClassDisplay import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.ui.helpers.ArcaeaFormatters import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme @@ -92,7 +93,10 @@ fun ArcaeaChartCard( Text( text = ArcaeaFormatters.ratingText(it), modifier = Modifier.fillMaxWidth(), - color = ratingClassColor(it.ratingClass), + color = + ratingClassColor( + ArcaeaRatingClassDisplay.of(it.ratingClass, it.ratingClassAlias), + ), ) } } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartSelector.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartSelector.kt index 6b9568a3..c4129e23 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartSelector.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaChartSelector.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.firstOrNull import org.koin.compose.koinInject import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.database.entities.Chart +import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty import xyz.sevive.arcaeaoffline.core.database.entities.Song import xyz.sevive.arcaeaoffline.core.database.helpers.ChartFactory import xyz.sevive.arcaeaoffline.core.database.repositories.ChartRepository @@ -40,25 +41,30 @@ private fun rememberArcaeaSong( } @Composable -private fun rememberArcaeaCharts( +private fun rememberArcaeaDifficulties( difficultyRepo: DifficultyRepository, + song: Song?, +): State> = + produceState(initialValue = emptyList(), song?.id) { + song?.let { + difficultyRepo.findAllBySongId(it.id).collect { value = it } + } + } + +@Composable +private fun rememberArcaeaCharts( chartRepo: ChartRepository, song: Song?, + difficulties: List, allowFakeChart: Boolean = false, ): State> = - produceState(initialValue = emptyList(), song?.id, allowFakeChart) { + produceState(initialValue = emptyList(), song?.id, difficulties, allowFakeChart) { song?.let { - val availableRatingClasses = - difficultyRepo.findAllBySongId(song.id).firstOrNull()?.map { it.ratingClass } - ?: emptyList() - value = - availableRatingClasses.mapNotNull { ratingClass -> - var chart = chartRepo.find(song.id, ratingClass).firstOrNull() + difficulties.mapNotNull { difficulty -> + var chart = chartRepo.find(difficulty.songId, difficulty.ratingClass).firstOrNull() if (chart == null && allowFakeChart) { - difficultyRepo.find(song.id, ratingClass).firstOrNull()?.let { difficulty -> - chart = ChartFactory.fakeChart(song, difficulty) - } + chart = ChartFactory.fakeChart(song, difficulty) } chart } @@ -80,16 +86,17 @@ fun ArcaeaChartSelector( mutableStateOf(chart?.ratingClass) } val song by rememberArcaeaSong(songRepo = songRepo, songId = selectedSongId) + val difficulties by rememberArcaeaDifficulties(difficultyRepo = difficultyRepo, song = song) val charts by rememberArcaeaCharts( - difficultyRepo = difficultyRepo, chartRepo = chartRepo, song = song, + difficulties = difficulties, allowFakeChart = allowFakeChart, ) val selectorItems = - remember(charts) { - charts.toRatingClassSelectorItems() + remember(difficulties) { + difficulties.toRatingClassSelectorItems() } // Emit the change when either [selectedSongId] or [selectedRatingClass] changes. diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaRatingClassSelector.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaRatingClassSelector.kt index c4b7d5ff..32113f05 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaRatingClassSelector.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/components/ArcaeaRatingClassSelector.kt @@ -47,7 +47,7 @@ import androidx.compose.ui.unit.em import com.materialkolor.hct.Hct import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClassDisplay -import xyz.sevive.arcaeaoffline.core.database.entities.Chart +import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty import xyz.sevive.arcaeaoffline.ui.theme.ArcaeaOfflineTheme import xyz.sevive.arcaeaoffline.ui.theme.ratingClassColor @@ -58,10 +58,10 @@ data class RatingClassSelectorItem( val ratingPlus: Boolean = false, ) -fun List.toRatingClassSelectorItems(): List = +fun List.toRatingClassSelectorItems(): List = map { RatingClassSelectorItem( - display = ArcaeaRatingClassDisplay.of(it.ratingClass), + display = ArcaeaRatingClassDisplay.of(it.ratingClass, it.ratingClassAlias), rating = it.rating, ratingPlus = it.ratingPlus, ) diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt index 444e6f79..68a6210a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormatters.kt @@ -9,6 +9,7 @@ import com.ionspin.kotlin.bignum.decimal.DecimalMode import com.ionspin.kotlin.bignum.decimal.RoundingMode import com.ionspin.kotlin.bignum.decimal.toBigDecimal import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClassDisplay import xyz.sevive.arcaeaoffline.core.database.entities.Chart import xyz.sevive.arcaeaoffline.core.database.entities.Difficulty @@ -96,12 +97,12 @@ object ArcaeaFormatters { } internal fun ratingText( - ratingClass: ArcaeaRatingClass, + ratingClassDisplay: ArcaeaRatingClassDisplay, rating: Int, ratingPlus: Boolean, constant: Int = 0, ) = buildString { - append(ratingClass.toString()) + append(ratingClassDisplay.name) append(' ') if (constant > 0) { @@ -114,18 +115,31 @@ object ArcaeaFormatters { } } + internal fun ratingText( + ratingClass: ArcaeaRatingClass, + rating: Int, + ratingPlus: Boolean, + constant: Int = 0, + ) = ratingText( + ArcaeaRatingClassDisplay.of(ratingClass), + rating, + ratingPlus, + constant, + ) + /** * Returns the readable rating text for the given difficulty. * * For example: * * `Difficulty(ratingClass=2, rating=2, ratingPlus=false)` > "FUTURE 2" * * `Difficulty(ratingClass=2, rating=10, ratingPlus=true)` > "FUTURE 10+" + * * `Difficulty(ratingClass=3, rating=11, ratingPlus=true, ratingClassAlias=1)` > "INSCRIBED 11+" */ fun ratingText(difficulty: Difficulty): String = ratingText( - ratingClass = difficulty.ratingClass, - rating = difficulty.rating, - ratingPlus = difficulty.ratingPlus, + ArcaeaRatingClassDisplay.of(difficulty.ratingClass, difficulty.ratingClassAlias), + difficulty.rating, + difficulty.ratingPlus, ) /** @@ -139,12 +153,13 @@ object ArcaeaFormatters { * * `Chart(ratingClass=2, rating=10, ratingPlus=true)` > "FUTURE 10+" * * `Chart(ratingClass=2, rating=10, ratingPlus=true, constant=108)` > "FUTURE 10.8" * * `Chart(ratingClass=2, rating=10, ratingPlus=true, constant=0)` > "FUTURE 10+" + * * `Chart(ratingClass=3, rating=11, ratingPlus=true, ratingClassAlias=1)` > "INSCRIBED 11+" */ fun ratingText(chart: Chart): String = ratingText( - ratingClass = chart.ratingClass, - rating = chart.rating, - ratingPlus = chart.ratingPlus, + ArcaeaRatingClassDisplay.of(chart.ratingClass, chart.ratingClassAlias), + chart.rating, + chart.ratingPlus, constant = chart.constant, ) } diff --git a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesCalculatorScreen.kt b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesCalculatorScreen.kt index 92e64b29..196c5b0a 100644 --- a/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesCalculatorScreen.kt +++ b/app/src/main/java/xyz/sevive/arcaeaoffline/ui/screens/utilities/UtilitiesCalculatorScreen.kt @@ -27,6 +27,7 @@ import org.koin.compose.koinInject import xyz.sevive.arcaeaoffline.R import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass import xyz.sevive.arcaeaoffline.core.database.repositories.ChartRepository +import xyz.sevive.arcaeaoffline.core.database.repositories.DifficultyRepository import xyz.sevive.arcaeaoffline.ui.SubScreenContainer import xyz.sevive.arcaeaoffline.ui.components.ArcaeaPackAndSongQuickSearch import xyz.sevive.arcaeaoffline.ui.components.ArcaeaRatingClassSelector @@ -39,6 +40,7 @@ import xyz.sevive.arcaeaoffline.ui.navigation.UtilitiesSubScreen fun UtilitiesCalculatorScreen( modifier: Modifier = Modifier, chartRepo: ChartRepository = koinInject(), + difficultyRepo: DifficultyRepository = koinInject(), ) { var constant by remember { mutableIntStateOf(0) } var selectedSongId by remember { mutableStateOf(null) } @@ -48,9 +50,14 @@ fun UtilitiesCalculatorScreen( value = chartRepo.findAllBySongId(it).firstOrNull() ?: emptyList() } } + val difficulties by produceState(initialValue = listOf(), selectedSongId) { + selectedSongId?.let { + value = difficultyRepo.findAllBySongId(it).firstOrNull() ?: emptyList() + } + } val selectorItems = - remember(charts) { - charts.toRatingClassSelectorItems() + remember(difficulties) { + difficulties.toRatingClassSelectorItems() } LaunchedEffect(selectedRatingClass, charts) { diff --git a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt index 74337b8c..211e1a59 100644 --- a/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt +++ b/app/src/test/java/xyz/sevive/arcaeaoffline/ui/helpers/ArcaeaFormattersTest.kt @@ -3,6 +3,7 @@ package xyz.sevive.arcaeaoffline.ui.helpers import org.junit.Assert.assertEquals import org.junit.Test import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass +import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClassDisplay class ArcaeaFormattersTest { @Test @@ -124,4 +125,28 @@ class ArcaeaFormattersTest { ArcaeaFormatters.ratingText(ArcaeaRatingClass.BEYOND, 11, true, 118), ) } + + @Test + fun ratingTextAliasTest() { + assertEquals( + "INSCRIBED 11", + ArcaeaFormatters.ratingText(ArcaeaRatingClassDisplay.INSCRIBED, 11, false), + ) + + assertEquals( + "INSCRIBED 11.5", + ArcaeaFormatters.ratingText(ArcaeaRatingClassDisplay.INSCRIBED, 11, false, 115), + ) + + // Unknown (ratingClass, alias) combos must fall back to the default + // display form of the rating class, not throw. + assertEquals( + "BEYOND 12", + ArcaeaFormatters.ratingText( + ArcaeaRatingClassDisplay.of(ArcaeaRatingClass.BEYOND, alias = 2), + 12, + false, + ), + ) + } } From 6bea5e0e7c161ef9fb3ce921cc521548fef5a7c0 Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 4 Sep 2026 11:12:42 +0800 Subject: [PATCH 4/5] chore: remove unnecessary comments & items --- .../sevive/arcaeaoffline/core/database/entities/Chart.kt | 3 --- .../arcaeaoffline/core/database/entities/Difficulty.kt | 2 -- .../externals/importers/ArcaeaSonglistImporterTest.kt | 3 --- core/src/test/resources/songlist_alias_fixture.json | 7 +++---- 4 files changed, 3 insertions(+), 12 deletions(-) diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Chart.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Chart.kt index 95df7fb1..7072cde2 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Chart.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Chart.kt @@ -3,9 +3,6 @@ package xyz.sevive.arcaeaoffline.core.database.entities import androidx.room.ColumnInfo import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass -// Not a Room entity/view anymore: the `charts` DatabaseView was replaced by -// explicit join queries in ChartDao, so schema migrations no longer need to -// recreate a view definition. data class Chart( @ColumnInfo(name = "song_idx") val songIdx: Int, @ColumnInfo(name = "song_id") val songId: String, diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Difficulty.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Difficulty.kt index 89595bb9..2cee1608 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Difficulty.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/entities/Difficulty.kt @@ -8,8 +8,6 @@ import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass data class Difficulty( @ColumnInfo(name = "song_id") val songId: String, @ColumnInfo(name = "rating_class") val ratingClass: ArcaeaRatingClass, - // Raw value from upstream songlist; interpretation is centralized in - // ArcaeaRatingClassDisplay.of(). Null means no alias. @ColumnInfo(name = "rating_class_alias") val ratingClassAlias: Int? = null, val rating: Int, @ColumnInfo(name = "rating_plus") val ratingPlus: Boolean, diff --git a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporterTest.kt b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporterTest.kt index 4ef35d48..a419f28f 100644 --- a/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporterTest.kt +++ b/core/src/test/kotlin/xyz/sevive/arcaeaoffline/core/database/externals/importers/ArcaeaSonglistImporterTest.kt @@ -6,9 +6,6 @@ import org.junit.Test import xyz.sevive.arcaeaoffline.core.constants.ArcaeaRatingClass class ArcaeaSonglistImporterTest { - // The fixture mirrors upstream alias entries: ratingClassAlias only appears - // alongside ratingClass 3, and ships with the unknown-to-this-app legacy11 - // key, which must be ignored rather than fail parsing. private val fixture = javaClass.classLoader!! .getResource("songlist_alias_fixture.json")!! diff --git a/core/src/test/resources/songlist_alias_fixture.json b/core/src/test/resources/songlist_alias_fixture.json index 8594f9a0..3ad10d52 100644 --- a/core/src/test/resources/songlist_alias_fixture.json +++ b/core/src/test/resources/songlist_alias_fixture.json @@ -17,17 +17,16 @@ "difficulties": [ { "ratingClass": 1, - "chartDesigner": "Toaster", + "chartDesigner": "designer ant", "jacketDesigner": "jl", "rating": 7 }, { "ratingClass": 3, - "chartDesigner": "Toaster", + "chartDesigner": "designer ant", "jacketDesigner": "jl", "rating": 12, - "ratingClassAlias": 1, - "legacy11": true + "ratingClassAlias": 1 } ] } From f1d0bbb7741c4c4a217bd99605d70985bdbc00db Mon Sep 17 00:00:00 2001 From: 283375 Date: Fri, 4 Sep 2026 11:20:06 +0800 Subject: [PATCH 5/5] refactor(core.db): replace Migration_14_15 with an auto migration Room generates the same statements (drop the removed charts view, add the nullable rating_class_alias column), so the hand-written migration was redundant. The migration test now relies on the registered AutoMigration via the database class and keeps its assertions. --- .../arcaeaoffline/core/DatabaseMigrationsTest.kt | 9 ++++----- .../core/database/ArcaeaOfflineDatabase.kt | 3 +-- .../core/database/migrations/Migration_14_15.kt | 16 ---------------- 3 files changed, 5 insertions(+), 23 deletions(-) delete mode 100644 core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_14_15.kt diff --git a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt index 9d06a023..9b5d2a42 100644 --- a/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt +++ b/core/src/androidTest/kotlin/xyz/sevive/arcaeaoffline/core/DatabaseMigrationsTest.kt @@ -10,13 +10,12 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import xyz.sevive.arcaeaoffline.core.database.ArcaeaOfflineDatabase -import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_14_15 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 import java.io.IOException @RunWith(AndroidJUnit4::class) class DatabaseMigrationsTest { - private val testDatabaseName = "arcaea-offline" + private val testDatabaseName7To8 = "arcaea-offline-7-8" private val testDatabaseName14To15 = "arcaea-offline-14-15" @get:Rule @@ -32,7 +31,7 @@ class DatabaseMigrationsTest { val sqlFilename = "arcaea_offline_0.0.6.db.sql" var db = - helper.createDatabase(testDatabaseName, 7).apply { + helper.createDatabase(testDatabaseName7To8, 7).apply { // filling data execSqlScript(this, sqlFilename) @@ -41,7 +40,7 @@ class DatabaseMigrationsTest { } // perform migration - db = helper.runMigrationsAndValidate(testDatabaseName, 8, true, Migration_7_8) + db = helper.runMigrationsAndValidate(testDatabaseName7To8, 8, true, Migration_7_8) /** * schema passed @@ -86,7 +85,7 @@ class DatabaseMigrationsTest { } val db = - helper.runMigrationsAndValidate(testDatabaseName14To15, 15, true, Migration_14_15) + helper.runMigrationsAndValidate(testDatabaseName14To15, 15, true) // Old rows get a null alias. db diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt index e7d41c0b..a47f1154 100644 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt +++ b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/ArcaeaOfflineDatabase.kt @@ -39,7 +39,6 @@ import xyz.sevive.arcaeaoffline.core.database.entities.Song import xyz.sevive.arcaeaoffline.core.database.entities.SongLocalized import xyz.sevive.arcaeaoffline.core.database.migrations.AutoMigration_5_6 import xyz.sevive.arcaeaoffline.core.database.migrations.AutoMigration_9_10 -import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_14_15 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_6_7 import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 @@ -65,6 +64,7 @@ import xyz.sevive.arcaeaoffline.core.database.migrations.Migration_7_8 AutoMigration(from = 11, to = 12), AutoMigration(from = 12, to = 13), AutoMigration(from = 13, to = 14), + AutoMigration(from = 14, to = 15), ], version = 15, exportSchema = true, @@ -128,7 +128,6 @@ abstract class ArcaeaOfflineDatabase : RoomDatabase() { .addMigrations( Migration_6_7, Migration_7_8, - Migration_14_15, ).build() .also { instance = it } } diff --git a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_14_15.kt b/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_14_15.kt deleted file mode 100644 index 010b9aba..00000000 --- a/core/src/main/kotlin/xyz/sevive/arcaeaoffline/core/database/migrations/Migration_14_15.kt +++ /dev/null @@ -1,16 +0,0 @@ -package xyz.sevive.arcaeaoffline.core.database.migrations - -import androidx.room.migration.Migration -import androidx.sqlite.SQLiteConnection -import androidx.sqlite.execSQL - -object Migration_14_15 : Migration(14, 15) { - override fun migrate(connection: SQLiteConnection) { - // Nullable raw value from upstream songlist; null means no alias. - connection.execSQL("ALTER TABLE `difficulties` ADD COLUMN `rating_class_alias` INTEGER") - - // The `charts` view is no longer declared by the database; leaving the - // stale definition behind would break any future `CREATE VIEW charts`. - connection.execSQL("DROP VIEW IF EXISTS `charts`") - } -}