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.
This commit is contained in:
Nik Clayton 2024-11-16 00:24:43 +01:00 committed by GitHub
parent 04190f0a2b
commit 71f39b3823
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 65 additions and 48 deletions

View File

@ -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)
}
}
}
}

View File

@ -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()
}