package expo.modules.updates.db import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import expo.modules.updates.db.dao.AssetDao import expo.modules.updates.db.dao.JSONDataDao import expo.modules.updates.db.dao.UpdateDao import expo.modules.updates.db.entity.AssetEntity import expo.modules.updates.db.entity.JSONDataEntity import expo.modules.updates.db.entity.UpdateAssetEntity import expo.modules.updates.db.entity.UpdateEntity import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.asExecutor import java.util.* /** * SQLite database that keeps track of updates currently loaded/loading to disk, including the * update manifest and metadata, status, and the individual assets (including bundles/bytecode) that * comprise the update. (Assets themselves are stored on the device's file system, and a relative * path is kept in SQLite.) * * SQLite allows a many-to-many relationship between updates and assets, which means we can keep * only one copy of each asset on disk at a time while also being able to clear unused assets with * relative ease (see [Reaper]). * * We use the Android Room library here, which provides a friendly abstraction over SQLite. The * database schema is autogenerated from the `Entity` classes, and `DAO` classes provide access to * the actual data. * * Occasionally it's necessary to add migrations when the data structures for updates or assets must * change. Extra care must be taken here, since these migrations will happen on users' devices for * apps we do not control. See * https://github.com/expo/expo/blob/main/packages/expo-updates/guides/migrations.md for step by * step instructions. * * [DatabaseHolder] provides a rudimentary locking mechanism, and most other classes access the * database through this class. This allows control over what high-level operations involving the * database can occur simultaneously - e.g. we don't want to be trying to download a new update at * the same time the [Reaper] is running. */ @Database( entities = [UpdateEntity::class, UpdateAssetEntity::class, AssetEntity::class, JSONDataEntity::class], exportSchema = false, version = 13 ) @TypeConverters(Converters::class) abstract class UpdatesDatabase : RoomDatabase() { abstract fun updateDao(): UpdateDao abstract fun assetDao(): AssetDao abstract fun jsonDataDao(): JSONDataDao? companion object { private const val DB_NAME = "updates.db" @Volatile private var INSTANCE: UpdatesDatabase? = null fun getInstance(context: Context, dispatcher: CoroutineDispatcher?): UpdatesDatabase { return INSTANCE ?: synchronized(this) { val instance = Room.databaseBuilder( context.applicationContext, UpdatesDatabase::class.java, DB_NAME ).apply { if (dispatcher != null) { setQueryExecutor(dispatcher.asExecutor()) } }.addMigrations( MIGRATION_4_5, MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9, MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12, MIGRATION_12_13 ) .allowMainThreadQueries() .fallbackToDestructiveMigration() .build() INSTANCE = instance instance } } private fun SupportSQLiteDatabase.runInTransaction(block: SupportSQLiteDatabase.() -> Unit) { beginTransaction() try { block() setTransactionSuccessful() } finally { endTransaction() } } private fun SupportSQLiteDatabase.runInTransactionWithForeignKeysOff(block: SupportSQLiteDatabase.() -> Unit) { // https://www.sqlite.org/lang_altertable.html#otheralter try { execSQL("PRAGMA foreign_keys=OFF") runInTransaction(block) } finally { execSQL("PRAGMA foreign_keys=ON") } } val MIGRATION_4_5: Migration = object : Migration(4, 5) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransactionWithForeignKeysOff { execSQL("CREATE TABLE `new_assets` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT, `key` TEXT, `headers` TEXT, `type` TEXT NOT NULL, `metadata` TEXT, `download_time` INTEGER, `relative_path` TEXT, `hash` BLOB, `hash_type` INTEGER NOT NULL, `marked_for_deletion` INTEGER NOT NULL)") execSQL( "INSERT INTO `new_assets` (`id`, `url`, `key`, `headers`, `type`, `metadata`, `download_time`, `relative_path`, `hash`, `hash_type`, `marked_for_deletion`)" + " SELECT `id`, `url`, `key`, `headers`, `type`, `metadata`, `download_time`, `relative_path`, `hash`, `hash_type`, `marked_for_deletion` FROM `assets`" ) execSQL("DROP TABLE `assets`") execSQL("ALTER TABLE `new_assets` RENAME TO `assets`") execSQL("CREATE UNIQUE INDEX `index_assets_key` ON `assets` (`key`)") } } } val MIGRATION_5_6: Migration = object : Migration(5, 6) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransactionWithForeignKeysOff { execSQL("CREATE TABLE `new_updates` (`id` BLOB NOT NULL, `scope_key` TEXT NOT NULL, `commit_time` INTEGER NOT NULL, `runtime_version` TEXT NOT NULL, `launch_asset_id` INTEGER, `manifest` TEXT, `status` INTEGER NOT NULL, `keep` INTEGER NOT NULL, `last_accessed` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`launch_asset_id`) REFERENCES `assets`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") // insert current time as lastAccessed date for all existing updates val currentTime = Date().time execSQL( "INSERT INTO `new_updates` (`id`, `scope_key`, `commit_time`, `runtime_version`, `launch_asset_id`, `manifest`, `status`, `keep`, `last_accessed`)" + " SELECT `id`, `scope_key`, `commit_time`, `runtime_version`, `launch_asset_id`, `metadata` AS `manifest`, `status`, `keep`, ?1 AS `last_accessed` FROM `updates`", arrayOf(currentTime) ) execSQL("DROP TABLE `updates`") execSQL("ALTER TABLE `new_updates` RENAME TO `updates`") execSQL("CREATE INDEX `index_updates_launch_asset_id` ON `updates` (`launch_asset_id`)") execSQL("CREATE UNIQUE INDEX `index_updates_scope_key_commit_time` ON `updates` (`scope_key`, `commit_time`)") } } } /** * Make the `assets` table `type` column nullable */ val MIGRATION_6_7: Migration = object : Migration(6, 7) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransactionWithForeignKeysOff { execSQL("CREATE TABLE IF NOT EXISTS `new_assets` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `url` TEXT, `key` TEXT, `headers` TEXT, `type` TEXT, `metadata` TEXT, `download_time` INTEGER, `relative_path` TEXT, `hash` BLOB, `hash_type` INTEGER NOT NULL, `marked_for_deletion` INTEGER NOT NULL)") execSQL( "INSERT INTO `new_assets` (`id`, `url`, `key`, `headers`, `type`, `metadata`, `download_time`, `relative_path`, `hash`, `hash_type`, `marked_for_deletion`)" + " SELECT `id`, `url`, `key`, `headers`, `type`, `metadata`, `download_time`, `relative_path`, `hash`, `hash_type`, `marked_for_deletion` FROM `assets`" ) execSQL("DROP TABLE `assets`") execSQL("ALTER TABLE `new_assets` RENAME TO `assets`") execSQL("CREATE UNIQUE INDEX `index_assets_key` ON `assets` (`key`)") } } } /** * Add the `successful_launch_count` and `failed_launch_count` columns to `updates` */ val MIGRATION_7_8: Migration = object : Migration(7, 8) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransactionWithForeignKeysOff { execSQL("CREATE TABLE `new_updates` (`id` BLOB NOT NULL, `scope_key` TEXT NOT NULL, `commit_time` INTEGER NOT NULL, `runtime_version` TEXT NOT NULL, `launch_asset_id` INTEGER, `manifest` TEXT, `status` INTEGER NOT NULL, `keep` INTEGER NOT NULL, `last_accessed` INTEGER NOT NULL, `successful_launch_count` INTEGER NOT NULL DEFAULT 0, `failed_launch_count` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`launch_asset_id`) REFERENCES `assets`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") // insert `1` for successful_launch_count for all existing updates // to make sure we don't roll back past them execSQL( "INSERT INTO `new_updates` (`id`, `scope_key`, `commit_time`, `runtime_version`, `launch_asset_id`, `manifest`, `status`, `keep`, `last_accessed`, `successful_launch_count`, `failed_launch_count`)" + " SELECT `id`, `scope_key`, `commit_time`, `runtime_version`, `launch_asset_id`, `manifest`, `status`, `keep`, `last_accessed`, 1 AS `successful_launch_count`, 0 AS `failed_launch_count` FROM `updates`" ) execSQL("DROP TABLE `updates`") execSQL("ALTER TABLE `new_updates` RENAME TO `updates`") execSQL("CREATE INDEX `index_updates_launch_asset_id` ON `updates` (`launch_asset_id`)") execSQL("CREATE UNIQUE INDEX `index_updates_scope_key_commit_time` ON `updates` (`scope_key`, `commit_time`)") } } } val MIGRATION_8_9: Migration = object : Migration(8, 9) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransactionWithForeignKeysOff { execSQL("ALTER TABLE `assets` ADD COLUMN `extra_request_headers` TEXT") } } } val MIGRATION_9_10: Migration = object : Migration(9, 10) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransactionWithForeignKeysOff { execSQL("ALTER TABLE `assets` ADD COLUMN `expected_hash` TEXT") } } } val MIGRATION_10_11: Migration = object : Migration(10, 11) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransaction { execSQL("UPDATE `assets` SET `expected_hash` = NULL") } } } /** * Change the `updates.manifest` column to be non-null */ val MIGRATION_11_12: Migration = object : Migration(11, 12) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransactionWithForeignKeysOff { execSQL("CREATE TABLE `new_updates` (`id` BLOB NOT NULL, `scope_key` TEXT NOT NULL, `commit_time` INTEGER NOT NULL, `runtime_version` TEXT NOT NULL, `launch_asset_id` INTEGER, `manifest` TEXT NOT NULL, `status` INTEGER NOT NULL, `keep` INTEGER NOT NULL, `last_accessed` INTEGER NOT NULL, `successful_launch_count` INTEGER NOT NULL DEFAULT 0, `failed_launch_count` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`launch_asset_id`) REFERENCES `assets`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )") execSQL( "INSERT INTO `new_updates` (`id`, `scope_key`, `commit_time`, `runtime_version`, `launch_asset_id`, `manifest`, `status`, `keep`, `last_accessed`, `successful_launch_count`, `failed_launch_count`)" + " SELECT `id`, `scope_key`, `commit_time`, `runtime_version`, `launch_asset_id`, `manifest`, `status`, `keep`, `last_accessed`, `successful_launch_count`, `failed_launch_count` FROM `updates` WHERE `manifest` IS NOT NULL" ) execSQL("DROP TABLE `updates`") execSQL("ALTER TABLE `new_updates` RENAME TO `updates`") execSQL("CREATE INDEX `index_updates_launch_asset_id` ON `updates` (`launch_asset_id`)") execSQL("CREATE UNIQUE INDEX `index_updates_scope_key_commit_time` ON `updates` (`scope_key`, `commit_time`)") } } } /** * Add the `url` and `headers` columns to `updates` */ val MIGRATION_12_13: Migration = object : Migration(12, 13) { override fun migrate(db: SupportSQLiteDatabase) { db.runInTransaction { execSQL("ALTER TABLE `updates` ADD COLUMN `url` TEXT") execSQL("ALTER TABLE `updates` ADD COLUMN `headers` TEXT") } } } } }