From 71f39b38238402aaa8cd6c8ece13f18f5b266e9d Mon Sep 17 00:00:00 2001 From: Nik Clayton Date: Sat, 16 Nov 2024 00:24:43 +0100 Subject: [PATCH] fix: Ensure clientId and clientSecret are non null during db migration (#1103) Previous migration code could crash if the `clientId` or `clientSecret` columns were null during the migration (unclear how that could happen but there's at least one user report of this crash). Re-write the migration to set these columns to the empty string if NULL first. --- .../app/pachli/core/database/AppDatabase.kt | 111 ++++++++++-------- .../pachli/core/database/di/DatabaseModule.kt | 2 + 2 files changed, 65 insertions(+), 48 deletions(-) diff --git a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt index c88a38cb3..4da0613d8 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/AppDatabase.kt @@ -26,9 +26,9 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.DeleteColumn import androidx.room.RenameColumn -import androidx.room.RenameTable import androidx.room.RoomDatabase import androidx.room.migration.AutoMigrationSpec +import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import app.pachli.core.database.dao.AccountDao import app.pachli.core.database.dao.AnnouncementsDao @@ -89,7 +89,6 @@ import java.util.TimeZone AutoMigration(from = 5, to = 6), AutoMigration(from = 6, to = 7, spec = AppDatabase.MIGRATE_6_7::class), AutoMigration(from = 7, to = 8, spec = AppDatabase.MIGRATE_7_8::class), - AutoMigration(from = 8, to = 9, spec = AppDatabase.MIGRATE_8_9::class), ], ) abstract class AppDatabase : RoomDatabase() { @@ -159,58 +158,74 @@ abstract class AppDatabase : RoomDatabase() { @DeleteColumn("DraftEntity", "scheduledAt") @RenameColumn("DraftEntity", "scheduledAtLong", "scheduledAt") class MIGRATE_7_8 : AutoMigrationSpec +} - /** - * Populates new tables with default data for existing accounts. - * - * Sets up: - * - * - InstanceInfoEntity - * - ServerEntity - * - ContentFiltersEntity - */ - @DeleteColumn("InstanceEntity", "emojiList") - @RenameColumn( - "InstanceEntity", - fromColumnName = "maximumTootCharacters", - toColumnName = "maxPostCharacters", - ) - @RenameTable(fromTableName = "InstanceEntity", toTableName = "InstanceInfoEntity") - class MIGRATE_8_9 : AutoMigrationSpec { - override fun onPostMigrate(db: SupportSQLiteDatabase) { - db.beginTransaction() +val MIGRATE_8_9 = object : Migration(8, 9) { + override fun migrate(db: SupportSQLiteDatabase) { + // clientId and clientSecret were made non-nullable. Before migrating data convert + // any existing NULL values to the empty string. + db.execSQL("UPDATE `AccountEntity` SET `clientId` = '' WHERE `clientId` IS NULL") + db.execSQL("UPDATE `AccountEntity` SET `clientSecret` = '' WHERE `clientSecret` IS NULL") - val accountCursor = db.query("SELECT id, domain FROM AccountEntity") - with(accountCursor) { - while (moveToNext()) { - val accountId = getLong(0) - val domain = getString(1) + // Migrate the tables. + // + // - Mark AccountEntity.clientId and .clientSecret as NON NULL + // - Delete InstanceEntity.emojiList + // - Rename InstanceEntity.maximumTootCharacters to .maxPostCharacters + // - Rename InstanceEntity to InstanceInfoEntity + db.execSQL("CREATE TABLE IF NOT EXISTS `EmojisEntity` (`accountId` INTEGER NOT NULL, `emojiList` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + db.execSQL("CREATE TABLE IF NOT EXISTS `MastodonListEntity` (`accountId` INTEGER NOT NULL, `listId` TEXT NOT NULL, `title` TEXT NOT NULL, `repliesPolicy` TEXT NOT NULL, `exclusive` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `listId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + db.execSQL("CREATE TABLE IF NOT EXISTS `ServerEntity` (`accountId` INTEGER NOT NULL, `serverKind` TEXT NOT NULL, `version` TEXT NOT NULL, `capabilities` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + db.execSQL("CREATE TABLE IF NOT EXISTS `ContentFiltersEntity` (`accountId` INTEGER NOT NULL, `version` TEXT NOT NULL, `contentFilters` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + db.execSQL("CREATE TABLE IF NOT EXISTS `AnnouncementEntity` (`accountId` INTEGER NOT NULL, `announcementId` TEXT NOT NULL, `announcement` TEXT NOT NULL, PRIMARY KEY(`accountId`), FOREIGN KEY(`accountId`) REFERENCES `AccountEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + db.execSQL( + "CREATE TABLE IF NOT EXISTS `_new_AccountEntity` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `domain` TEXT NOT NULL, `accessToken` TEXT NOT NULL, `clientId` TEXT NOT NULL, `clientSecret` TEXT NOT NULL, `isActive` INTEGER NOT NULL, `accountId` TEXT NOT NULL, `username` TEXT NOT NULL, `displayName` TEXT NOT NULL, `profilePictureUrl` TEXT NOT NULL, `profileHeaderPictureUrl` TEXT NOT NULL DEFAULT '', `notificationsEnabled` INTEGER NOT NULL, `notificationsMentioned` INTEGER NOT NULL, `notificationsFollowed` INTEGER NOT NULL, `notificationsFollowRequested` INTEGER NOT NULL, `notificationsReblogged` INTEGER NOT NULL, `notificationsFavorited` INTEGER NOT NULL, `notificationsPolls` INTEGER NOT NULL, `notificationsSubscriptions` INTEGER NOT NULL, `notificationsSignUps` INTEGER NOT NULL, `notificationsUpdates` INTEGER NOT NULL, `notificationsReports` INTEGER NOT NULL, `notificationsSeveredRelationships` INTEGER NOT NULL DEFAULT true, `notificationSound` INTEGER NOT NULL, `notificationVibration` INTEGER NOT NULL, `notificationLight` INTEGER NOT NULL, `defaultPostPrivacy` INTEGER NOT NULL, `defaultMediaSensitivity` INTEGER NOT NULL, `defaultPostLanguage` TEXT NOT NULL, `alwaysShowSensitiveMedia` INTEGER NOT NULL, `alwaysOpenSpoiler` INTEGER NOT NULL, `mediaPreviewEnabled` INTEGER NOT NULL, `lastNotificationId` TEXT NOT NULL, `notificationMarkerId` TEXT NOT NULL DEFAULT '0', `emojis` TEXT NOT NULL, `tabPreferences` TEXT NOT NULL, `notificationsFilter` TEXT NOT NULL, `oauthScopes` TEXT NOT NULL, `unifiedPushUrl` TEXT NOT NULL, `pushPubKey` TEXT NOT NULL, `pushPrivKey` TEXT NOT NULL, `pushAuth` TEXT NOT NULL, `pushServerKey` TEXT NOT NULL, `lastVisibleHomeTimelineStatusId` TEXT, `locked` INTEGER NOT NULL DEFAULT 0)", + ) + db.execSQL( + "INSERT INTO `_new_AccountEntity` (`id`,`domain`,`accessToken`,`clientId`,`clientSecret`,`isActive`,`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`,`notificationsMentioned`,`notificationsFollowed`,`notificationsFollowRequested`,`notificationsReblogged`,`notificationsFavorited`,`notificationsPolls`,`notificationsSubscriptions`,`notificationsSignUps`,`notificationsUpdates`,`notificationsReports`,`notificationsSeveredRelationships`,`notificationSound`,`notificationVibration`,`notificationLight`,`defaultPostPrivacy`,`defaultMediaSensitivity`,`defaultPostLanguage`,`alwaysShowSensitiveMedia`,`alwaysOpenSpoiler`,`mediaPreviewEnabled`,`lastNotificationId`,`notificationMarkerId`,`emojis`,`tabPreferences`,`notificationsFilter`,`oauthScopes`,`unifiedPushUrl`,`pushPubKey`,`pushPrivKey`,`pushAuth`,`pushServerKey`,`lastVisibleHomeTimelineStatusId`,`locked`) SELECT `id`,`domain`,`accessToken`,`clientId`,`clientSecret`,`isActive`,`accountId`,`username`,`displayName`,`profilePictureUrl`,`notificationsEnabled`,`notificationsMentioned`,`notificationsFollowed`,`notificationsFollowRequested`,`notificationsReblogged`,`notificationsFavorited`,`notificationsPolls`,`notificationsSubscriptions`,`notificationsSignUps`,`notificationsUpdates`,`notificationsReports`,`notificationsSeveredRelationships`,`notificationSound`,`notificationVibration`,`notificationLight`,`defaultPostPrivacy`,`defaultMediaSensitivity`,`defaultPostLanguage`,`alwaysShowSensitiveMedia`,`alwaysOpenSpoiler`,`mediaPreviewEnabled`,`lastNotificationId`,`notificationMarkerId`,`emojis`,`tabPreferences`,`notificationsFilter`,`oauthScopes`,`unifiedPushUrl`,`pushPubKey`,`pushPrivKey`,`pushAuth`,`pushServerKey`,`lastVisibleHomeTimelineStatusId`,`locked` FROM `AccountEntity`", + ) + db.execSQL("DROP TABLE `AccountEntity`") + db.execSQL("ALTER TABLE `_new_AccountEntity` RENAME TO `AccountEntity`") + db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_AccountEntity_domain_accountId` ON `AccountEntity` (`domain`, `accountId`)") + db.execSQL("CREATE TABLE IF NOT EXISTS `_new_InstanceInfoEntity` (`instance` TEXT NOT NULL, `maxPostCharacters` INTEGER, `maxPollOptions` INTEGER, `maxPollOptionLength` INTEGER, `minPollDuration` INTEGER, `maxPollDuration` INTEGER, `charactersReservedPerUrl` INTEGER, `version` TEXT, `videoSizeLimit` INTEGER, `imageSizeLimit` INTEGER, `imageMatrixLimit` INTEGER, `maxMediaAttachments` INTEGER, `maxFields` INTEGER, `maxFieldNameLength` INTEGER, `maxFieldValueLength` INTEGER, `enabledTranslation` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`instance`))") + db.execSQL("INSERT INTO `_new_InstanceInfoEntity` (`instance`,`maxPostCharacters`,`maxPollOptions`,`maxPollOptionLength`,`minPollDuration`,`maxPollDuration`,`charactersReservedPerUrl`,`version`,`videoSizeLimit`,`imageSizeLimit`,`imageMatrixLimit`,`maxMediaAttachments`,`maxFields`,`maxFieldNameLength`,`maxFieldValueLength`) SELECT `instance`,`maximumTootCharacters`,`maxPollOptions`,`maxPollOptionLength`,`minPollDuration`,`maxPollDuration`,`charactersReservedPerUrl`,`version`,`videoSizeLimit`,`imageSizeLimit`,`imageMatrixLimit`,`maxMediaAttachments`,`maxFields`,`maxFieldNameLength`,`maxFieldValueLength` FROM `InstanceEntity`") + db.execSQL("DROP TABLE `InstanceEntity`") + db.execSQL("ALTER TABLE `_new_InstanceInfoEntity` RENAME TO `InstanceInfoEntity`") - val instanceInfoEntityValues = ContentValues().apply { - put("instance", domain) - put("enabledTranslation", 0) - } - db.insert("InstanceInfoEntity", CONFLICT_IGNORE, instanceInfoEntityValues) + // Populate the new tables with default data for existing accounts. + // + // Sets up: + // + // - InstanceInfoEntity + // - ServerEntity + // - ContentFiltersEntity + val accountCursor = db.query("SELECT id, domain FROM AccountEntity") + with(accountCursor) { + while (moveToNext()) { + val accountId = getLong(0) + val domain = getString(1) - val serverEntityValues = ContentValues().apply { - put("accountId", accountId) - put("serverKind", "UNKNOWN") - put("version", "0.0.1") - put("capabilities", "{}") - } - db.insert("ServerEntity", CONFLICT_IGNORE, serverEntityValues) - - val contentFiltersEntityValues = ContentValues().apply { - put("accountId", accountId) - put("version", ContentFilterVersion.V1.name) - put("contentFilters", "[]") - } - db.insert("ContentFiltersEntity", CONFLICT_IGNORE, contentFiltersEntityValues) + val instanceInfoEntityValues = ContentValues().apply { + put("instance", domain) + put("enabledTranslation", 0) } - } + db.insert("InstanceInfoEntity", CONFLICT_IGNORE, instanceInfoEntityValues) - db.setTransactionSuccessful() - db.endTransaction() + val serverEntityValues = ContentValues().apply { + put("accountId", accountId) + put("serverKind", "UNKNOWN") + put("version", "0.0.1") + put("capabilities", "{}") + } + db.insert("ServerEntity", CONFLICT_IGNORE, serverEntityValues) + + val contentFiltersEntityValues = ContentValues().apply { + put("accountId", accountId) + put("version", ContentFilterVersion.V1.name) + put("contentFilters", "[]") + } + db.insert("ContentFiltersEntity", CONFLICT_IGNORE, contentFiltersEntityValues) + } } } } diff --git a/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt b/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt index 24a15ae2d..12ad6eadf 100644 --- a/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt +++ b/core/database/src/main/kotlin/app/pachli/core/database/di/DatabaseModule.kt @@ -22,6 +22,7 @@ import androidx.room.Room import androidx.room.withTransaction import app.pachli.core.database.AppDatabase import app.pachli.core.database.Converters +import app.pachli.core.database.MIGRATE_8_9 import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -41,6 +42,7 @@ object DatabaseModule { return Room.databaseBuilder(appContext, AppDatabase::class.java, "pachliDB") .addTypeConverter(converters) .allowMainThreadQueries() + .addMigrations(MIGRATE_8_9) .build() }