diff --git a/build.gradle b/build.gradle index 3c04bf51c..5a43f3b74 100644 --- a/build.gradle +++ b/build.gradle @@ -2,13 +2,14 @@ apply from: 'buildsystem/dependencies.gradle' buildscript { ext.kotlin_version = '1.9.24' + ext.roomVersion = '2.6.1' repositories { mavenCentral() google() } dependencies { classpath 'com.android.tools.build:gradle:8.4.1' - classpath 'org.greenrobot:greendao-gradle-plugin:3.3.1' + classpath "androidx.room:room-gradle-plugin:$roomVersion" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "de.mannodermaus.gradle.plugins:android-junit5:1.7.1.1" } diff --git a/buildsystem/dependencies.gradle b/buildsystem/dependencies.gradle index 387a69409..21ce78865 100644 --- a/buildsystem/dependencies.gradle +++ b/buildsystem/dependencies.gradle @@ -77,9 +77,6 @@ ext { lruFileCacheVersion = '1.2' - // KEEP IN SYNC WITH GENERATOR VERSION IN root build.gradle - greenDaoVersion = '3.3.0' - // cloud provider libs cryptolibVersion = '2.1.2' @@ -159,7 +156,9 @@ ext { googlePlayServicesAuth : "com.google.android.gms:play-services-auth:${googlePlayServicesVersion}", trackingFreeGoogleCLient : "com.github.cryptomator.google-http-java-client:google-http-client:${trackingFreeGoogleCLientVersion}", trackingFreeGoogleAndroidCLient: "com.github.cryptomator.google-http-java-client:google-http-client-android:${trackingFreeGoogleCLientVersion}", - greenDao : "org.greenrobot:greendao:${greenDaoVersion}", + room : "androidx.room:room-runtime:${roomVersion}", + roomCompiler : "androidx.room:room-compiler:${roomVersion}", + roomTesting : "androidx.room:room-testing:${roomVersion}", gson : "com.google.code.gson:gson:${gsonVersion}", hamcrest : "org.hamcrest:hamcrest-all:${hamcrestVersion}", javaxAnnotation : "javax.annotation:jsr250-api:${javaxAnnotationVersion}", diff --git a/data/build.gradle b/data/build.gradle index 4e3014675..be4847ea3 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -1,8 +1,16 @@ -apply plugin: 'org.greenrobot.greendao' +apply plugin: 'androidx.room' apply plugin: 'com.android.library' apply plugin: 'kotlin-android' +apply plugin: 'kotlin-kapt' apply plugin: 'de.mannodermaus.android-junit5' +def roomSchemasDir = new File(projectDir, "room/schemas") +def androidTestRoomSchemasDir = new File(buildDir, "tmp/room/schemas/androidTest") +def copyRoomSchemasTask = tasks.register("copyRoomSchemasToAndroidTestAssets", Sync.class) { + from(roomSchemasDir) + into(androidTestRoomSchemasDir) +} + android { def globalConfiguration = rootProject.extensions.getByName("ext") @@ -74,12 +82,25 @@ android { lite { java.srcDirs = ['src/main/java/', 'src/lite/java/'] } + + androidTest.assets.srcDir(androidTestRoomSchemasDir) } packagingOptions { resources { excludes += ['META-INF/DEPENDENCIES', 'META-INF/NOTICE.md', 'META-INF/INDEX.LIST'] } } + libraryVariants.configureEach { variant -> + tasks.findByName("kapt${variant.name.capitalize()}Kotlin")?.configure { kaptTask -> + copyRoomSchemasTask.configure { it.mustRunAfter(kaptTask) } + } + tasks.findByName("kapt${variant.name.capitalize()}AndroidTestKotlin")?.configure { kaptTask -> + copyRoomSchemasTask.configure { it.mustRunAfter(kaptTask) } + } + tasks.findByName("merge${variant.name.capitalize()}AndroidTestAssets")?.configure { + it.dependsOn(copyRoomSchemasTask) + } + } lint { abortOnError false @@ -89,8 +110,8 @@ android { namespace 'org.cryptomator.data' } -greendao { - schemaVersion 13 +room { + schemaDirectory(roomSchemasDir.path) } configurations.all { @@ -112,10 +133,14 @@ dependencies { // cryptomator implementation dependencies.cryptolib - // greendao - api dependencies.greenDao + // room + implementation dependencies.room + annotationProcessor dependencies.roomCompiler + kapt dependencies.roomCompiler + // dagger annotationProcessor dependencies.daggerCompiler + kapt dependencies.daggerCompiler implementation dependencies.dagger implementation dependencies.jsonWebToken @@ -217,6 +242,7 @@ dependencies { testImplementation dependencies.mockitoInline testImplementation dependencies.hamcrest + androidTestImplementation dependencies.roomTesting androidTestImplementation(dependencies.runner) { exclude group: 'com.android.support', module: 'support-annotations' } @@ -237,4 +263,4 @@ tasks.withType(Test) { showStandardStreams = false } -} +} \ No newline at end of file diff --git a/data/room/schemas/org.cryptomator.data.db.CryptomatorDatabase/14.json b/data/room/schemas/org.cryptomator.data.db.CryptomatorDatabase/14.json new file mode 100644 index 000000000..60e92a308 --- /dev/null +++ b/data/room/schemas/org.cryptomator.data.db.CryptomatorDatabase/14.json @@ -0,0 +1,250 @@ +{ + "formatVersion": 1, + "database": { + "version": 14, + "identityHash": "b8c52ca7bdf9dce0036787a18080b679", + "entities": [ + { + "tableName": "CLOUD_ENTITY", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `TYPE` TEXT NOT NULL, `ACCESS_TOKEN` TEXT, `ACCESS_TOKEN_CRYPTO_MODE` TEXT, `URL` TEXT, `USERNAME` TEXT, `WEBDAV_CERTIFICATE` TEXT, `S3_BUCKET` TEXT, `S3_REGION` TEXT, `S3_SECRET_KEY` TEXT, `S3_SECRET_KEY_CRYPTO_MODE` TEXT, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "TYPE", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "ACCESS_TOKEN", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accessTokenCryptoMode", + "columnName": "ACCESS_TOKEN_CRYPTO_MODE", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "URL", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "USERNAME", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webdavCertificate", + "columnName": "WEBDAV_CERTIFICATE", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "s3Bucket", + "columnName": "S3_BUCKET", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "s3Region", + "columnName": "S3_REGION", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "s3SecretKey", + "columnName": "S3_SECRET_KEY", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "s3SecretKeyCryptoMode", + "columnName": "S3_SECRET_KEY_CRYPTO_MODE", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UPDATE_CHECK_ENTITY", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `LICENSE_TOKEN` TEXT, `RELEASE_NOTE` TEXT, `VERSION` TEXT, `URL_TO_APK` TEXT, `APK_SHA256` TEXT, `URL_TO_RELEASE_NOTE` TEXT, PRIMARY KEY(`_id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "licenseToken", + "columnName": "LICENSE_TOKEN", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "releaseNote", + "columnName": "RELEASE_NOTE", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "VERSION", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urlToApk", + "columnName": "URL_TO_APK", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "apkSha256", + "columnName": "APK_SHA256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urlToReleaseNote", + "columnName": "URL_TO_RELEASE_NOTE", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "VAULT_ENTITY", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER, `FOLDER_CLOUD_ID` INTEGER, `FOLDER_PATH` TEXT, `FOLDER_NAME` TEXT, `CLOUD_TYPE` TEXT NOT NULL, `PASSWORD` TEXT, `PASSWORD_CRYPTO_MODE` TEXT, `POSITION` INTEGER, `FORMAT` INTEGER, `SHORTENING_THRESHOLD` INTEGER, PRIMARY KEY(`_id`), FOREIGN KEY(`FOLDER_CLOUD_ID`) REFERENCES `CLOUD_ENTITY`(`_id`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderCloudId", + "columnName": "FOLDER_CLOUD_ID", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderPath", + "columnName": "FOLDER_PATH", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderName", + "columnName": "FOLDER_NAME", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cloudType", + "columnName": "CLOUD_TYPE", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "PASSWORD", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "passwordCryptoMode", + "columnName": "PASSWORD_CRYPTO_MODE", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "POSITION", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "format", + "columnName": "FORMAT", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shorteningThreshold", + "columnName": "SHORTENING_THRESHOLD", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "_id" + ] + }, + "indices": [ + { + "name": "IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID", + "unique": true, + "columnNames": [ + "FOLDER_PATH", + "FOLDER_CLOUD_ID" + ], + "orders": [ + "ASC", + "ASC" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID` ON `${TABLE_NAME}` (`FOLDER_PATH` ASC, `FOLDER_CLOUD_ID` ASC)" + } + ], + "foreignKeys": [ + { + "table": "CLOUD_ENTITY", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "FOLDER_CLOUD_ID" + ], + "referencedColumns": [ + "_id" + ] + } + ] + } + ], + "views": [], + "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, 'b8c52ca7bdf9dce0036787a18080b679')" + ] + } +} \ No newline at end of file diff --git a/data/room/schemas/org.cryptomator.data.db.CryptomatorDatabase/15.json b/data/room/schemas/org.cryptomator.data.db.CryptomatorDatabase/15.json new file mode 100644 index 000000000..6f4d886a9 --- /dev/null +++ b/data/room/schemas/org.cryptomator.data.db.CryptomatorDatabase/15.json @@ -0,0 +1,250 @@ +{ + "formatVersion": 1, + "database": { + "version": 15, + "identityHash": "cf38b0f4b0e95445e2d2b37ea8df36b6", + "entities": [ + { + "tableName": "CLOUD_ENTITY", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `type` TEXT NOT NULL, `accessToken` TEXT, `accessTokenCryptoMode` TEXT, `url` TEXT, `username` TEXT, `webdavCertificate` TEXT, `s3Bucket` TEXT, `s3Region` TEXT, `s3SecretKey` TEXT, `s3SecretKeyCryptoMode` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessToken", + "columnName": "accessToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accessTokenCryptoMode", + "columnName": "accessTokenCryptoMode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "webdavCertificate", + "columnName": "webdavCertificate", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "s3Bucket", + "columnName": "s3Bucket", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "s3Region", + "columnName": "s3Region", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "s3SecretKey", + "columnName": "s3SecretKey", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "s3SecretKeyCryptoMode", + "columnName": "s3SecretKeyCryptoMode", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "UPDATE_CHECK_ENTITY", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `licenseToken` TEXT, `releaseNote` TEXT, `version` TEXT, `urlToApk` TEXT, `apkSha256` TEXT, `urlToReleaseNote` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "licenseToken", + "columnName": "licenseToken", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "releaseNote", + "columnName": "releaseNote", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urlToApk", + "columnName": "urlToApk", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "apkSha256", + "columnName": "apkSha256", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "urlToReleaseNote", + "columnName": "urlToReleaseNote", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "VAULT_ENTITY", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER, `folderCloudId` INTEGER, `folderPath` TEXT, `folderName` TEXT, `cloudType` TEXT NOT NULL, `password` TEXT, `passwordCryptoMode` TEXT, `position` INTEGER, `format` INTEGER, `shorteningThreshold` INTEGER, PRIMARY KEY(`id`), FOREIGN KEY(`folderCloudId`) REFERENCES `CLOUD_ENTITY`(`id`) ON UPDATE NO ACTION ON DELETE RESTRICT )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderCloudId", + "columnName": "folderCloudId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "folderPath", + "columnName": "folderPath", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "folderName", + "columnName": "folderName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "cloudType", + "columnName": "cloudType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "passwordCryptoMode", + "columnName": "passwordCryptoMode", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "shorteningThreshold", + "columnName": "shorteningThreshold", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID", + "unique": true, + "columnNames": [ + "folderPath", + "folderCloudId" + ], + "orders": [ + "ASC", + "ASC" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID` ON `${TABLE_NAME}` (`folderPath` ASC, `folderCloudId` ASC)" + } + ], + "foreignKeys": [ + { + "table": "CLOUD_ENTITY", + "onDelete": "RESTRICT", + "onUpdate": "NO ACTION", + "columns": [ + "folderCloudId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [], + "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, 'cf38b0f4b0e95445e2d2b37ea8df36b6')" + ] + } +} \ No newline at end of file diff --git a/data/src/androidTest/java/de/skymatic/android_issue153521693/BugTest.kt b/data/src/androidTest/java/de/skymatic/android_issue153521693/BugTest.kt new file mode 100644 index 000000000..fa66a4a88 --- /dev/null +++ b/data/src/androidTest/java/de/skymatic/android_issue153521693/BugTest.kt @@ -0,0 +1,248 @@ +/* + +MIT License + +Copyright (c) 2024 Skymatic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ +package de.skymatic.android_issue153521693 + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase.* +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +// See https://github.com/cryptomator/android/issues/529 to learn more about this class. +// Also see https://github.com/SailReal/Android-Issue-153521693/tree/cfb5033cf287572ac4b4972700f1656ce2b61d03/src/androidTest/java/de/skymatic/android_issue153521693 +@RunWith(AndroidJUnit4::class) +class BugTest { + + private val TEST_DB = "bug-test" + + private val context = InstrumentationRegistry.getInstrumentation().context + + private lateinit var db: SupportSQLiteDatabase + + @Before + fun setup() { + context.getDatabasePath(TEST_DB).delete() //Clean up last test + + //The bug doesn't seem to appear if everything is done is one session (at least with this suite), so let's simulate two sessions + /* Database is created */ + SupportSQLiteOpenHelper.Configuration(context, TEST_DB, + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = createDb(db) + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) = throw AssertionError() + }).let { FrameworkSQLiteOpenHelperFactory().create(it).writableDatabase }.close() + + /* Database is closed, e.g. the app has been closed */ + + //////////////////////////////////////////// + + /* Database is opened, not created */ + + db = SupportSQLiteOpenHelper.Configuration(context, TEST_DB, + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = throw AssertionError() + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) = throw AssertionError() + }).let { FrameworkSQLiteOpenHelperFactory().create(it).writableDatabase } + } + + @After + fun tearDown() { + if (this::db.isInitialized) { + db.close() + } + } + + private fun createDb(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE `TEST_TABLE` (" + // + "`column0` TEXT NOT NULL," + + "`column1` TEXT NOT NULL" + + ")" + ) + db.execSQL("INSERT INTO `TEST_TABLE` (`column0`, `column1`) VALUES ('content0', 'content1')") + + db.version = 1 + } + + private fun modifySchema(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TEST_TABLE` RENAME TO `TEST_TABLE_OLD`") + db.execSQL( + "CREATE TABLE `TEST_TABLE` (" + // + "`column0` TEXT NOT NULL," + + "`column1` TEXT NOT NULL," + + "`column2` TEXT" + + ")" + ) + db.execSQL("INSERT INTO `TEST_TABLE` (`column0`, `column1`) SELECT `column0`, `column1` FROM `TEST_TABLE_OLD`") + db.execSQL("DROP TABLE `TEST_TABLE_OLD`") + db.update( + "TEST_TABLE", + CONFLICT_NONE, + ContentValues().also { it.put("column2", "content2"); }, + null, + null + ) + } + + private fun assertBroken(cursor: Cursor) { + //cursor.moveToFirst() //This doesn't help because it only fixes *future* cursors + assertEquals(2, cursor.columnCount) //Should be 3! + assertArrayEquals( + arrayOf("column0", "column1"), + cursor.columnNames + ) //Should be ["column0", "column1", "column2"] + assertEquals(-1, cursor.getColumnIndex("column2")) //Should be 2 + } + + private fun assertNotBroken(cursor: Cursor) { + //cursor.moveToFirst() //Not needed and it wouldn't fix this cursor either way. + assertEquals(3, cursor.columnCount) + assertArrayEquals(arrayOf("column0", "column1", "column2"), cursor.columnNames) + assertEquals(2, cursor.getColumnIndex("column2")) + } + + @Test + fun causeBugWithoutBackticks() { + db.query("SELECT * FROM TEST_TABLE").close() + + modifySchema(db) + + db.query("SELECT * FROM TEST_TABLE").use { + assertBroken(it) + } + } + + @Test + fun causeBugWithBackticks() { + db.query("SELECT * FROM `TEST_TABLE`").close() + + modifySchema(db) + + db.query("SELECT * FROM `TEST_TABLE`").use { + assertBroken(it) + } + } + + @Test + fun causeBugWithMove1() { + db.query("SELECT * FROM `TEST_TABLE`").close() + + modifySchema(db) + + db.query("SELECT * FROM `TEST_TABLE`").use { + it.moveToFirst() //This doesn't help because it only fixes *future* cursors + assertBroken(it) + } + + db.query("SELECT * FROM `TEST_TABLE`").use { + //it.moveToFirst() //Not needed and it wouldn't fix this cursor either way. + assertNotBroken(it) + } + } + + @Test + fun causeBugWithMove2() { + db.query("SELECT * FROM TEST_TABLE").close() + db.query("SELECT * FROM `TEST_TABLE`").close() + + modifySchema(db) + + db.query("SELECT * FROM `TEST_TABLE`").use { + assertBroken(it) + } + + db.query("SELECT * FROM TEST_TABLE").use { + it.moveToFirst() //This doesn't help because it only fixes *future* cursors + assertBroken(it) + } + + db.query("SELECT * FROM `TEST_TABLE`").use { + assertBroken(it) + } + } + + @Test + fun doNotCauseBugWithMixedBackticks1() { + db.query("SELECT * FROM `TEST_TABLE`").close() + + modifySchema(db) + + db.query("SELECT * FROM TEST_TABLE").use { + assertNotBroken(it) + } + } + + @Test + fun doNotCauseBugWithMixedBackticks2() { + db.query("SELECT * FROM TEST_TABLE").close() + + modifySchema(db) + + db.query("SELECT * FROM `TEST_TABLE`").use { + assertNotBroken(it) + } + } + + @Test + fun doNotCauseBugWithoutFirstCall() { + modifySchema(db) + + db.query("SELECT * FROM `TEST_TABLE`").use { + assertNotBroken(it) + } + } + + @Test + fun doNotCauseBugWithRefresh() { + db.query("SELECT * FROM `TEST_TABLE`").close() + + modifySchema(db) + + db.query("SELECT * FROM `TEST_TABLE`").use { + it.count //Alternatively: it.moveToNext(), it.moveToFirst() + } + + db.query("SELECT * FROM `TEST_TABLE`").use { + assertNotBroken(it) + } + } +} \ No newline at end of file diff --git a/data/src/androidTest/java/de/skymatic/android_issue153521693/SmallBugTest.kt b/data/src/androidTest/java/de/skymatic/android_issue153521693/SmallBugTest.kt new file mode 100644 index 000000000..8de9914f0 --- /dev/null +++ b/data/src/androidTest/java/de/skymatic/android_issue153521693/SmallBugTest.kt @@ -0,0 +1,140 @@ +/* + +MIT License + +Copyright (c) 2024 Skymatic + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +*/ +package de.skymatic.android_issue153521693 + +import android.content.ContentValues +import android.database.sqlite.SQLiteDatabase.* +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Assert.assertArrayEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +// See https://github.com/cryptomator/android/issues/529 to learn more about this class. +// Also see https://github.com/SailReal/Android-Issue-153521693/tree/cfb5033cf287572ac4b4972700f1656ce2b61d03/src/androidTest/java/de/skymatic/android_issue153521693 +@RunWith(AndroidJUnit4::class) +@SmallTest +class SmallBugTest { + + private val TEST_DB = "small-bug-test" + + private val context = InstrumentationRegistry.getInstrumentation().context + + private lateinit var db: SupportSQLiteDatabase + + @Before + fun setup() { + context.getDatabasePath(TEST_DB).delete() //Clean up last test + + //The bug doesn't seem to appear if everything is done is one session (at least with this suite), so let's simulate two sessions + /* Database is created */ + SupportSQLiteOpenHelper.Configuration(context, TEST_DB, + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = createDb(db) + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) = throw AssertionError() + }).let { FrameworkSQLiteOpenHelperFactory().create(it).writableDatabase }.close() + + /* Database is closed, e.g. the app has been closed */ + + //////////////////////////////////////////// + + /* Database is opened, not created */ + + db = SupportSQLiteOpenHelper.Configuration(context, TEST_DB, + object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) = throw AssertionError() + override fun onUpgrade( + db: SupportSQLiteDatabase, + oldVersion: Int, + newVersion: Int + ) = throw AssertionError() + }).let { FrameworkSQLiteOpenHelperFactory().create(it).writableDatabase } + } + + @After + fun tearDown() { + if (this::db.isInitialized) { + db.close() + } + } + + private fun createDb(db: SupportSQLiteDatabase) { + db.execSQL( + "CREATE TABLE `TEST_TABLE` (" + // + "`column0` TEXT NOT NULL," + + "`column1` TEXT NOT NULL" + + ")" + ) + db.execSQL("INSERT INTO `TEST_TABLE` (`column0`, `column1`) VALUES ('content0', 'content1')") + + db.version = 1 + } + + private fun modifySchema(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `TEST_TABLE` RENAME TO `TEST_TABLE_OLD`") + db.execSQL( + "CREATE TABLE `TEST_TABLE` (" + // + "`column0` TEXT NOT NULL," + + "`column1` TEXT NOT NULL," + + "`column2` TEXT" + + ")" + ) + db.execSQL("INSERT INTO `TEST_TABLE` (`column0`, `column1`) SELECT `column0`, `column1` FROM `TEST_TABLE_OLD`") + db.execSQL("DROP TABLE `TEST_TABLE_OLD`") + db.update( + "TEST_TABLE", + CONFLICT_NONE, + ContentValues().also { it.put("column2", "content2"); }, + null, + null + ) + } + + @Test + fun causeBug() { //If this test is successful, the bug occurred + db.query("SELECT * FROM TEST_TABLE").close() + + modifySchema(db) + + db.query("SELECT * FROM TEST_TABLE").use { + it.moveToFirst() + assertArrayEquals( + arrayOf("column0", "column1"), + it.columnNames + ) //Should be ["column0", "column1", "column2"] + } + } +} \ No newline at end of file diff --git a/data/src/androidTest/java/org/cryptomator/data/db/CorruptedDatabaseTest.kt b/data/src/androidTest/java/org/cryptomator/data/db/CorruptedDatabaseTest.kt new file mode 100644 index 000000000..49c50cd05 --- /dev/null +++ b/data/src/androidTest/java/org/cryptomator/data/db/CorruptedDatabaseTest.kt @@ -0,0 +1,195 @@ +package org.cryptomator.data.db + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.cryptomator.data.db.CryptomatorAssert.Order +import org.cryptomator.data.db.CryptomatorAssert.assertOrder +import org.cryptomator.data.db.migrations.MigrationContainer +import org.cryptomator.data.db.templating.DbTemplateModule +import org.cryptomator.data.db.templating.TemplateDatabaseContext +import org.cryptomator.data.util.useFinally +import org.cryptomator.util.SharedPreferencesHandler +import org.hamcrest.CoreMatchers.instanceOf +import org.hamcrest.MatcherAssert.assertThat +import org.junit.After +import org.junit.Assert.assertThrows +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import java.io.IOException + +private const val TEST_DB = "corruption-test" + +@RunWith(AndroidJUnit4::class) +@SmallTest +class CorruptedDatabaseTest { + + private val context = InstrumentationRegistry.getInstrumentation().context + private val sharedPreferencesHandler = SharedPreferencesHandler(context) + private val templateDbStream = DbTemplateModule().let { + it.provideDbTemplateStream(it.provideConfiguration(TemplateDatabaseContext(context))) + }.also { + require(it.markSupported()) + it.mark(it.available()) + } + + private lateinit var migrationContainer: MigrationContainer + + @Before + fun setup() { + context.getDatabasePath(TEST_DB).also { dbFile -> + if (dbFile.exists()) { + //This may happen when killing the process while using the debugger + println("Test database \"${dbFile.absolutePath}\" not cleaned up. Deleting...") + dbFile.delete() + } + } + + migrationContainer = createMigrationContainer(context, sharedPreferencesHandler) + } + + @After + fun tearDown() { + context.getDatabasePath(TEST_DB).delete() + templateDbStream.reset() + } + + private fun createVersion0Database() { + createVersion0Database(context, TEST_DB) + } + + @Test + fun testOpenVersion0Database() { + createVersion0Database() + DatabaseModule().provideInternalCryptomatorDatabase( // + context, // + migrationContainer.getPath(1).toTypedArray(), // + { templateDbStream }, // + openHelperFactory(), // + TEST_DB // + ).useFinally({ db -> + db.compileStatement("SELECT count(*) FROM `sqlite_master` WHERE `name` = 'CLOUD_ENTITY'").use { statement -> + require(statement.simpleQueryForLong() == 1L) + } + }, finallyBlock = CryptomatorDatabase::close) + } + + @Test + fun testOpenVersion0DatabaseVerifyStreamAccessed() { + val order = Order() + val templateStreamCallable = { + assertOrder(order, 0) + templateDbStream + } + val listener = object : InterceptorOpenHelperListener { + override fun onWritableDatabaseCalled() { + assertOrder(order, 1, 3, 4) + } + } + + createVersion0Database() + DatabaseModule().provideInternalCryptomatorDatabase( // + context, // + migrationContainer.getPath(1).toTypedArray(), // + templateStreamCallable, // + InterceptorOpenHelperFactory(openHelperFactory(), listener), // + TEST_DB // + ).useFinally({ db -> + assertOrder(order, 2) + db.compileStatement("SELECT count(*) FROM `sqlite_master` WHERE `name` = 'CLOUD_ENTITY'").use { statement -> + require(statement.simpleQueryForLong() == 1L) + } + }, finallyBlock = CryptomatorDatabase::close) + assertOrder(order, 5) + } + + @Test + fun testOpenDatabaseWithRecovery() { + val order = Order() + val templateStreamCallable = { + assertOrder(order, 0) + throw IOException() + } + val listener = object : InterceptorOpenHelperListener { + override fun onWritableDatabaseCalled() { + assertOrder(order, 1) + } + + override fun onWritableDatabaseThrew(exc: Exception): Exception { + assertOrder(order, 4) + assertThat(exc, instanceOf(UnsupportedOperationException::class.java)) + return WrappedException(exc) + } + } + val openHelperFactory = openHelperFactory { + assertOrder(order, 2, 3) + } + + createVersion0Database(context, TEST_DB) + assertThrows(WrappedException::class.java) { + DatabaseModule().provideInternalCryptomatorDatabase( // + context, // + migrationContainer.getPath(1).toTypedArray(), // + templateStreamCallable, // + InterceptorOpenHelperFactory(openHelperFactory, listener), // + TEST_DB // + ).useFinally({ _ -> + fail("Database initialization must throw") + }, finallyBlock = CryptomatorDatabase::close) + }.also { + assertThat(it.cause, instanceOf(UnsupportedOperationException::class.java)) + } + assertOrder(order, 5) + } +} + +private fun openHelperFactory( + invalidationCallback: () -> Unit = { throw IllegalStateException() } +): DatabaseOpenHelperFactory { + return DatabaseOpenHelperFactory(invalidationCallback) +} + +private class InterceptorOpenHelperFactory( + private val delegate: SupportSQLiteOpenHelper.Factory, // + private val listener: InterceptorOpenHelperListener +) : SupportSQLiteOpenHelper.Factory { + + override fun create( + configuration: SupportSQLiteOpenHelper.Configuration + ): SupportSQLiteOpenHelper { + return InterceptorOpenHelper(delegate.create(configuration), listener) + } +} + +private class InterceptorOpenHelper( + private val delegate: SupportSQLiteOpenHelper, // + private val listener: InterceptorOpenHelperListener +) : SupportSQLiteOpenHelper by delegate { + + override val writableDatabase: SupportSQLiteDatabase + get() { + listener.onWritableDatabaseCalled() + try { + return delegate.writableDatabase + } catch (exc: Exception) { + throw listener.onWritableDatabaseThrew(exc) + } + } + + override val readableDatabase: SupportSQLiteDatabase + get() = throw AssertionError() +} + +private interface InterceptorOpenHelperListener { + + fun onWritableDatabaseCalled() + fun onWritableDatabaseThrew(exc: Exception): Exception = exc + +} + +@Suppress("serial") +class WrappedException(cause: Exception) : Exception(cause) \ No newline at end of file diff --git a/data/src/androidTest/java/org/cryptomator/data/db/CreateDatabaseTest.kt b/data/src/androidTest/java/org/cryptomator/data/db/CreateDatabaseTest.kt new file mode 100644 index 000000000..287e31ea2 --- /dev/null +++ b/data/src/androidTest/java/org/cryptomator/data/db/CreateDatabaseTest.kt @@ -0,0 +1,98 @@ +package org.cryptomator.data.db + +import androidx.room.util.useCursor +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.cryptomator.data.db.migrations.Sql +import org.cryptomator.data.db.templating.DbTemplateModule +import org.cryptomator.data.db.templating.TemplateDatabaseContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import java.nio.file.Files + +@RunWith(AndroidJUnit4::class) +@SmallTest +class CreateDatabaseTest { + + private val context = InstrumentationRegistry.getInstrumentation().context + + @get:Rule + val tempFolder: TemporaryFolder = TemporaryFolder() + + @Test + fun testProvideDbTemplateStream() { + val templateStream = DbTemplateModule().let { it.provideDbTemplateStream(it.provideConfiguration(TemplateDatabaseContext(context))) } + val templateFile = tempFolder.newFolder("provideDbTemplateStream").resolve(DATABASE_NAME) + Files.copy(templateStream, templateFile.toPath()) + val templateDatabaseContext = TemplateDatabaseContext(context).also { + it.templateFile = templateFile + } + + val config = SupportSQLiteOpenHelper.Configuration.builder(templateDatabaseContext) // + .name(DATABASE_NAME) // + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onConfigure(db: SupportSQLiteDatabase) = db.applyDefaultConfiguration( // + assertedWalEnabledStatus = false // + ) + + override fun onCreate(db: SupportSQLiteDatabase) = fail("Database should already exist") + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = fail("Database should already be target version") + }).build() + + FrameworkSQLiteOpenHelperFactory().create(config).use { openHelper -> + openHelper.setWriteAheadLoggingEnabled(false) + verifyDbTemplateStream(openHelper) + } + } + + private fun verifyDbTemplateStream(openHelper: SupportSQLiteOpenHelper) { + openHelper.writableDatabase.use { templateDb -> + assertEquals(1, templateDb.version) + + val elements = mutableListOf("CLOUD_ENTITY", "VAULT_ENTITY", "IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID") + Sql.query("sqlite_master") // + .columns(listOf("name")) // + .where("name", Sql.notEq("android_metadata")) // + .executeOn(templateDb) // + .useCursor { + while (it.moveToNext()) { + val elementName = it.getString(it.getColumnIndex("name")) + assertTrue("Unknown/Duplicate element: \"$elementName\"", elements.remove(elementName)) + } + assertTrue("Missing element(s): ${elements.joinToString(prefix = "\"", postfix = "\"")}", elements.isEmpty()) + } + } + } + + @Test + fun testProvideDbTemplateStreamFiles() { + val templateDatabaseContext = TemplateDatabaseContext(context) + assertNull(templateDatabaseContext.templateFile) + DbTemplateModule().let { it.provideDbTemplateStream(it.provideConfiguration(templateDatabaseContext)) }.close() + + val templateFile = assertNotNullObj(templateDatabaseContext.templateFile) + assertFalse(templateFile.exists()) + + val parentDir = assertNotNullObj(templateFile.parentFile) + assertFalse(parentDir.exists()) + assertEquals(requireNotNull(context.cacheDir), parentDir.parentFile) + } +} + +private fun assertNotNullObj(obj: T?): T { + //TODO Improve this method by using the kotlin contract API once it's stable + assertNotNull(obj) + return obj!! +} \ No newline at end of file diff --git a/data/src/androidTest/java/org/cryptomator/data/db/CryptomatorAssert.java b/data/src/androidTest/java/org/cryptomator/data/db/CryptomatorAssert.java new file mode 100644 index 000000000..c85e73d73 --- /dev/null +++ b/data/src/androidTest/java/org/cryptomator/data/db/CryptomatorAssert.java @@ -0,0 +1,105 @@ +package org.cryptomator.data.db; + +import android.database.Cursor; + +import com.google.android.gms.common.util.Strings; + +import java.text.MessageFormat; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.IntStream; + +import static org.junit.Assert.fail; + +public class CryptomatorAssert { + + private final static Pattern UUID_PATTERN = Pattern.compile("^\\p{XDigit}{8}-\\p{XDigit}{4}-\\p{XDigit}{4}-\\p{XDigit}{4}-\\p{XDigit}{12}$"); + + private CryptomatorAssert() { + } + + public static void assertCursorEquals(Cursor expected, Cursor actual) { + assertCursorEquals(null, expected, actual); + } + + public static void assertCursorEquals(String message, Cursor expected, Cursor actual) { + if ((expected == null) != (actual == null)) { + failCursorNotEquals(message, expected, actual); + return; + } + if (expected == null || CryptomatorDatabaseKt.equalsCursor(expected, actual)) { + return; + } + + failCursorNotEquals(message, expected, actual); + } + + private static void failCursorNotEquals(String message, Cursor expected, Cursor actual) { + String failMessage = MessageFormat.format("{0}\n" + // + "---------- expected ----------\n" + // + "{1}\n" + // + "---------- but was ----------\n" + // + "{2}", // + message != null && !Strings.isEmptyOrWhitespace(message) ? message : "Cursors are not equal", // + CryptomatorDatabaseKt.stringify(expected), // + CryptomatorDatabaseKt.stringify(actual)); + fail(failMessage); + } + + public static void assertIsUUID(String actual) { + assertIsUUID(null, actual); + } + + public static void assertIsUUID(String message, String actual) { + if (actual != null && UUID_PATTERN.matcher(actual).matches()) { + return; + } + String failMessage = MessageFormat.format("{0}: {1}", // + message != null && !Strings.isEmptyOrWhitespace(message) ? message : "String is not a valid UUID", // + actual != null ? '"' + actual + '"' : ""); + fail(failMessage); + } + + public static void assertOrder(Order order, int firstStep, int... moreSteps) { + order.assertOrder(firstStep, moreSteps); + } + + public static class Order { + + private final Object lock = new Object(); + private final Map> recognized = new HashMap<>(); + + private int currentStep = 0; + + public void assertOrder(int firstStep, int... moreSteps) { + List steps = IntStream.concat( // + IntStream.of(firstStep), // + Arrays.stream(moreSteps) // + ).boxed().toList(); + + assertOrder(steps); + } + + private void assertOrder(List steps) { + synchronized (lock) { + for (Integer step : steps) { + List existing = recognized.get(step); + if (existing != null) { + if (!existing.equals(steps)) { + fail("Step has been assigned twice!"); + } + } else { + recognized.put(step, steps); + } + } + if (!steps.contains(currentStep)) { + fail("Expected step was any of %s; current step is <%d>".formatted(steps, currentStep)); + } + currentStep++; + } + } + } +} \ No newline at end of file diff --git a/data/src/androidTest/java/org/cryptomator/data/db/DatabaseTests.kt b/data/src/androidTest/java/org/cryptomator/data/db/DatabaseTests.kt new file mode 100644 index 000000000..8e8c4064c --- /dev/null +++ b/data/src/androidTest/java/org/cryptomator/data/db/DatabaseTests.kt @@ -0,0 +1,100 @@ +package org.cryptomator.data.db + +import android.content.Context +import androidx.room.util.readVersion +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import org.cryptomator.data.db.migrations.MigrationContainer +import org.cryptomator.data.db.migrations.legacy.Upgrade10To11 +import org.cryptomator.data.db.migrations.legacy.Upgrade11To12 +import org.cryptomator.data.db.migrations.legacy.Upgrade12To13 +import org.cryptomator.data.db.migrations.legacy.Upgrade1To2 +import org.cryptomator.data.db.migrations.legacy.Upgrade2To3 +import org.cryptomator.data.db.migrations.legacy.Upgrade3To4 +import org.cryptomator.data.db.migrations.legacy.Upgrade4To5 +import org.cryptomator.data.db.migrations.legacy.Upgrade5To6 +import org.cryptomator.data.db.migrations.legacy.Upgrade6To7 +import org.cryptomator.data.db.migrations.legacy.Upgrade7To8 +import org.cryptomator.data.db.migrations.legacy.Upgrade8To9 +import org.cryptomator.data.db.migrations.legacy.Upgrade9To10 +import org.cryptomator.data.db.migrations.manual.Migration13To14 +import org.cryptomator.util.SharedPreferencesHandler +import org.junit.Assert.assertEquals +import org.junit.Assert.fail + +private const val LATEST_LEGACY_MIGRATION = 13 + +internal fun configureTestDatabase(context: Context, databaseName: String): SupportSQLiteOpenHelper.Configuration { + return SupportSQLiteOpenHelper.Configuration.builder(context) // + .name(databaseName) // + .callback(object : SupportSQLiteOpenHelper.Callback(LATEST_LEGACY_MIGRATION) { + override fun onConfigure(db: SupportSQLiteDatabase) = db.applyDefaultConfiguration( // + assertedWalEnabledStatus = false // + ) + + override fun onCreate(db: SupportSQLiteDatabase) { + fail("Database should not be created, but copied from template") + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { + assertEquals(1, oldVersion) + assertEquals(LATEST_LEGACY_MIGRATION, newVersion) + } + }).build() +} + +internal fun createVersion0Database(context: Context, databaseName: String) { + val config = SupportSQLiteOpenHelper.Configuration.builder(context) // + .name(databaseName) // + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onConfigure(db: SupportSQLiteDatabase) = db.applyDefaultConfiguration( // + assertedWalEnabledStatus = false // + ) + + override fun onCreate(db: SupportSQLiteDatabase) = throw InterruptCreationException() + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = throw IllegalStateException() + }).build() + + FrameworkSQLiteOpenHelperFactory().create(config).use { openHelper -> + openHelper.setWriteAheadLoggingEnabled(false) + try { + //The "use" block in "initVersion0Database" should not be reached, let alone finished; ... + initVersion0Database(openHelper) + } catch (e: InterruptCreationException) { + //... instead, the creation of the database should be interrupted by the InterruptCreationException thrown by "onCreate", + //so that this catch block is called and the database remains in version 0. + require(readVersion(context.getDatabasePath(databaseName)) == 0) + } + } +} + +@Suppress("serial") +private class InterruptCreationException : Exception() + +private fun initVersion0Database(openHelper: SupportSQLiteOpenHelper): Nothing { + openHelper.writableDatabase.use { + throw IllegalStateException("Creating a v0 database requires throwing an exception during creation (got ${it.version})") + } +} + +internal fun createMigrationContainer( + context: Context, // + sharedPreferencesHandler: SharedPreferencesHandler +) = MigrationContainer( + Upgrade1To2(), // + Upgrade2To3(context), // + Upgrade3To4(), // + Upgrade4To5(), // + Upgrade5To6(), // + Upgrade6To7(), // + Upgrade7To8(), // + Upgrade8To9(sharedPreferencesHandler), // + Upgrade9To10(sharedPreferencesHandler), // + Upgrade10To11(), // + Upgrade11To12(sharedPreferencesHandler), // + Upgrade12To13(context), // + // + Migration13To14(), // + //Auto: 14 -> 15 +) \ No newline at end of file diff --git a/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt b/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt index 8f79d67cc..51d182fe9 100644 --- a/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt +++ b/data/src/androidTest/java/org/cryptomator/data/db/UpgradeDatabaseTest.kt @@ -1,97 +1,153 @@ package org.cryptomator.data.db import android.content.Context -import android.database.sqlite.SQLiteDatabase +import android.database.Cursor +import androidx.room.migration.Migration +import androidx.room.testing.MigrationTestHelper +import androidx.room.util.copyAndClose +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import com.google.common.base.Optional -import org.cryptomator.data.db.entities.CloudEntityDao -import org.cryptomator.data.db.entities.UpdateCheckEntityDao -import org.cryptomator.data.db.entities.VaultEntityDao +import org.cryptomator.data.db.CryptomatorAssert.assertCursorEquals +import org.cryptomator.data.db.CryptomatorAssert.assertIsUUID +import org.cryptomator.data.db.SQLiteCacheControl.asCacheControlled +import org.cryptomator.data.db.migrations.MigrationContainer +import org.cryptomator.data.db.migrations.Sql +import org.cryptomator.data.db.migrations.legacy.Upgrade10To11 +import org.cryptomator.data.db.migrations.legacy.Upgrade11To12 +import org.cryptomator.data.db.migrations.legacy.Upgrade12To13 +import org.cryptomator.data.db.migrations.legacy.Upgrade2To3 +import org.cryptomator.data.db.migrations.legacy.Upgrade3To4 +import org.cryptomator.data.db.migrations.legacy.Upgrade4To5 +import org.cryptomator.data.db.migrations.legacy.Upgrade5To6 +import org.cryptomator.data.db.migrations.legacy.Upgrade6To7 +import org.cryptomator.data.db.migrations.legacy.Upgrade7To8 +import org.cryptomator.data.db.migrations.legacy.Upgrade8To9 +import org.cryptomator.data.db.migrations.legacy.Upgrade9To10 +import org.cryptomator.data.db.migrations.manual.Migration13To14 +import org.cryptomator.data.db.templating.DbTemplateModule +import org.cryptomator.data.db.templating.TemplateDatabaseContext import org.cryptomator.domain.CloudType import org.cryptomator.util.SharedPreferencesHandler import org.cryptomator.util.crypto.CredentialCryptor import org.cryptomator.util.crypto.CryptoMode -import org.greenrobot.greendao.database.Database -import org.greenrobot.greendao.database.StandardDatabase -import org.greenrobot.greendao.internal.DaoConfig import org.hamcrest.CoreMatchers import org.junit.After import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before +import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import java.io.IOException +import java.nio.file.Files + +private const val TEST_DB = "migration-test" + +private const val UUID_LENGTH = 36 @RunWith(AndroidJUnit4::class) @SmallTest class UpgradeDatabaseTest { - private val context = InstrumentationRegistry.getInstrumentation().context + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context = instrumentation.context private val sharedPreferencesHandler = SharedPreferencesHandler(context) - private lateinit var db: Database + + private val templateDbStream = DbTemplateModule().let { + it.provideDbTemplateStream(it.provideConfiguration(TemplateDatabaseContext(context))) + }.also { + require(it.markSupported()) + it.mark(it.available()) + } + + private lateinit var migrationContainer: MigrationContainer + private lateinit var openHelper: SupportSQLiteOpenHelper + private lateinit var db: SupportSQLiteDatabase + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( // + instrumentation, // + CryptomatorDatabase::class.java, // + listOf(), //TODO AutoSpecs + DatabaseOpenHelperFactory { throw IllegalStateException() } + ) @Before fun setup() { - db = StandardDatabase(SQLiteDatabase.create(null)) + context.getDatabasePath(TEST_DB).also { dbFile -> + if (dbFile.exists()) { + //This may happen when killing the process while using the debugger + println("Test database \"${dbFile.absolutePath}\" not cleaned up. Deleting...") + dbFile.delete() + } + Files.copy(templateDbStream, dbFile.toPath()) + } + + migrationContainer = createMigrationContainer(context, sharedPreferencesHandler) + + openHelper = FrameworkSQLiteOpenHelperFactory().asCacheControlled().create(configureTestDatabase(context, TEST_DB)) + openHelper.setWriteAheadLoggingEnabled(false) + db = openHelper.writableDatabase } @After fun tearDown() { db.close() + openHelper.close() + //Room handles creating/deleting room-only databases correctly, but this falls apart when using the FrameworkSQLiteOpenHelper directly + context.getDatabasePath(TEST_DB).delete() + templateDbStream.reset() } @Test fun upgradeAll() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) - Upgrade12To13(context).applyTo(db, 12) - - CloudEntityDao(DaoConfig(db, CloudEntityDao::class.java)).loadAll() - VaultEntityDao(DaoConfig(db, VaultEntityDao::class.java)).loadAll() - UpdateCheckEntityDao(DaoConfig(db, UpdateCheckEntityDao::class.java)).loadAll() + migrationContainer.applyPath(db, 1, 13) + db.close() + + runMigrationsAndValidate(14, Migration13To14()) + runMigrationsAndValidate(15, CryptomatorDatabase_AutoMigration_14_15_Impl()) } + @Throws(IOException::class) + private fun runMigrationsAndValidate(version: Int, vararg migrations: Migration): SupportSQLiteDatabase { + return helper.runMigrationsAndValidate(TEST_DB, version, true, *migrations).also { db -> helper.closeWhenFinished(db) } + } @Test fun upgrade2To3() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 2) val url = "url" val username = "username" val webdavCertificate = "webdavCertificate" val accessToken = "accessToken" - Sql.update("CLOUD_ENTITY") - .where("TYPE", Sql.eq("DROPBOX")) - .set("ACCESS_TOKEN", Sql.toString(accessToken)) - .set("WEBDAV_URL", Sql.toString(url)) - .set("USERNAME", Sql.toString(username)) - .set("WEBDAV_CERTIFICATE", Sql.toString(webdavCertificate)) + Sql.update("CLOUD_ENTITY") // + .where("TYPE", Sql.eq("DROPBOX")) // + .set("ACCESS_TOKEN", Sql.toString(accessToken)) // + .set("WEBDAV_URL", Sql.toString(url)) // + .set("USERNAME", Sql.toString(username)) // + .set("WEBDAV_CERTIFICATE", Sql.toString(webdavCertificate)) // .executeOn(db) - Sql.update("CLOUD_ENTITY") - .where("TYPE", Sql.eq("ONEDRIVE")) - .set("ACCESS_TOKEN", Sql.toString("NOT USED")) - .set("WEBDAV_URL", Sql.toString(url)) - .set("USERNAME", Sql.toString(username)) - .set("WEBDAV_CERTIFICATE", Sql.toString(webdavCertificate)) + Sql.update("CLOUD_ENTITY") // + .where("TYPE", Sql.eq("ONEDRIVE")) // + .set("ACCESS_TOKEN", Sql.toString("NOT USED")) // + .set("WEBDAV_URL", Sql.toString(url)) // + .set("USERNAME", Sql.toString(username)) // + .set("WEBDAV_CERTIFICATE", Sql.toString(webdavCertificate)) // .executeOn(db) context.getSharedPreferences("com.microsoft.live", Context.MODE_PRIVATE).edit().putString("refresh_token", accessToken).commit() - Upgrade2To3(context).applyTo(db, 2) + testedUpgrade.migrate(db) checkUpgrade2to3ResultForCloud("DROPBOX", accessToken, url, username, webdavCertificate) checkUpgrade2to3ResultForCloud("ONEDRIVE", accessToken, url, username, webdavCertificate) @@ -111,9 +167,7 @@ class UpgradeDatabaseTest { @Test fun upgrade3To4() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 3) val ids = arrayOf("10", "20", "31", "32", "51") @@ -128,7 +182,7 @@ class UpgradeDatabaseTest { .executeOn(db) } - Upgrade3To4().applyTo(db, 3) + testedUpgrade.migrate(db) Sql.query("VAULT_ENTITY").where("CLOUD_TYPE", Sql.eq(CloudType.DROPBOX.name)).executeOn(db).use { Assert.assertThat(it.count, CoreMatchers.`is`(ids.size)) @@ -146,10 +200,7 @@ class UpgradeDatabaseTest { @Test fun upgrade4To5() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 4) val cloudId = 15 val cloudUrl = "url" @@ -182,7 +233,7 @@ class UpgradeDatabaseTest { .integer("POSITION", position) // .executeOn(db) - Upgrade4To5().applyTo(db, 4) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("TYPE", Sql.eq(CloudType.WEBDAV.name)).executeOn(db).use { it.moveToFirst() @@ -208,11 +259,7 @@ class UpgradeDatabaseTest { @Test fun upgrade5To6() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 5) val cloudId = 15 val cloudUrl = "url" @@ -245,7 +292,7 @@ class UpgradeDatabaseTest { .integer("POSITION", position) // .executeOn(db) - Upgrade5To6().applyTo(db, 5) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("TYPE", Sql.eq(CloudType.WEBDAV.name)).executeOn(db).use { it.moveToFirst() @@ -271,12 +318,7 @@ class UpgradeDatabaseTest { @Test fun upgrade6To7() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 6) val licenseToken = "licenseToken" val releaseNote = "releaseNote" @@ -284,15 +326,15 @@ class UpgradeDatabaseTest { val urlApk = "urlApk" val urlReleaseNote = "urlReleaseNote" - Sql.update("UPDATE_CHECK_ENTITY") - .set("LICENSE_TOKEN", Sql.toString(licenseToken)) - .set("RELEASE_NOTE", Sql.toString(releaseNote)) - .set("VERSION", Sql.toString(version)) - .set("URL_TO_APK", Sql.toString(urlApk)) - .set("URL_TO_RELEASE_NOTE", Sql.toString(urlReleaseNote)) + Sql.update("UPDATE_CHECK_ENTITY") // + .set("LICENSE_TOKEN", Sql.toString(licenseToken)) // + .set("RELEASE_NOTE", Sql.toString(releaseNote)) // + .set("VERSION", Sql.toString(version)) // + .set("URL_TO_APK", Sql.toString(urlApk)) // + .set("URL_TO_RELEASE_NOTE", Sql.toString(urlReleaseNote)) // .executeOn(db) - Upgrade6To7().applyTo(db, 6) + testedUpgrade.migrate(db) Sql.query("UPDATE_CHECK_ENTITY").executeOn(db).use { it.moveToFirst() @@ -307,27 +349,22 @@ class UpgradeDatabaseTest { @Test fun upgrade6To7DueToSQLiteExceptionThrown() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 6) val licenseToken = "licenseToken" - Sql.update("UPDATE_CHECK_ENTITY") - .set("LICENSE_TOKEN", Sql.toString(licenseToken)) - .set("RELEASE_NOTE", Sql.toString("releaseNote")) - .set("VERSION", Sql.toString("version")) - .set("URL_TO_APK", Sql.toString("urlApk")) - .set("URL_TO_RELEASE_NOTE", Sql.toString("urlReleaseNote")) + Sql.update("UPDATE_CHECK_ENTITY") // + .set("LICENSE_TOKEN", Sql.toString(licenseToken)) // + .set("RELEASE_NOTE", Sql.toString("releaseNote")) // + .set("VERSION", Sql.toString("version")) // + .set("URL_TO_APK", Sql.toString("urlApk")) // + .set("URL_TO_RELEASE_NOTE", Sql.toString("urlReleaseNote")) // .executeOn(db) Sql.alterTable("UPDATE_CHECK_ENTITY").renameTo("UPDATE_CHECK_ENTITY_OLD").executeOn(db) Sql.createTable("UPDATE_CHECK_ENTITY") // - .id() // + .pre15Id() // .optionalText("LICENSE_TOKEN") // .optionalText("RELEASE_NOTE") // .optionalText("VERSION") // @@ -336,7 +373,7 @@ class UpgradeDatabaseTest { .optionalText("URL_TO_RELEASE_NOTE") // .executeOn(db) - Upgrade6To7().tryToRecoverFromSQLiteException(db) + testedUpgrade.tryToRecoverFromSQLiteException(db) Sql.query("UPDATE_CHECK_ENTITY").executeOn(db).use { it.moveToFirst() @@ -351,13 +388,7 @@ class UpgradeDatabaseTest { @Test fun upgrade7To8() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 7) Sql.insertInto("CLOUD_ENTITY") // .integer("_id", 15) // @@ -365,7 +396,7 @@ class UpgradeDatabaseTest { .text("URL", "url") // .text("USERNAME", "username") // .text("WEBDAV_CERTIFICATE", "certificate") // - .text("ACCESS_TOKEN", "accessToken") + .text("ACCESS_TOKEN", "accessToken") // .text("S3_BUCKET", "s3Bucket") // .text("S3_REGION", "s3Region") // .text("S3_SECRET_KEY", "s3SecretKey") // @@ -385,7 +416,7 @@ class UpgradeDatabaseTest { Assert.assertThat(it.count, CoreMatchers.`is`(5)) } - Upgrade7To8().applyTo(db, 7) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").executeOn(db).use { Assert.assertThat(it.count, CoreMatchers.`is`(4)) @@ -398,33 +429,18 @@ class UpgradeDatabaseTest { @Test fun upgrade8To9() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 8) sharedPreferencesHandler.setBetaScreenDialogAlreadyShown(true) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) + testedUpgrade.migrate(db) Assert.assertThat(sharedPreferencesHandler.isBetaModeAlreadyShown(), CoreMatchers.`is`(false)) } @Test fun upgrade9To10() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 9) Sql.insertInto("CLOUD_ENTITY") // .integer("_id", 15) // @@ -432,7 +448,7 @@ class UpgradeDatabaseTest { .text("URL", "url") // .text("USERNAME", "username") // .text("WEBDAV_CERTIFICATE", "certificate") // - .text("ACCESS_TOKEN", "accessToken") + .text("ACCESS_TOKEN", "accessToken") // .text("S3_BUCKET", "s3Bucket") // .text("S3_REGION", "s3Region") // .text("S3_SECRET_KEY", "s3SecretKey") // @@ -462,7 +478,7 @@ class UpgradeDatabaseTest { Assert.assertThat(it.count, CoreMatchers.`is`(5)) } - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) + testedUpgrade.migrate(db) Sql.query("VAULT_ENTITY").executeOn(db).use { Assert.assertThat(it.count, CoreMatchers.`is`(1)) @@ -477,16 +493,7 @@ class UpgradeDatabaseTest { @Test fun upgrade10To11EmptyOnedriveCloudRemovesCloud() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 10) Sql.insertInto("VAULT_ENTITY") // .integer("_id", 25) // @@ -502,7 +509,7 @@ class UpgradeDatabaseTest { Assert.assertThat(it.count, CoreMatchers.`is`(3)) } - Upgrade10To11().applyTo(db, 10) + testedUpgrade.migrate(db) Sql.query("VAULT_ENTITY").executeOn(db).use { Assert.assertThat(it.count, CoreMatchers.`is`(1)) @@ -510,7 +517,7 @@ class UpgradeDatabaseTest { Sql.query("VAULT_ENTITY").executeOn(db).use { it.moveToFirst() - Assert.assertThat(it.getString(it.getColumnIndex("FOLDER_CLOUD_ID")), CoreMatchers.`is`("3")) + Assert.assertThat(it.getString(it.getColumnIndex("FOLDER_CLOUD_ID")), CoreMatchers.nullValue()) Assert.assertThat(it.getString(it.getColumnIndex("FOLDER_PATH")), CoreMatchers.`is`("path")) Assert.assertThat(it.getString(it.getColumnIndex("FOLDER_NAME")), CoreMatchers.`is`("name")) Assert.assertThat(it.getString(it.getColumnIndex("CLOUD_TYPE")), CoreMatchers.`is`(CloudType.ONEDRIVE.name)) @@ -527,16 +534,7 @@ class UpgradeDatabaseTest { @Test fun upgrade10To11UsedOnedriveCloudPreservesCloud() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 10) Sql.insertInto("VAULT_ENTITY") // .integer("_id", 25) // @@ -550,10 +548,10 @@ class UpgradeDatabaseTest { Sql.query("CLOUD_ENTITY").executeOn(db).use { while (it.moveToNext()) { - Sql.update("CLOUD_ENTITY") - .where("_id", Sql.eq(3L)) - .set("ACCESS_TOKEN", Sql.toString("Access token 3000")) - .set("USERNAME", Sql.toString("foo@bar.baz")) + Sql.update("CLOUD_ENTITY") // + .where("_id", Sql.eq(3L)) // + .set("ACCESS_TOKEN", Sql.toString("Access token 3000")) // + .set("USERNAME", Sql.toString("foo@bar.baz")) // .executeOn(db) } } @@ -561,7 +559,7 @@ class UpgradeDatabaseTest { Assert.assertThat(it.count, CoreMatchers.`is`(3)) } - Upgrade10To11().applyTo(db, 10) + testedUpgrade.migrate(db) Sql.query("VAULT_ENTITY").executeOn(db).use { Assert.assertThat(it.count, CoreMatchers.`is`(1)) @@ -586,81 +584,40 @@ class UpgradeDatabaseTest { @Test fun upgrade11To12IfOldDefaultSet() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 11) sharedPreferencesHandler.setUpdateIntervalInDays(Optional.of(7)) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + testedUpgrade.migrate(db) Assert.assertThat(sharedPreferencesHandler.updateIntervalInDays(), CoreMatchers.`is`(Optional.of(1))) } @Test fun upgrade11To12MonthlySet() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 11) sharedPreferencesHandler.setUpdateIntervalInDays(Optional.of(30)) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + testedUpgrade.migrate(db) Assert.assertThat(sharedPreferencesHandler.updateIntervalInDays(), CoreMatchers.`is`(Optional.of(1))) } @Test fun upgrade11To12MonthlyNever() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 11) sharedPreferencesHandler.setUpdateIntervalInDays(Optional.absent()) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + testedUpgrade.migrate(db) Assert.assertThat(sharedPreferencesHandler.updateIntervalInDays(), CoreMatchers.`is`(Optional.absent())) } @Test fun upgrade12To13BaseTests() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 12) val gcmCryptor = CredentialCryptor.getInstance(context, CryptoMode.GCM) val cbcCryptor = CredentialCryptor.getInstance(context, CryptoMode.CBC) @@ -705,7 +662,7 @@ class UpgradeDatabaseTest { .integer("SHORTENING_THRESHOLD", 4) .executeOn(db) - Upgrade12To13(context).applyTo(db, 12) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("_id", Sql.eq(15)).executeOn(db).use { it.moveToFirst() @@ -744,18 +701,7 @@ class UpgradeDatabaseTest { @Test fun upgrade12To13DropGoogleDriveUsernameInAccessToken() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 12) Sql.insertInto("CLOUD_ENTITY") // .integer("_id", 15) // @@ -764,7 +710,7 @@ class UpgradeDatabaseTest { .text("ACCESS_TOKEN", "username") // .executeOn(db) - Upgrade12To13(context).applyTo(db, 12) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("_id", Sql.eq(15)).executeOn(db).use { it.moveToFirst() @@ -774,18 +720,7 @@ class UpgradeDatabaseTest { @Test fun upgrade12To13MovingAccessTokenToUrlInLocalStorage() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 12) Sql.insertInto("CLOUD_ENTITY") // .integer("_id", 15) // @@ -793,7 +728,7 @@ class UpgradeDatabaseTest { .text("ACCESS_TOKEN", "testUrl3000") // .executeOn(db) - Upgrade12To13(context).applyTo(db, 12) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("_id", Sql.eq(15)).executeOn(db).use { it.moveToFirst() @@ -804,18 +739,7 @@ class UpgradeDatabaseTest { @Test fun upgrade12To13Dropbox() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 12) val gcmCryptor = CredentialCryptor.getInstance(context, CryptoMode.GCM) val cbcCryptor = CredentialCryptor.getInstance(context, CryptoMode.CBC) @@ -830,7 +754,7 @@ class UpgradeDatabaseTest { .text("ACCESS_TOKEN", accessTokenCiphertext) // .executeOn(db) - Upgrade12To13(context).applyTo(db, 12) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("_id", Sql.eq(15)).executeOn(db).use { it.moveToFirst() @@ -842,18 +766,7 @@ class UpgradeDatabaseTest { @Test fun upgrade12To13OneDrive() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 12) val gcmCryptor = CredentialCryptor.getInstance(context, CryptoMode.GCM) val cbcCryptor = CredentialCryptor.getInstance(context, CryptoMode.CBC) @@ -868,7 +781,7 @@ class UpgradeDatabaseTest { .text("ACCESS_TOKEN", accessTokenCiphertext) // .executeOn(db) - Upgrade12To13(context).applyTo(db, 12) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("_id", Sql.eq(15)).executeOn(db).use { it.moveToFirst() @@ -879,18 +792,7 @@ class UpgradeDatabaseTest { @Test fun upgrade12To13PCloud() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 12) val gcmCryptor = CredentialCryptor.getInstance(context, CryptoMode.GCM) val cbcCryptor = CredentialCryptor.getInstance(context, CryptoMode.CBC) @@ -906,7 +808,7 @@ class UpgradeDatabaseTest { .text("URL", "url") // .executeOn(db) - Upgrade12To13(context).applyTo(db, 12) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("_id", Sql.eq(15)).executeOn(db).use { it.moveToFirst() @@ -917,18 +819,7 @@ class UpgradeDatabaseTest { @Test fun upgrade12To13Webdav() { - Upgrade0To1().applyTo(db, 0) - Upgrade1To2().applyTo(db, 1) - Upgrade2To3(context).applyTo(db, 2) - Upgrade3To4().applyTo(db, 3) - Upgrade4To5().applyTo(db, 4) - Upgrade5To6().applyTo(db, 5) - Upgrade6To7().applyTo(db, 6) - Upgrade7To8().applyTo(db, 7) - Upgrade8To9(sharedPreferencesHandler).applyTo(db, 8) - Upgrade9To10(sharedPreferencesHandler).applyTo(db, 9) - Upgrade10To11().applyTo(db, 10) - Upgrade11To12(sharedPreferencesHandler).applyTo(db, 11) + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 12) val gcmCryptor = CredentialCryptor.getInstance(context, CryptoMode.GCM) val cbcCryptor = CredentialCryptor.getInstance(context, CryptoMode.CBC) @@ -944,7 +835,7 @@ class UpgradeDatabaseTest { .text("URL", "url") // .executeOn(db) - Upgrade12To13(context).applyTo(db, 12) + testedUpgrade.migrate(db) Sql.query("CLOUD_ENTITY").where("_id", Sql.eq(15)).executeOn(db).use { it.moveToFirst() @@ -952,4 +843,186 @@ class UpgradeDatabaseTest { Assert.assertThat(it.getString(it.getColumnIndex("ACCESS_TOKEN_CRYPTO_MODE")), CoreMatchers.`is`(CryptoMode.GCM.name)) } } -} + + @Test + fun migrate13To15ForeignKeySideEffects() { //See: Migration13To14 + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 13) + + val pre14Statement = referencesStatement(db) + val pre14Expected = "CONSTRAINT FK_FOLDER_CLOUD_ID_CLOUD_ENTITY FOREIGN KEY (FOLDER_CLOUD_ID) REFERENCES CLOUD_ENTITY(_id) ON DELETE SET NULL" + //This is a sanity check and may need to be updated if Sql.java is changed + assertTrue("Expected \".*$pre14Expected.*\", got \"$pre14Statement\"", pre14Statement.contains(pre14Expected)) + db.close() + + runMigrationsAndValidate(14, testedUpgrade).also { migratedDb -> + val statement = referencesStatement(migratedDb) + assertEquals(pre14Statement, statement) + } + + runMigrationsAndValidate(15, CryptomatorDatabase_AutoMigration_14_15_Impl()).also { migratedDb -> + val statement = referencesStatement(migratedDb) + val expected = "FOREIGN KEY(folderCloudId) REFERENCES CLOUD_ENTITY(id) ON" + assertTrue("Expected \".*$expected.*\", got \"$statement\"", statement.contains(expected)) + assertFalse(statement.contains("CONSTRAINT")) + assertFalse(statement.contains("FK_FOLDER_CLOUD_ID_CLOUD_ENTITY")) + + assertTrue(statement.contains("ON UPDATE NO ACTION")) + assertTrue(statement.contains("ON DELETE RESTRICT")) + } + } + + private fun referencesStatement(db: SupportSQLiteDatabase): String { + return Sql.SqlQueryBuilder("sqlite_master") // + .columns(listOf("sql")) // + .where("tbl_name", Sql.eq("VAULT_ENTITY")) // + .where("sql", Sql.like("%REFERENCES%")) // + .executeOn(db).use { + assertEquals(it.count, 1) + assertTrue(it.moveToNext()) + it.getString(0) + }.filterNot { it in "\"'`" } + } + + @Test + fun migrate13To15IndexSideEffects() { //See: Migration13To14 + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 13) + + val pre14Statement = indexStatement(db) + val pre14Expected = "CREATE UNIQUE INDEX \"IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID\" ON \"VAULT_ENTITY\" (\"FOLDER_PATH\" ASC,\"FOLDER_CLOUD_ID\" ASC) -- " + //This is a sanity check and may need to be updated if Sql.java is changed + assertEquals(pre14Expected, pre14Statement.substring(0, pre14Statement.length - UUID_LENGTH)) + assertIsUUID(pre14Statement.substring(pre14Statement.length - UUID_LENGTH)) + db.close() + + runMigrationsAndValidate(14, testedUpgrade).also { migratedDb -> + val statement = indexStatement(migratedDb) + assertEquals(pre14Statement, statement) + } + + runMigrationsAndValidate(15, CryptomatorDatabase_AutoMigration_14_15_Impl()).also { migratedDb -> + val statement = indexStatement(migratedDb) + val expected = "CREATE UNIQUE INDEX `IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID` ON `VAULT_ENTITY` (`folderPath` ASC, `folderCloudId` ASC)" + assertEquals(expected, statement) + } + } + + private fun indexStatement(db: SupportSQLiteDatabase): String { + return Sql.SqlQueryBuilder("sqlite_master") // + .columns(listOf("sql")) // + .where("type", Sql.eq("index")) // + .where("name", Sql.eq("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID")) // + .where("tbl_name", Sql.eq("VAULT_ENTITY")) // + .executeOn(db).use { + assertEquals(it.count, 1) + assertTrue(it.moveToNext()) + it.getString(0) + } + } + + @Test + fun migrate13To14() { + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 13) + + assertEquals(13, db.version) + val pre14Tables: Map = listOf("CLOUD_ENTITY", "UPDATE_CHECK_ENTITY", "VAULT_ENTITY").associateWith { tableName -> + val cursor = Sql.query(tableName).executeOn(db) + copyAndClose(cursor) + } + db.close() + + runMigrationsAndValidate(14, testedUpgrade).also { migratedDb -> + assertTrue(migratedDb.hasRoomMasterTable) + assertEquals(14, migratedDb.version) + + for (preTable in pre14Tables) { + preTable.value.use { preCursor -> + Sql.query(preTable.key).executeOn(migratedDb).use { postCursor -> + assertCursorEquals(preCursor, postCursor) + } + } + } + } + } + + @Test + fun migrate13To14WithData() { + val testedUpgrade = migrationContainer.applyPathAndReturnNext(db, 1, 13) + + Sql.insertInto("CLOUD_ENTITY") // + .integer("_id", 3) // + .text("TYPE", CloudType.LOCAL.name) // + .text("URL", "url1") // + .text("USERNAME", "username1") // + .text("WEBDAV_CERTIFICATE", "certificate1") // + .text("ACCESS_TOKEN", "accessToken1") // + .text("S3_BUCKET", "s3Bucket1") // + .text("S3_REGION", "s3Region1") // + .text("S3_SECRET_KEY", "s3SecretKey1") // + .executeOn(db) + + Sql.insertInto("VAULT_ENTITY") // + .integer("_id", 10) // + .integer("FOLDER_CLOUD_ID", 1) // + .text("FOLDER_PATH", "path1") // + .text("FOLDER_NAME", "name1") // + .text("CLOUD_TYPE", CloudType.DROPBOX.name) // + .text("PASSWORD", "password1") // + .integer("POSITION", 10) // + .integer("FORMAT", 42) // + .integer("SHORTENING_THRESHOLD", 110) // + .executeOn(db) + + Sql.insertInto("VAULT_ENTITY") // + .integer("_id", 20) // + .integer("FOLDER_CLOUD_ID", 3) // + .text("FOLDER_PATH", "path2") // + .text("FOLDER_NAME", "name2") // + .text("CLOUD_TYPE", CloudType.LOCAL.name) // + .text("PASSWORD", "password2") // + .integer("POSITION", 20) // + .integer("FORMAT", 43) // + .integer("SHORTENING_THRESHOLD", 120) // + .executeOn(db) + + Sql.update("UPDATE_CHECK_ENTITY") // + .set("LICENSE_TOKEN", Sql.toString("license1")) // + .set("RELEASE_NOTE", Sql.toString("note1")) // + .set("VERSION", Sql.toString("version1")) // + .set("URL_TO_APK", Sql.toString("urlToApk1")) // + .set("APK_SHA256", Sql.toString("sha1")) // + .set("URL_TO_RELEASE_NOTE", Sql.toString("urlToNote1")) // + .executeOn(db) + + assertEquals(13, db.version) + val pre14Tables: Map = listOf("CLOUD_ENTITY", "UPDATE_CHECK_ENTITY", "VAULT_ENTITY").associateWith { tableName -> + copyAndClose(Sql.query(tableName).executeOn(db)) + } + db.close() + + runMigrationsAndValidate(14, testedUpgrade).also { migratedDb -> + assertTrue(migratedDb.hasRoomMasterTable) + assertEquals(14, migratedDb.version) + + for (preTable in pre14Tables) { + preTable.value.use { preCursor -> + Sql.query(preTable.key).executeOn(migratedDb).use { postCursor -> + assertCursorEquals(preCursor, postCursor) + } + } + } + } + } + + //TODO Test metadata of non-entity tables for v14, v15 + //TODO Test metadata and content of entity tables for v15 + + @Test + fun migrate1To14WithRoom() { + db.version = 1 + db.close() + runMigrationsAndValidate( + 14, + *migrationContainer.getPath(1, 14).toTypedArray() + ) + } +} \ No newline at end of file diff --git a/data/src/androidTest/java/org/cryptomator/data/db/templating/TemplateDatabaseContextTest.kt b/data/src/androidTest/java/org/cryptomator/data/db/templating/TemplateDatabaseContextTest.kt new file mode 100644 index 000000000..300ffaea7 --- /dev/null +++ b/data/src/androidTest/java/org/cryptomator/data/db/templating/TemplateDatabaseContextTest.kt @@ -0,0 +1,74 @@ +package org.cryptomator.data.db.templating + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SmallTest +import androidx.test.platform.app.InstrumentationRegistry +import org.cryptomator.data.db.DATABASE_NAME +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File + +@RunWith(AndroidJUnit4::class) +@SmallTest +class TemplateDatabaseContextTest { + + private val baseContext = InstrumentationRegistry.getInstrumentation().context + + @Test(expected = IllegalArgumentException::class) + fun testTempDatabaseContextIllegalName() { + TemplateDatabaseContext(baseContext).getDatabasePath("Database42") + } + + @Test(expected = IllegalArgumentException::class) + fun testTempDatabaseContextIllegalNameFileSet() { + val templateDbContext = TemplateDatabaseContext(baseContext) + templateDbContext.templateFile = File("/test/12345/Database") + templateDbContext.getDatabasePath("Database42") + } + + @Test + fun testTempDatabaseContextTemplateFileNotSet() { + val templateDbContext = TemplateDatabaseContext(baseContext) + assertNull(templateDbContext.templateFile) + } + + @Test(expected = IllegalArgumentException::class) + fun testTempDatabaseContextTemplateFileNotSetThrows() { + val templateDbContext = TemplateDatabaseContext(baseContext) + templateDbContext.getDatabasePath(DATABASE_NAME) + } + + @Test(expected = IllegalArgumentException::class) + fun testTempDatabaseContextFileSetTwice() { + val templateDbContext = TemplateDatabaseContext(baseContext) + assertNull(templateDbContext.templateFile) + + val actualTemplateFile = File("/test/12345/Database") + templateDbContext.templateFile = actualTemplateFile + assertSame(actualTemplateFile, templateDbContext.templateFile) + + templateDbContext.templateFile = File("/test/67890/Throws") + } + + @Test(expected = IllegalArgumentException::class) + fun testTempDatabaseContextFileSetWithNull() { + val templateDbContext = TemplateDatabaseContext(baseContext) + templateDbContext.templateFile = null + } + + @Test + fun testTempDatabaseContext() { + val templateDbContext = TemplateDatabaseContext(baseContext) + assertNull(templateDbContext.templateFile) + + val actualTemplateFile = File("/test/12345/Database") + templateDbContext.templateFile = actualTemplateFile + + val invocation1 = templateDbContext.getDatabasePath(DATABASE_NAME) + assertSame(actualTemplateFile, invocation1) + val invocation2 = templateDbContext.getDatabasePath(DATABASE_NAME) + assertSame(actualTemplateFile, invocation2) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/CloudDao.kt b/data/src/main/java/org/cryptomator/data/db/CloudDao.kt new file mode 100644 index 000000000..4c14e4db5 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/CloudDao.kt @@ -0,0 +1,51 @@ +package org.cryptomator.data.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.cryptomator.data.db.entities.CloudEntity + +@Dao +interface CloudDao { + + @Query("SELECT * FROM CLOUD_ENTITY WHERE id = :id LIMIT 1") + fun load(id: Long): CloudEntity + + @Query("SELECT * from CLOUD_ENTITY") + fun loadAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun storeReplacing(entity: CloudEntity): RowId + + @Query("SELECT * FROM CLOUD_ENTITY WHERE rowid = :rowId") + fun loadFromRowId(rowId: RowId): CloudEntity + + @Transaction + fun storeReplacingAndReload(entity: CloudEntity): CloudEntity { + return loadFromRowId(storeReplacing(entity)) + } + + @Delete + fun delete(entity: CloudEntity) +} + +internal class DelegatingCloudDao(private val database: Invalidatable) : CloudDao { + + private val delegate: CloudDao + get() = database.call().cloudDao() + + override fun load(id: Long): CloudEntity = delegate.load(id) + + override fun loadAll(): List = delegate.loadAll() + + override fun storeReplacing(entity: CloudEntity): RowId = delegate.storeReplacing(entity) + + override fun loadFromRowId(rowId: RowId): CloudEntity = delegate.loadFromRowId(rowId) + + override fun storeReplacingAndReload(entity: CloudEntity): CloudEntity = delegate.storeReplacingAndReload(entity) + + override fun delete(entity: CloudEntity) = delegate.delete(entity) +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/CompoundDatabaseUpgrade.java b/data/src/main/java/org/cryptomator/data/db/CompoundDatabaseUpgrade.java deleted file mode 100755 index a4ab1f220..000000000 --- a/data/src/main/java/org/cryptomator/data/db/CompoundDatabaseUpgrade.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.cryptomator.data.db; - -import java.util.List; - -class CompoundDatabaseUpgrade extends DatabaseUpgrade { - - private final List upgrades; - - public CompoundDatabaseUpgrade(List upgrades) { - super(upgrades.get(0).from(), upgrades.get(upgrades.size() - 1).to()); - this.upgrades = upgrades; - } - - @Override - protected void internalApplyTo(org.greenrobot.greendao.database.Database db, int origin) { - for (DatabaseUpgrade upgrade : upgrades) { - upgrade.applyTo(db, origin); - } - } - -} diff --git a/data/src/main/java/org/cryptomator/data/db/CryptomatorDatabase.kt b/data/src/main/java/org/cryptomator/data/db/CryptomatorDatabase.kt new file mode 100644 index 000000000..ed634b6ae --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/CryptomatorDatabase.kt @@ -0,0 +1,178 @@ +package org.cryptomator.data.db + +import android.database.Cursor +import androidx.room.AutoMigration +import androidx.room.Database +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.entities.CloudEntity +import org.cryptomator.data.db.entities.UpdateCheckEntity +import org.cryptomator.data.db.entities.VaultEntity +import org.cryptomator.data.db.migrations.Sql +import org.cryptomator.data.db.migrations.auto.AutoMigration14To15 +import kotlin.math.max + +const val DATABASE_NAME = "Cryptomator" +const val CRYPTOMATOR_DATABASE_VERSION = 15 + +@Database( + version = CRYPTOMATOR_DATABASE_VERSION, entities = [CloudEntity::class, UpdateCheckEntity::class, VaultEntity::class], autoMigrations = [ + AutoMigration(from = 14, to = 15, spec = AutoMigration14To15::class) + ] +) +abstract class CryptomatorDatabase : RoomDatabase() { + + abstract fun cloudDao(): CloudDao + + abstract fun updateCheckDao(): UpdateCheckDao + + abstract fun vaultDao(): VaultDao +} + +val SupportSQLiteDatabase.foreignKeyConstraintsEnabled: Boolean + get() { + query("PRAGMA foreign_keys;").use { cursor -> + check(cursor.count == 1 && cursor.moveToNext()) { "\"PRAGMA foreign_keys\" returned invalid value" } + return cursor.getLong(0) == 1L + } + } +val SupportSQLiteDatabase.hasRoomMasterTable: Boolean + get() { + return Sql.query("sqlite_master") // + .columns(listOf("count(*)")) // + .where("name", Sql.eq("room_master_table")) // + .executeOn(this) // + .use { cursor -> + cursor.moveToNext() && cursor.getInt(0) == 1 + } + } + +/** + * @param type The type of the value; any of [Cursor.FIELD_TYPE_NULL], [Cursor.FIELD_TYPE_INTEGER], [Cursor.FIELD_TYPE_FLOAT], [Cursor.FIELD_TYPE_STRING] or [Cursor.FIELD_TYPE_BLOB] + * @param value The value matching the type; `null` for [Cursor.FIELD_TYPE_NULL] + */ +data class CursorValue(val type: Int, val value: Any?) { + + init { + require((type == Cursor.FIELD_TYPE_NULL) == (value == null)) + } +} + +fun Cursor.getValue(columnIndex: Int): CursorValue { + require(0 <= columnIndex && columnIndex < this.columnCount) { "Column index $columnIndex outside range 0 <= index < ${this.columnCount}." } + return when (getType(columnIndex)) { + Cursor.FIELD_TYPE_INTEGER -> CursorValue(Cursor.FIELD_TYPE_INTEGER, getLong(columnIndex)) + Cursor.FIELD_TYPE_FLOAT -> CursorValue(Cursor.FIELD_TYPE_FLOAT, getDouble(columnIndex)) + Cursor.FIELD_TYPE_STRING -> CursorValue(Cursor.FIELD_TYPE_STRING, getString(columnIndex)) + Cursor.FIELD_TYPE_BLOB -> CursorValue(Cursor.FIELD_TYPE_BLOB, getBlob(columnIndex)) + Cursor.FIELD_TYPE_NULL -> CursorValue(Cursor.FIELD_TYPE_NULL, null) + else -> throw IllegalArgumentException() + } +} + +fun Cursor.getValueAsString(columnIndex: Int): String { + require(0 <= columnIndex && columnIndex < this.columnCount) { "Column index $columnIndex outside range 0 <= index < ${this.columnCount}." } + return when (getType(columnIndex)) { + Cursor.FIELD_TYPE_INTEGER -> getLong(columnIndex).toString() + Cursor.FIELD_TYPE_FLOAT -> getDouble(columnIndex).toBigDecimal().toPlainString() + Cursor.FIELD_TYPE_STRING -> "\"${getString(columnIndex)}\"" + Cursor.FIELD_TYPE_BLOB -> getBlob(columnIndex).asSequence().map { + it.toUByte().toString(16) + }.joinToString(separator = " ", prefix = "0x") + Cursor.FIELD_TYPE_NULL -> "null" + else -> throw IllegalArgumentException() + } +} + +fun Cursor.equalsCursor(other: Cursor): Boolean { + if (this.count != other.count) { + return false + } + if (this.columnCount != other.columnCount) { + return false + } + if (this.columnNames.uniqueToSet() != other.columnNames.uniqueToSet()) { + return false + } + + val thisPos = this.position + this.moveToPosition(-1) + + val otherPos = other.position + other.moveToPosition(-1) + + while (this.moveToNext()) { + require(other.moveToNext()) + for (name in this.columnNames) { + val valueThis = this.getValue(this.getColumnIndexOrThrow(name)) + val valueOther = other.getValue(other.getColumnIndexOrThrow(name)) + + if (valueThis != valueOther) { + this.moveToPosition(thisPos) + other.moveToPosition(otherPos) + return false + } + } + } + this.moveToPosition(thisPos) + other.moveToPosition(otherPos) + return true +} + +fun Cursor?.stringify(): String { + if (this == null) { + return "" + } + if (this.columnCount == 0) { + return "" + } + if (this.count == 0) { + return this.columnNames.joinToString(" ") + } + val startPos = this.position + this.moveToPosition(-1) + + val columnWidths: MutableMap = this.columnNames.associateWithTo(mutableMapOf()) { it.length } + val values = buildList(this.count * this.columnCount) { + while (moveToNext()) { + for (name in columnNames) { + val value = getValueAsString(getColumnIndexOrThrow(name)) + columnWidths.compute(name) { _, currentWidth: Int? -> max(currentWidth!!, value.length) } + add(value) + } + } + } + + this.moveToPosition(startPos) + val stringifiedRowCount = this.count + 1 /* Header */ + val rowCapacity = columnWidths.values.sum() + this.columnCount /* V-Spaces */ // - 1 (V-Spaces) + 1 (Line breaks) + val capacity = stringifiedRowCount * rowCapacity // + 1 (H-Space) - 1 (No Line break in last line) + return buildString(capacity) { + appendLine(columnNames.asSequence().map { it.padEnd(columnWidths[it]!!) }.joinToString(" ")) + appendLine() + values.forEachIndexed { i: Int, value: String -> + append(value.padEnd(columnWidths[columnNames[i % columnCount]]!!)) + if ((i == values.size - 1)) { + //Last element + return@buildString + } + if ((i + 1) % columnCount == 0) { + //Last element in line + appendLine() + } else { + append(" ") + } + } + } +} + +private fun Array.uniqueToSet(): Set = toSet().also { + require(this.size == it.size) { "Array contained ${this.size - it.size} duplicate elements." } +} + +fun SupportSQLiteDatabase.applyDefaultConfiguration(assertedWalEnabledStatus: Boolean) { + require(isWriteAheadLoggingEnabled == assertedWalEnabledStatus) { + "Expected WAL enabled status to be $assertedWalEnabledStatus for \"${path}\", but was $isWriteAheadLoggingEnabled" + } + setForeignKeyConstraintsEnabled(true) +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/Database.java b/data/src/main/java/org/cryptomator/data/db/Database.java deleted file mode 100644 index 7f24da91d..000000000 --- a/data/src/main/java/org/cryptomator/data/db/Database.java +++ /dev/null @@ -1,53 +0,0 @@ -package org.cryptomator.data.db; - -import org.cryptomator.data.db.entities.DaoMaster; -import org.cryptomator.data.db.entities.DaoSession; -import org.cryptomator.data.db.entities.DatabaseEntity; - -import java.util.List; - -import javax.inject.Inject; -import javax.inject.Singleton; - -@Singleton -public class Database { - - private final DaoSession daoSession; - - @Inject - public Database(DatabaseFactory databaseFactory) { - DaoMaster daoMaster = new DaoMaster(databaseFactory.getWritableDatabase()); - daoSession = daoMaster.newSession(); - } - - public T load(Class type, long id) { - return daoSession.load(type, id); - } - - public void delete(T entity) { - daoSession.delete(entity); - } - - public List loadAll(Class type) { - return daoSession.loadAll(type); - } - - public T create(T entity) { - long id = daoSession.insert(entity); - return load((Class) entity.getClass(), id); - } - - public T store(T entity) { - Long id = entity.getId(); - if (id == null) { - id = daoSession.insert(entity); - } else { - daoSession.update(entity); - } - return load((Class) entity.getClass(), id); - } - - public void clearCache() { - daoSession.clear(); - } -} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseAutoMigrationSpec.kt b/data/src/main/java/org/cryptomator/data/db/DatabaseAutoMigrationSpec.kt new file mode 100644 index 000000000..79de9fb4c --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseAutoMigrationSpec.kt @@ -0,0 +1,18 @@ +package org.cryptomator.data.db + +import androidx.room.migration.AutoMigrationSpec +import androidx.sqlite.db.SupportSQLiteDatabase +import timber.log.Timber + + +abstract class DatabaseAutoMigrationSpec : AutoMigrationSpec { + + final override fun onPostMigrate(db: SupportSQLiteDatabase) { + Timber.tag("DatabaseMigration").i("Ran automatic migration %s", javaClass.simpleName) + require(db.foreignKeyConstraintsEnabled) + onPostMigrateInternal(db) + } + + protected open fun onPostMigrateInternal(db: SupportSQLiteDatabase) {} + +} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseFactory.java b/data/src/main/java/org/cryptomator/data/db/DatabaseFactory.java deleted file mode 100644 index 36cea49ae..000000000 --- a/data/src/main/java/org/cryptomator/data/db/DatabaseFactory.java +++ /dev/null @@ -1,57 +0,0 @@ -package org.cryptomator.data.db; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; - -import org.cryptomator.data.db.entities.DaoMaster; -import org.greenrobot.greendao.database.Database; - -import javax.inject.Inject; -import javax.inject.Singleton; - -import timber.log.Timber; - -import static org.cryptomator.data.db.entities.DaoMaster.SCHEMA_VERSION; - -@Singleton -class DatabaseFactory extends DaoMaster.OpenHelper { - - private static final String DATABASE_NAME = "Cryptomator"; - - private final DatabaseUpgrades databaseUpgrades; - - @Inject - public DatabaseFactory(Context context, DatabaseUpgrades databaseUpgrades) { - super(context, DATABASE_NAME); - this.databaseUpgrades = databaseUpgrades; - } - - @Override - public void onConfigure(SQLiteDatabase db) { - super.onConfigure(db); - - Timber.tag("Database").i("Configure v%d", db.getVersion()); - - if (!db.isReadOnly()) { - db.setForeignKeyConstraintsEnabled(true); - } - } - - @Override - public void onCreate(Database db) { - Timber.tag("Database").i("Create v%s", SCHEMA_VERSION); - databaseUpgrades.getUpgrade(0, SCHEMA_VERSION).applyTo(db, 0); - } - - @Override - public void onUpgrade(Database db, int oldVersion, int newVersion) { - Timber.tag("Database").i("Upgrade v" + oldVersion + " to v" + newVersion); - databaseUpgrades.getUpgrade(oldVersion, newVersion).applyTo(db, oldVersion); - } - - @Override - public void onOpen(SQLiteDatabase db) { - super.onOpen(db); - Timber.tag("Database").i("Open v%s", db.getVersion()); - } -} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseMigration.kt b/data/src/main/java/org/cryptomator/data/db/DatabaseMigration.kt new file mode 100644 index 000000000..59d248359 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseMigration.kt @@ -0,0 +1,18 @@ +package org.cryptomator.data.db + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import timber.log.Timber + + +abstract class DatabaseMigration(startVersion: Int, endVersion: Int) : Migration(startVersion, endVersion) { + + final override fun migrate(database: SupportSQLiteDatabase) { + Timber.tag("DatabaseMigration").i("Running %s (%d -> %d)", javaClass.simpleName, startVersion, endVersion) + require(database.foreignKeyConstraintsEnabled) + migrateInternal(database) + } + + protected abstract fun migrateInternal(db: SupportSQLiteDatabase) + +} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseModule.kt b/data/src/main/java/org/cryptomator/data/db/DatabaseModule.kt new file mode 100644 index 000000000..6427263f6 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseModule.kt @@ -0,0 +1,153 @@ +package org.cryptomator.data.db + +import android.content.Context +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import org.cryptomator.data.db.SQLiteCacheControl.asCacheControlled +import org.cryptomator.data.db.migrations.MigrationContainer +import org.cryptomator.data.db.templating.DbTemplateComponent +import org.cryptomator.util.ThreadUtil +import org.cryptomator.util.named +import java.io.File +import java.io.IOException +import java.io.InputStream +import java.util.concurrent.Callable +import javax.inject.Named +import javax.inject.Provider +import javax.inject.Qualifier +import javax.inject.Singleton +import dagger.Module +import dagger.Provides +import timber.log.Timber + +private val LOG = Timber.Forest.named("DatabaseModule") + +@Module(subcomponents = [DbTemplateComponent::class]) +class DatabaseModule { + + @Singleton + @Provides + fun provideCryptomatorDatabase(@DbInternal delegate: Provider): Invalidatable = Invalidatable { + delegate.get() + } + + @Singleton + @Provides + @Named("databaseInvalidationCallback") + fun provideInvalidationCallback(invalidatable: Invalidatable): Function0 { + return invalidatable::invalidate + } + + @Provides + @DbInternal + internal fun provideInternalCryptomatorDatabase( + context: Context, // + @DbInternal migrations: Array, // + @DbInternal dbTemplateStreamCallable: Callable, // + @DbInternal openHelperFactory: SupportSQLiteOpenHelper.Factory, // + @DbInternal databaseName: String, // + ): CryptomatorDatabase { + LOG.i("Building database (target version: %s)", CRYPTOMATOR_DATABASE_VERSION) + ThreadUtil.assumeNotMainThread() + return Room.databaseBuilder(context, CryptomatorDatabase::class.java, databaseName) // + .createFromInputStream(dbTemplateStreamCallable) // + .addMigrations(*migrations) // + .addCallback(DatabaseCallback) // + .openHelperFactory(openHelperFactory) // + .setJournalMode(RoomDatabase.JournalMode.TRUNCATE) // + .fallbackToDestructiveMigrationFrom(0) // + .build() //Fails if no migration is found (especially when downgrading) + .also { // + //Migrations are only triggered once the database is used for the first time. + //-- Let's do that now and verify all went well before returning the database. + it.openHelper.writableDatabase.run { + require(this.version == CRYPTOMATOR_DATABASE_VERSION) + require(this.foreignKeyConstraintsEnabled) + } + LOG.i("Database built successfully") + } + } + + @Singleton + @Provides + @DbInternal + fun provideDbTemplateStreamCallable(templateFactory: DbTemplateComponent.Factory): Callable = Callable { + LOG.d("Creating database template stream") + try { + return@Callable templateFactory.create().templateStream() + } catch (t: Throwable) { + if (t !is IOException) { + throw t + } + LOG.w(t, "IOException while reading database template, retrying...") + try { + return@Callable templateFactory.create().templateStream() + } catch (tInner: Throwable) { + tInner.addSuppressed(t) + throw tInner + } + } + } + + @Singleton + @Provides + @DbInternal + internal fun provideOpenHelperFactory(openHelperFactory: DatabaseOpenHelperFactory): SupportSQLiteOpenHelper.Factory { + return openHelperFactory.asCacheControlled() + } + + @Singleton + @Provides + @DbInternal + internal fun provideDatabaseName(): String = DATABASE_NAME + + @Singleton + @Provides + fun provideCloudDao(database: Invalidatable): CloudDao { + return DelegatingCloudDao(database) + } + + @Singleton + @Provides + fun provideUpdateCheckDao(database: Invalidatable): UpdateCheckDao { + return DelegatingUpdateCheckDao(database) + } + + @Singleton + @Provides + fun provideVaultDao(database: Invalidatable): VaultDao { + return DelegatingVaultDao(database) + } + + @Singleton + @Provides + @DbInternal + internal fun provideMigrations(migrationContainer: MigrationContainer): Array { + return migrationContainer.getPath(1).toTypedArray() + } +} + +object DatabaseCallback : RoomDatabase.Callback() { + + override fun onCreate(db: SupportSQLiteDatabase) { + //This should not be called except if there was corruption and the recovery in CopyOpenHelper failed; in that case PatchedCallback will invalidate the db + throw UnsupportedOperationException("Creation is handled as upgrade") + } + + override fun onOpen(db: SupportSQLiteDatabase) { + LOG.i("Opened database (v%s)", db.version) + } + + override fun onDestructiveMigration(db: SupportSQLiteDatabase) { + //This should not be called + throw UnsupportedOperationException("Destructive migration is not supported") + } +} + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +private annotation class DbInternal \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseOpenHelperFactory.kt b/data/src/main/java/org/cryptomator/data/db/DatabaseOpenHelperFactory.kt new file mode 100644 index 000000000..2177a0e09 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/DatabaseOpenHelperFactory.kt @@ -0,0 +1,114 @@ +package org.cryptomator.data.db + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import org.cryptomator.data.util.useFinally +import org.cryptomator.util.named +import java.io.File +import javax.inject.Inject +import javax.inject.Named +import javax.inject.Singleton +import timber.log.Timber + +private val LOG = Timber.Forest.named("DatabaseOpenHelperFactory") + +//This needs to stay in sync with UpgradeDatabaseTest#setup +@Singleton +internal class DatabaseOpenHelperFactory( + private val invalidationCallback: Function0, // + private val delegate: SupportSQLiteOpenHelper.Factory +) : SupportSQLiteOpenHelper.Factory { + + @Inject + constructor(@Named("databaseInvalidationCallback") invalidationCallback: Function0) : this(invalidationCallback, FrameworkSQLiteOpenHelperFactory()) + + override fun create(configuration: SupportSQLiteOpenHelper.Configuration): SupportSQLiteOpenHelper { + LOG.d("Creating SupportSQLiteOpenHelper for database \"${configuration.name}\"") + return delegate.create(patchConfiguration(invalidationCallback, configuration)) + } +} + +private fun patchConfiguration(invalidationCallback: Function0, configuration: SupportSQLiteOpenHelper.Configuration): SupportSQLiteOpenHelper.Configuration { + return SupportSQLiteOpenHelper.Configuration( + context = configuration.context, + name = configuration.name, + callback = PatchedCallback(invalidationCallback, configuration.callback), + useNoBackupDirectory = configuration.useNoBackupDirectory, + allowDataLossOnRecovery = configuration.allowDataLossOnRecovery + ) +} + +private class PatchedCallback( + private val invalidationCallback: Function0, + private val delegateCallback: SupportSQLiteOpenHelper.Callback, +) : SupportSQLiteOpenHelper.Callback(delegateCallback.version) { + + override fun onConfigure(db: SupportSQLiteDatabase) { + LOG.d("Called onConfigure for \"${db.path}\"@${db.version}") + require(!db.isWriteAheadLoggingEnabled) { "WAL for \"${db.path}\" should already be disabled by room" } + db.applyDefaultConfiguration( // + assertedWalEnabledStatus = false //WAL is handled by Room + ) + // + delegateCallback.onConfigure(db) + // + } + + override fun onCreate(db: SupportSQLiteDatabase) { + //This should not be called except if there was corruption and the recovery in CopyOpenHelper failed; in that case invalidate the db + LOG.e(Exception(), "Called onCreate for \"${db.path}\"@${db.version}") + invalidationCallback.invoke() + // + delegateCallback.onCreate(db) //Callback from DatabaseModule will throw here + // + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { + LOG.i("Called onUpgrade for \"${db.path}\"@${db.version} ($oldVersion -> $newVersion)") + // + delegateCallback.onUpgrade(db, oldVersion, newVersion) + // + } + + override fun onDowngrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { + LOG.e(Exception(), "Called onDowngrade for \"${db.path}\"@${db.version} ($oldVersion -> $newVersion)") + // + delegateCallback.onDowngrade(db, oldVersion, newVersion) + // + } + + override fun onCorruption(db: SupportSQLiteDatabase) = useFinally({ + LOG.e(Exception(), "Called onCorruption for \"${db.path}\"") + // + delegateCallback.onCorruption(db) + // + }, finallyBlock = { + invalidationCallback.invoke() + + logCorruptedDbState(db) + }) + + private fun logCorruptedDbState(db: SupportSQLiteDatabase) { + val state = db.path?.let { path -> + if (path == ":memory:") null else path + }.runCatching { + this?.let { path -> (File(path).exists()) } + }.map { + when (it) { + null -> "In memory" + true -> "Exists" + false -> "Deleted" + } + }.onFailure { verificationFailure -> + LOG.e(verificationFailure, "Couldn't verify state of database \"${db.path}\"") + }.getOrDefault("Unknown (see above)") + LOG.e(Exception(), "State of \"${db.path}\": $state") + } + + override fun onOpen(db: SupportSQLiteDatabase) { + // + delegateCallback.onOpen(db) + // + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrade.java b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrade.java deleted file mode 100644 index da2fb34df..000000000 --- a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrade.java +++ /dev/null @@ -1,41 +0,0 @@ -package org.cryptomator.data.db; - -import org.greenrobot.greendao.database.Database; - -import timber.log.Timber; - -abstract class DatabaseUpgrade implements Comparable { - - private final int from; - private final int to; - - DatabaseUpgrade(int from, int to) { - this.from = from; - this.to = to; - } - - public int from() { - return from; - } - - public int to() { - return to; - } - - @Override - public int compareTo(DatabaseUpgrade other) { - int compareByFrom = from - other.from; - if (compareByFrom != 0) { - return compareByFrom; - } - return to - other.to; - } - - final void applyTo(Database db, int origin) { - Timber.tag("DatabaseUpgrade").i("Running %s (%d -> %d)", getClass().getSimpleName(), from, to); - internalApplyTo(db, origin); - } - - protected abstract void internalApplyTo(Database db, int origin); - -} diff --git a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java b/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java deleted file mode 100644 index 52401e64f..000000000 --- a/data/src/main/java/org/cryptomator/data/db/DatabaseUpgrades.java +++ /dev/null @@ -1,96 +0,0 @@ -package org.cryptomator.data.db; - -import static java.lang.String.format; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -import javax.inject.Inject; -import javax.inject.Singleton; - -@Singleton -class DatabaseUpgrades { - - private final Map> availableUpgrades; - - @Inject - public DatabaseUpgrades( // - Upgrade0To1 upgrade0To1, // - Upgrade1To2 upgrade1To2, // - Upgrade2To3 upgrade2To3, // - Upgrade3To4 upgrade3To4, // - Upgrade4To5 upgrade4To5, // - Upgrade5To6 upgrade5To6, // - Upgrade6To7 upgrade6To7, // - Upgrade7To8 upgrade7To8, // - Upgrade8To9 upgrade8To9, // - Upgrade9To10 upgrade9To10, // - Upgrade10To11 upgrade10To11, // - Upgrade11To12 upgrade11To12, // - Upgrade12To13 upgrade12To13 - ) { - - availableUpgrades = defineUpgrades( // - upgrade0To1, // - upgrade1To2, // - upgrade2To3, // - upgrade3To4, // - upgrade4To5, // - upgrade5To6, // - upgrade6To7, // - upgrade7To8, // - upgrade8To9, // - upgrade9To10, // - upgrade10To11, // - upgrade11To12, // - upgrade12To13); - } - - private Map> defineUpgrades(DatabaseUpgrade... upgrades) { - Map> result = new HashMap<>(); - for (DatabaseUpgrade upgrade : upgrades) { - if (!result.containsKey(upgrade.from())) { - result.put(upgrade.from(), new ArrayList<>()); - } - result.get(upgrade.from()).add(upgrade); - } - for (List list : result.values()) { - Collections.sort(list, Comparator.reverseOrder()); - } - return result; - } - - public DatabaseUpgrade getUpgrade(int oldVersion, int newVersion) { - List upgrades = new ArrayList<>(10); - if (!findUpgrades(upgrades, oldVersion, newVersion)) { - throw new IllegalStateException(format("No upgrade path from %d to %d", oldVersion, newVersion)); - } - return new CompoundDatabaseUpgrade(upgrades); - } - - private boolean findUpgrades(List upgrades, int oldVersion, int newVersion) { - if (oldVersion == newVersion) { - return true; - } - - List upgradesFromOldVersion = availableUpgrades.get(oldVersion); - if (upgradesFromOldVersion == null) { - return false; - } - for (DatabaseUpgrade upgrade : upgradesFromOldVersion) { - if (upgrade.to() > newVersion) { - continue; - } - upgrades.add(upgrade); - if (findUpgrades(upgrades, upgrade.to(), newVersion)) { - return true; - } - upgrades.remove(upgrades.size() - 1); - } - return false; - } -} diff --git a/data/src/main/java/org/cryptomator/data/db/Invalidatable.kt b/data/src/main/java/org/cryptomator/data/db/Invalidatable.kt new file mode 100644 index 000000000..d218b8266 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/Invalidatable.kt @@ -0,0 +1,36 @@ +package org.cryptomator.data.db + +import java.util.concurrent.Callable + +/** + * Instances of this class are thread-safe [Callables][Callable] that cache their results after the first [call,][call] similar to [Lazy.][Lazy] + * However, they allow the stored result to be invalidated with [invalidate,][invalidate] after which the result is recalculated the next time [call] is invoked. + */ +class Invalidatable(private val delegate: Callable) : Callable { + + @Volatile + private var instance: Any? = UNINITIALIZED + + override fun call(): T { + synchronized(this) { + if (instance === UNINITIALIZED) { + instance = delegate.call() + } + @Suppress("UNCHECKED_CAST") // + return instance as T + } + } + + fun invalidate() { + synchronized(this) { + instance = UNINITIALIZED + } + } + + companion object { + + private val UNINITIALIZED = object { + override fun toString(): String = "UNINITIALIZED" + } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/RowId.kt b/data/src/main/java/org/cryptomator/data/db/RowId.kt new file mode 100644 index 000000000..d1429964d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/RowId.kt @@ -0,0 +1,3 @@ +package org.cryptomator.data.db + +typealias RowId = Long \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/SQLiteCacheControl.kt b/data/src/main/java/org/cryptomator/data/db/SQLiteCacheControl.kt new file mode 100644 index 000000000..b0b230f24 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/SQLiteCacheControl.kt @@ -0,0 +1,40 @@ +package org.cryptomator.data.db + +import android.database.Cursor +import androidx.sqlite.db.SupportSQLiteOpenHelper +import org.cryptomator.data.db.sqlmapping.SQLMappingFunction +import org.cryptomator.data.db.sqlmapping.asMapped +import java.util.UUID + +object SQLiteCacheControl { + + object RandomUUIDMapping : SQLMappingFunction { + + private val newIdentifier: String + get() = UUID.randomUUID().toString() + + override fun map(sql: String): String { + return "$sql -- $newIdentifier" + } + + override fun mapWhereClause(whereClause: String?): String { + return map(whereClause ?: "1") + } + + override fun mapCursor(cursor: Cursor): Cursor { + return NoRequeryCursor(cursor) + } + } + + fun SupportSQLiteOpenHelper.Factory.asCacheControlled(): SupportSQLiteOpenHelper.Factory = asMapped(RandomUUIDMapping) +} + +private class NoRequeryCursor( + private val delegateCursor: Cursor +) : Cursor by delegateCursor { + + @Deprecated("Deprecated in Java") + override fun requery(): Boolean { + throw UnsupportedOperationException() + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/UpdateCheckDao.kt b/data/src/main/java/org/cryptomator/data/db/UpdateCheckDao.kt new file mode 100644 index 000000000..e95fbd3eb --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/UpdateCheckDao.kt @@ -0,0 +1,52 @@ +package org.cryptomator.data.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.cryptomator.data.db.entities.UpdateCheckEntity + +@Dao +interface UpdateCheckDao { + + @Query("SELECT * FROM UPDATE_CHECK_ENTITY WHERE id = :id LIMIT 1") + fun load(id: Long): UpdateCheckEntity + + @Query("SELECT * from UPDATE_CHECK_ENTITY") + fun loadAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun storeReplacing(entity: UpdateCheckEntity): RowId + + @Query("SELECT * FROM UPDATE_CHECK_ENTITY WHERE rowid = :rowId") + fun loadFromRowId(rowId: RowId): UpdateCheckEntity + + @Transaction + fun storeReplacingAndReload(entity: UpdateCheckEntity): UpdateCheckEntity { + return loadFromRowId(storeReplacing(entity)) + } + + @Delete + fun delete(entity: UpdateCheckEntity) + +} + +internal class DelegatingUpdateCheckDao(private val database: Invalidatable) : UpdateCheckDao { + + private val delegate: UpdateCheckDao + get() = database.call().updateCheckDao() + + override fun load(id: Long): UpdateCheckEntity = delegate.load(id) + + override fun loadAll(): List = delegate.loadAll() + + override fun storeReplacing(entity: UpdateCheckEntity): RowId = delegate.storeReplacing(entity) + + override fun loadFromRowId(rowId: RowId): UpdateCheckEntity = delegate.loadFromRowId(rowId) + + override fun storeReplacingAndReload(entity: UpdateCheckEntity): UpdateCheckEntity = delegate.storeReplacingAndReload(entity) + + override fun delete(entity: UpdateCheckEntity) = delegate.delete(entity) +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade7To8.kt b/data/src/main/java/org/cryptomator/data/db/Upgrade7To8.kt deleted file mode 100644 index 5f1a65cc1..000000000 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade7To8.kt +++ /dev/null @@ -1,32 +0,0 @@ -package org.cryptomator.data.db - -import org.greenrobot.greendao.database.Database -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -internal class Upgrade7To8 @Inject constructor() : DatabaseUpgrade(7, 8) { - - override fun internalApplyTo(db: Database, origin: Int) { - db.beginTransaction() - try { - dropS3Vaults(db) - dropS3Clouds(db) - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - } - - private fun dropS3Vaults(db: Database) { - Sql.deleteFrom("VAULT_ENTITY") // - .where("CLOUD_TYPE", Sql.eq("S3")) - .executeOn(db) - } - - private fun dropS3Clouds(db: Database) { - Sql.deleteFrom("CLOUD_ENTITY") // - .where("TYPE", Sql.eq("S3")) - .executeOn(db) - } -} diff --git a/data/src/main/java/org/cryptomator/data/db/VaultDao.kt b/data/src/main/java/org/cryptomator/data/db/VaultDao.kt new file mode 100644 index 000000000..f0061f7d1 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/VaultDao.kt @@ -0,0 +1,51 @@ +package org.cryptomator.data.db + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.cryptomator.data.db.entities.VaultEntity + +@Dao +interface VaultDao { + + @Query("SELECT * FROM VAULT_ENTITY WHERE id = :id LIMIT 1") + fun load(id: Long): VaultEntity + + @Query("SELECT * from VAULT_ENTITY") + fun loadAll(): List + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun storeReplacing(entity: VaultEntity): RowId + + @Query("SELECT * FROM VAULT_ENTITY WHERE rowid = :rowId") + fun loadFromRowId(rowId: RowId): VaultEntity + + @Transaction + fun storeReplacingAndReload(entity: VaultEntity): VaultEntity { + return loadFromRowId(storeReplacing(entity)) + } + + @Delete + fun delete(entity: VaultEntity) +} + +internal class DelegatingVaultDao(private val database: Invalidatable) : VaultDao { + + private val delegate: VaultDao + get() = database.call().vaultDao() + + override fun load(id: Long): VaultEntity = delegate.load(id) + + override fun loadAll(): List = delegate.loadAll() + + override fun storeReplacing(entity: VaultEntity): RowId = delegate.storeReplacing(entity) + + override fun loadFromRowId(rowId: RowId): VaultEntity = delegate.loadFromRowId(rowId) + + override fun storeReplacingAndReload(entity: VaultEntity): VaultEntity = delegate.storeReplacingAndReload(entity) + + override fun delete(entity: VaultEntity) = delegate.delete(entity) +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java deleted file mode 100644 index 385898bc9..000000000 --- a/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.java +++ /dev/null @@ -1,142 +0,0 @@ -package org.cryptomator.data.db.entities; - -import org.greenrobot.greendao.annotation.Entity; -import org.greenrobot.greendao.annotation.Generated; -import org.greenrobot.greendao.annotation.Id; -import org.greenrobot.greendao.annotation.NotNull; - -@Entity -public class CloudEntity extends DatabaseEntity { - - @Id - private Long id; - - @NotNull - private String type; - - private String accessToken; - - private String accessTokenCryptoMode; - - private String url; - - private String username; - - private String webdavCertificate; - - private String s3Bucket; - - private String s3Region; - - private String s3SecretKey; - - private String s3SecretKeyCryptoMode; - - @Generated(hash = 930663276) - public CloudEntity(Long id, @NotNull String type, String accessToken, String accessTokenCryptoMode, String url, String username, String webdavCertificate, String s3Bucket, - String s3Region, String s3SecretKey, String s3SecretKeyCryptoMode) { - this.id = id; - this.type = type; - this.accessToken = accessToken; - this.accessTokenCryptoMode = accessTokenCryptoMode; - this.url = url; - this.username = username; - this.webdavCertificate = webdavCertificate; - this.s3Bucket = s3Bucket; - this.s3Region = s3Region; - this.s3SecretKey = s3SecretKey; - this.s3SecretKeyCryptoMode = s3SecretKeyCryptoMode; - } - - @Generated(hash = 1354152224) - public CloudEntity() { - } - - public String getAccessToken() { - return this.accessToken; - } - - public void setAccessToken(String accessToken) { - this.accessToken = accessToken; - } - - public String getType() { - return this.type; - } - - public void setType(String type) { - this.type = type; - } - - public Long getId() { - return this.id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getUrl() { - return url; - } - - public void setUrl(String url) { - this.url = url; - } - - public String getUsername() { - return username; - } - - public void setUsername(String username) { - this.username = username; - } - - public String getWebdavCertificate() { - return webdavCertificate; - } - - public void setWebdavCertificate(String webdavCertificate) { - this.webdavCertificate = webdavCertificate; - } - - public String getS3Bucket() { - return this.s3Bucket; - } - - public void setS3Bucket(String s3Bucket) { - this.s3Bucket = s3Bucket; - } - - public String getS3Region() { - return this.s3Region; - } - - public void setS3Region(String s3Region) { - this.s3Region = s3Region; - } - - public String getS3SecretKey() { - return this.s3SecretKey; - } - - public void setS3SecretKey(String s3SecretKey) { - this.s3SecretKey = s3SecretKey; - } - - public String getAccessTokenCryptoMode() { - return this.accessTokenCryptoMode; - } - - public void setAccessTokenCryptoMode(String accessTokenCryptoMode) { - this.accessTokenCryptoMode = accessTokenCryptoMode; - } - - public String getS3SecretKeyCryptoMode() { - return this.s3SecretKeyCryptoMode; - } - - public void setS3SecretKeyCryptoMode(String s3SecretKeyCryptoMode) { - this.s3SecretKeyCryptoMode = s3SecretKeyCryptoMode; - } -} diff --git a/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.kt b/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.kt new file mode 100644 index 000000000..43d859fdc --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/CloudEntity.kt @@ -0,0 +1,26 @@ +package org.cryptomator.data.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "CLOUD_ENTITY") +data class CloudEntity( + @PrimaryKey override var id: Long?, + var type: String, + var accessToken: String? = null, + var accessTokenCryptoMode: String? = null, + var url: String? = null, + var username: String? = null, + var webdavCertificate: String? = null, + var s3Bucket: String? = null, + var s3Region: String? = null, + var s3SecretKey: String? = null, + var s3SecretKeyCryptoMode: String? = null, +) : DatabaseEntity { + + companion object { + + @JvmStatic + fun newEntity(id: Long?, name: String): CloudEntity = CloudEntity(id, name) + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.java deleted file mode 100755 index c40978e34..000000000 --- a/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.cryptomator.data.db.entities; - -public abstract class DatabaseEntity { - - DatabaseEntity() { - } - - public abstract Long getId(); - -} diff --git a/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.kt b/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.kt new file mode 100644 index 000000000..e02330d4f --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/DatabaseEntity.kt @@ -0,0 +1,6 @@ +package org.cryptomator.data.db.entities + +interface DatabaseEntity { + + val id: Long? +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java deleted file mode 100644 index aec5f36bc..000000000 --- a/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.java +++ /dev/null @@ -1,95 +0,0 @@ -package org.cryptomator.data.db.entities; - -import org.greenrobot.greendao.annotation.Entity; -import org.greenrobot.greendao.annotation.Generated; -import org.greenrobot.greendao.annotation.Id; - -@Entity -public class UpdateCheckEntity extends DatabaseEntity { - - @Id - private Long id; - - private String licenseToken; - - private String releaseNote; - - private String version; - - private String urlToApk; - - private String apkSha256; - - private String urlToReleaseNote; - - public UpdateCheckEntity() { - } - - @Generated(hash = 67239496) - public UpdateCheckEntity(Long id, String licenseToken, String releaseNote, String version, String urlToApk, String apkSha256, String urlToReleaseNote) { - this.id = id; - this.licenseToken = licenseToken; - this.releaseNote = releaseNote; - this.version = version; - this.urlToApk = urlToApk; - this.apkSha256 = apkSha256; - this.urlToReleaseNote = urlToReleaseNote; - } - - @Override - public Long getId() { - return id; - } - - public void setId(Long id) { - this.id = id; - } - - public String getLicenseToken() { - return this.licenseToken; - } - - public void setLicenseToken(String licenseToken) { - this.licenseToken = licenseToken; - } - - public String getVersion() { - return this.version; - } - - public void setVersion(String version) { - this.version = version; - } - - public String getUrlToApk() { - return this.urlToApk; - } - - public void setUrlToApk(String urlToApk) { - this.urlToApk = urlToApk; - } - - public String getReleaseNote() { - return this.releaseNote; - } - - public void setReleaseNote(String releaseNote) { - this.releaseNote = releaseNote; - } - - public String getUrlToReleaseNote() { - return this.urlToReleaseNote; - } - - public void setUrlToReleaseNote(String urlToReleaseNote) { - this.urlToReleaseNote = urlToReleaseNote; - } - - public String getApkSha256() { - return this.apkSha256; - } - - public void setApkSha256(String apkSha256) { - this.apkSha256 = apkSha256; - } -} diff --git a/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.kt b/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.kt new file mode 100644 index 000000000..056404388 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/UpdateCheckEntity.kt @@ -0,0 +1,15 @@ +package org.cryptomator.data.db.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "UPDATE_CHECK_ENTITY") +data class UpdateCheckEntity( + @PrimaryKey override var id: Long?, + var licenseToken: String?, + var releaseNote: String?, + var version: String?, + var urlToApk: String?, + var apkSha256: String?, + var urlToReleaseNote: String?, +) : DatabaseEntity \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.java b/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.java deleted file mode 100644 index e23220c60..000000000 --- a/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.java +++ /dev/null @@ -1,228 +0,0 @@ -package org.cryptomator.data.db.entities; - -import org.greenrobot.greendao.DaoException; -import org.greenrobot.greendao.annotation.Entity; -import org.greenrobot.greendao.annotation.Generated; -import org.greenrobot.greendao.annotation.Id; -import org.greenrobot.greendao.annotation.Index; -import org.greenrobot.greendao.annotation.NotNull; -import org.greenrobot.greendao.annotation.ToOne; - -@Entity(indexes = {@Index(value = "folderPath,folderCloudId", unique = true)}) -public class VaultEntity extends DatabaseEntity { - - @Id - private Long id; - - private Long folderCloudId; - - @ToOne(joinProperty = "folderCloudId") - private CloudEntity folderCloud; - - private String folderPath; - - private String folderName; - - @NotNull - private String cloudType; - - private String password; - - private String passwordCryptoMode; - - private Integer position; - - private Integer format; - - private Integer shorteningThreshold; - - /** - * Used for active entity operations. - */ - @Generated(hash = 941685503) - private transient VaultEntityDao myDao; - /** - * Used to resolve relations - */ - @Generated(hash = 2040040024) - private transient DaoSession daoSession; - - @Generated(hash = 229273163) - private transient Long folderCloud__resolvedKey; - - @Generated(hash = 1663458645) - public VaultEntity(Long id, Long folderCloudId, String folderPath, String folderName, @NotNull String cloudType, String password, String passwordCryptoMode, Integer position, Integer format, - Integer shorteningThreshold) { - this.id = id; - this.folderCloudId = folderCloudId; - this.folderPath = folderPath; - this.folderName = folderName; - this.cloudType = cloudType; - this.password = password; - this.passwordCryptoMode = passwordCryptoMode; - this.position = position; - this.format = format; - this.shorteningThreshold = shorteningThreshold; - } - - @Generated(hash = 691253864) - public VaultEntity() { - } - - /** - * Convenient call for {@link org.greenrobot.greendao.AbstractDao#refresh(Object)}. - * Entity must attached to an entity context. - */ - @Generated(hash = 1942392019) - public void refresh() { - if (myDao == null) { - throw new DaoException("Entity is detached from DAO context"); - } - myDao.refresh(this); - } - - /** - * Convenient call for {@link org.greenrobot.greendao.AbstractDao#update(Object)}. - * Entity must attached to an entity context. - */ - @Generated(hash = 713229351) - public void update() { - if (myDao == null) { - throw new DaoException("Entity is detached from DAO context"); - } - myDao.update(this); - } - - /** - * Convenient call for {@link org.greenrobot.greendao.AbstractDao#delete(Object)}. - * Entity must attached to an entity context. - */ - @Generated(hash = 128553479) - public void delete() { - if (myDao == null) { - throw new DaoException("Entity is detached from DAO context"); - } - myDao.delete(this); - } - - /** - * To-one relationship, resolved on first access. - */ - @Generated(hash = 1508817413) - public CloudEntity getFolderCloud() { - Long __key = this.folderCloudId; - if (folderCloud__resolvedKey == null || !folderCloud__resolvedKey.equals(__key)) { - final DaoSession daoSession = this.daoSession; - if (daoSession == null) { - throw new DaoException("Entity is detached from DAO context"); - } - CloudEntityDao targetDao = daoSession.getCloudEntityDao(); - CloudEntity folderCloudNew = targetDao.load(__key); - synchronized (this) { - folderCloud = folderCloudNew; - folderCloud__resolvedKey = __key; - } - } - return folderCloud; - } - - /** - * called by internal mechanisms, do not call yourself. - */ - @Generated(hash = 1482096330) - public void setFolderCloud(CloudEntity folderCloud) { - synchronized (this) { - this.folderCloud = folderCloud; - folderCloudId = folderCloud == null ? null : folderCloud.getId(); - folderCloud__resolvedKey = folderCloudId; - } - } - - public String getFolderPath() { - return this.folderPath; - } - - public void setFolderPath(String folderPath) { - this.folderPath = folderPath; - } - - public String getFolderName() { - return folderName; - } - - public void setFolderName(String folderName) { - this.folderName = folderName; - } - - public Long getId() { - return this.id; - } - - public void setId(Long id) { - this.id = id; - } - - public Long getFolderCloudId() { - return this.folderCloudId; - } - - public void setFolderCloudId(Long folderCloudId) { - this.folderCloudId = folderCloudId; - } - - public String getCloudType() { - return this.cloudType; - } - - public void setCloudType(String cloudType) { - this.cloudType = cloudType; - } - - public String getPassword() { - return this.password; - } - - public void setPassword(String password) { - this.password = password; - } - - public Integer getPosition() { - return this.position; - } - - public void setPosition(Integer position) { - this.position = position; - } - - public Integer getFormat() { - return this.format; - } - - public void setFormat(Integer format) { - this.format = format; - } - - public Integer getShorteningThreshold() { - return this.shorteningThreshold; - } - - public void setShorteningThreshold(Integer shorteningThreshold) { - this.shorteningThreshold = shorteningThreshold; - } - - public String getPasswordCryptoMode() { - return this.passwordCryptoMode; - } - - public void setPasswordCryptoMode(String passwordCryptoMode) { - this.passwordCryptoMode = passwordCryptoMode; - } - - /** called by internal mechanisms, do not call yourself. */ - @Generated(hash = 674742652) - public void __setDaoSession(DaoSession daoSession) { - this.daoSession = daoSession; - myDao = daoSession != null ? daoSession.getVaultEntityDao() : null; - } - -} diff --git a/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.kt b/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.kt new file mode 100644 index 000000000..755311923 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/entities/VaultEntity.kt @@ -0,0 +1,25 @@ +package org.cryptomator.data.db.entities + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.Index.Order +import androidx.room.PrimaryKey + +@Entity( + tableName = "VAULT_ENTITY", // + indices = [Index(name = "IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID", value = ["folderPath", "folderCloudId"], orders = [Order.ASC, Order.ASC], unique = true)], // + foreignKeys = [ForeignKey(CloudEntity::class, ["id"], ["folderCloudId"], onDelete = ForeignKey.RESTRICT)] +) +data class VaultEntity( + @PrimaryKey override val id: Long?, + val folderCloudId: Long?, + val folderPath: String?, + val folderName: String?, + val cloudType: String, + val password: String?, + val passwordCryptoMode: String?, + val position: Int?, + val format: Int?, + val shorteningThreshold: Int?, +) : DatabaseEntity \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java b/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java index 5dd41c4d6..e0148884e 100644 --- a/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java +++ b/data/src/main/java/org/cryptomator/data/db/mappers/CloudEntityMapper.java @@ -11,6 +11,8 @@ import org.cryptomator.domain.S3Cloud; import org.cryptomator.domain.WebDavCloud; +import java.util.Objects; + import javax.inject.Inject; import javax.inject.Singleton; @@ -86,10 +88,9 @@ public Cloud fromEntity(CloudEntity entity) { @Override public CloudEntity toEntity(Cloud domainObject) { - CloudEntity result = new CloudEntity(); - result.setId(domainObject.id()); - result.setType(domainObject.type().name()); - switch (domainObject.type()) { + CloudType type = Objects.requireNonNull(domainObject.type()); + CloudEntity result = CloudEntity.newEntity(domainObject.id(), type.name()); + switch (type) { case DROPBOX: result.setAccessToken(((DropboxCloud) domainObject).accessToken()); result.setUsername(((DropboxCloud) domainObject).username()); diff --git a/data/src/main/java/org/cryptomator/data/db/mappers/VaultEntityMapper.java b/data/src/main/java/org/cryptomator/data/db/mappers/VaultEntityMapper.java index 3cd19471f..e6ff9fb13 100644 --- a/data/src/main/java/org/cryptomator/data/db/mappers/VaultEntityMapper.java +++ b/data/src/main/java/org/cryptomator/data/db/mappers/VaultEntityMapper.java @@ -1,5 +1,6 @@ package org.cryptomator.data.db.mappers; +import org.cryptomator.data.db.CloudDao; import org.cryptomator.data.db.entities.VaultEntity; import org.cryptomator.domain.Cloud; import org.cryptomator.domain.CloudType; @@ -16,9 +17,11 @@ public class VaultEntityMapper extends EntityMapper { private final CloudEntityMapper cloudEntityMapper; + private final CloudDao cloudDao; @Inject - public VaultEntityMapper(CloudEntityMapper cloudEntityMapper) { + public VaultEntityMapper(CloudEntityMapper cloudEntityMapper, CloudDao cloudDao) { + this.cloudDao = cloudDao; this.cloudEntityMapper = cloudEntityMapper; } @@ -38,10 +41,10 @@ public Vault fromEntity(VaultEntity entity) throws BackendException { } private Cloud cloudFrom(VaultEntity entity) { - if (entity.getFolderCloud() == null) { + if (entity.getFolderCloudId() == null) { return null; } - return cloudEntityMapper.fromEntity(entity.getFolderCloud()); + return cloudEntityMapper.fromEntity(cloudDao.load(entity.getFolderCloudId())); } private CryptoMode cryptoModeFrom(VaultEntity entity) { @@ -50,21 +53,24 @@ private CryptoMode cryptoModeFrom(VaultEntity entity) { @Override public VaultEntity toEntity(Vault domainObject) { - VaultEntity entity = new VaultEntity(); - entity.setId(domainObject.getId()); - entity.setFolderPath(domainObject.getPath()); - entity.setFolderName(domainObject.getName()); + Long folderCloudId = null; if (domainObject.getCloud() != null) { - entity.setFolderCloud(cloudEntityMapper.toEntity(domainObject.getCloud())); + folderCloudId = cloudEntityMapper.toEntity(domainObject.getCloud()).getId(); } - entity.setCloudType(domainObject.getCloudType().name()); - entity.setPassword(domainObject.getPassword()); + String cryptoMode = null; if (domainObject.getPasswordCryptoMode() != null) { - entity.setPasswordCryptoMode(domainObject.getPasswordCryptoMode().name()); + cryptoMode = domainObject.getPasswordCryptoMode().name(); } - entity.setPosition(domainObject.getPosition()); - entity.setFormat(domainObject.getFormat()); - entity.setShorteningThreshold(domainObject.getShorteningThreshold()); - return entity; + return new VaultEntity(domainObject.getId(), // + folderCloudId, // + domainObject.getPath(), // + domainObject.getName(), // + domainObject.getCloudType().name(), // + domainObject.getPassword(), // + cryptoMode, // + domainObject.getPosition(), // + domainObject.getFormat(), // + domainObject.getShorteningThreshold() // + ); } } diff --git a/data/src/main/java/org/cryptomator/data/db/migrations/MigrationContainer.kt b/data/src/main/java/org/cryptomator/data/db/migrations/MigrationContainer.kt new file mode 100644 index 000000000..a1f6b17cf --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/migrations/MigrationContainer.kt @@ -0,0 +1,100 @@ +package org.cryptomator.data.db.migrations + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.legacy.Upgrade10To11 +import org.cryptomator.data.db.migrations.legacy.Upgrade11To12 +import org.cryptomator.data.db.migrations.legacy.Upgrade12To13 +import org.cryptomator.data.db.migrations.legacy.Upgrade1To2 +import org.cryptomator.data.db.migrations.legacy.Upgrade2To3 +import org.cryptomator.data.db.migrations.legacy.Upgrade3To4 +import org.cryptomator.data.db.migrations.legacy.Upgrade4To5 +import org.cryptomator.data.db.migrations.legacy.Upgrade5To6 +import org.cryptomator.data.db.migrations.legacy.Upgrade6To7 +import org.cryptomator.data.db.migrations.legacy.Upgrade7To8 +import org.cryptomator.data.db.migrations.legacy.Upgrade8To9 +import org.cryptomator.data.db.migrations.legacy.Upgrade9To10 +import org.cryptomator.data.db.migrations.manual.Migration13To14 +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class MigrationContainer private constructor(private val migrations: List) { + + @Inject + internal constructor( + upgrade1To2: Upgrade1To2, // + upgrade2To3: Upgrade2To3, // + upgrade3To4: Upgrade3To4, // + upgrade4To5: Upgrade4To5, // + upgrade5To6: Upgrade5To6, // + upgrade6To7: Upgrade6To7, // + upgrade7To8: Upgrade7To8, // + upgrade8To9: Upgrade8To9, // + upgrade9To10: Upgrade9To10, // + upgrade10To11: Upgrade10To11, // + upgrade11To12: Upgrade11To12, // + upgrade12To13: Upgrade12To13, // + // + migration13To14: Migration13To14, // + ) : this( + validateMigrations( + upgrade1To2, // + upgrade2To3, // + upgrade3To4, // + upgrade4To5, // + upgrade5To6, // + upgrade6To7, // + upgrade7To8, // + upgrade8To9, // + upgrade9To10, // + upgrade10To11, // + upgrade11To12, // + upgrade12To13, // + // + migration13To14, // + ) + ) + + internal fun getPath(oldVersion: Int): List { + return getPath(oldVersion, migrations.size + 1) + } + + internal fun getPath(oldVersion: Int, newVersion: Int): List { + require(oldVersion in 1.. applyPathAndReturnNext(database: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int): T { + val nextMigration = migrations.getOrNull(newVersion - 1) ?: throw IllegalArgumentException("No migration from version $newVersion to ${newVersion + 1}") + require(nextMigration is T) + applyPath(database, oldVersion, newVersion) + return nextMigration + } + + internal fun applyPathAndReturnNext(database: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int): DatabaseMigration { + return applyPathAndReturnNext(database, oldVersion, newVersion) + } +} + +private fun validateMigrations(vararg migrations: DatabaseMigration): List { + require(migrations.isNotEmpty()) + return migrations.asSequence().onEachIndexed { index, migration -> + require(migration.startVersion == (index + 1) && Math.addExact(migration.startVersion, 1) == migration.endVersion) { // + "Illegal migration configuration" + } + }.toCollection(ArrayList(migrations.size)) +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/Sql.java b/data/src/main/java/org/cryptomator/data/db/migrations/Sql.java similarity index 68% rename from data/src/main/java/org/cryptomator/data/db/Sql.java rename to data/src/main/java/org/cryptomator/data/db/migrations/Sql.java index 1d961e146..6697e23f2 100644 --- a/data/src/main/java/org/cryptomator/data/db/Sql.java +++ b/data/src/main/java/org/cryptomator/data/db/migrations/Sql.java @@ -1,22 +1,26 @@ -package org.cryptomator.data.db; +package org.cryptomator.data.db.migrations; import android.content.ContentValues; import android.database.Cursor; import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteQueryBuilder; -import org.greenrobot.greendao.database.Database; +import androidx.sqlite.db.SupportSQLiteDatabase; + +import org.cryptomator.data.util.Utils; import java.util.ArrayList; import java.util.List; +import static org.cryptomator.data.db.migrations.Sql.SqlCreateTableBuilder.ColumnConstraint.NOT_NULL; +import static org.cryptomator.data.db.migrations.Sql.SqlCreateTableBuilder.ColumnConstraint.PRIMARY_KEY; +import static org.cryptomator.data.db.migrations.Sql.SqlCreateTableBuilder.ColumnType.BOOLEAN; +import static org.cryptomator.data.db.migrations.Sql.SqlCreateTableBuilder.ColumnType.INTEGER; +import static org.cryptomator.data.db.migrations.Sql.SqlCreateTableBuilder.ColumnType.TEXT; import static java.lang.String.format; -import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnConstraint.NOT_NULL; -import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnConstraint.PRIMARY_KEY; -import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnType.BOOLEAN; -import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnType.INTEGER; -import static org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ColumnType.TEXT; -class Sql { +//https://developer.android.com/reference/android/database/sqlite/package-summary.html -- API 26 -> 3.18 +public class Sql { public static SqlInsertBuilder insertInto(String table) { return new SqlInsertBuilder(table); @@ -61,6 +65,13 @@ public static Criterion eq(final String value) { }; } + public static Criterion notEq(final String value) { + return (column, whereClause, whereArgs) -> { + whereClause.append('"').append(column).append("\" != ?"); + whereArgs.add(value); + }; + } + public static Criterion isNull() { return (column, whereClause, whereArgs) -> whereClause.append('"').append(column).append("\" IS NULL"); } @@ -73,6 +84,13 @@ public static Criterion eq(final Long value) { return (column, whereClause, whereArgs) -> whereClause.append('"').append(column).append("\" = ").append(value); } + public static Criterion like(final String value) { + return (column, whereClause, whereArgs) -> { + whereClause.append('"').append(column).append("\" LIKE(?)"); + whereArgs.add(value); + }; + } + public static ValueHolder toString(final String value) { return (column, contentValues) -> contentValues.put(column, value); } @@ -89,10 +107,6 @@ public static ValueHolder toNull() { return (column, contentValues) -> contentValues.putNull(column); } - private static SQLiteDatabase unwrap(Database wrapped) { - return (SQLiteDatabase) wrapped.getRawDatabase(); - } - public interface ValueHolder { void put(String column, ContentValues contentValues); @@ -113,6 +127,7 @@ public static class SqlQueryBuilder { private List columns = new ArrayList<>(); private String groupBy; private String having; + private String orderBy; private String limit; public SqlQueryBuilder(String tableName) { @@ -133,25 +148,43 @@ public SqlQueryBuilder where(String column, Criterion criterion) { } public SqlQueryBuilder groupBy(String groupBy) { - this.groupBy = groupBy; + this.groupBy = Utils.requireNullOrNotBlank(groupBy); return this; } public SqlQueryBuilder having(String having) { - this.having = having; + this.having = Utils.requireNullOrNotBlank(having); return this; } - public SqlQueryBuilder limit(String limit) { - this.limit = limit; + public SqlQueryBuilder orderBy(String orderBy) { + this.orderBy = Utils.requireNullOrNotBlank(orderBy); return this; } - public Cursor executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); - return db.query(tableName, columns.toArray(new String[columns.size()]), whereClause.toString(), whereArgs.toArray(new String[whereArgs.size()]), groupBy, having, limit); + public SqlQueryBuilder limit(String limit) { + this.limit = Utils.requireNullOrNotBlank(limit); + return this; } + public Cursor executeOn(SupportSQLiteDatabase db) { + if (tableName == null || tableName.trim().isEmpty()) { + throw new IllegalArgumentException(); + } + String query = SQLiteQueryBuilder.buildQueryString( // + /* distinct */ false, // + tableName, // + Utils.emptyToNull(columns.toArray(new String[columns.size()])), // + Utils.blankToNull(whereClause.toString()), // + groupBy, // + having, // + orderBy, // + limit // + ); + //In contrast to "SupportSQLiteDatabase#update" "query" doesn't define how the contents of "whereArgs" are bound. + //As of now we always pass an "Array", but this has to be kept in mind if we ever change this. See: "SqlUpdateBuilder#executeOn" + return db.query(query, whereArgs.toArray()); + } } public static class SqlUpdateBuilder { @@ -179,14 +212,18 @@ public SqlUpdateBuilder where(String column, Criterion criterion) { return this; } - public void executeOn(Database wrapped) { + public void executeOn(SupportSQLiteDatabase db) { if (contentValues.size() == 0) { throw new IllegalStateException("At least one value must be set"); } - SQLiteDatabase db = unwrap(wrapped); - db.update(tableName, contentValues, whereClause.toString(), whereArgs.toArray(new String[whereArgs.size()])); - } + //The behavior of "SupportSQLiteDatabase#update" is a bit strange, which caused me to investigate: + //The docs say that the contents of "whereArgs" are bound as "Strings", even if the parameter is of type "Array". + //The internal binding methods are type-safe, but resolve to just putting all args into an "Array" in "SQLiteProgram" anyway. + //This array is also used by "SQLiteDatabase#update". Apparently the contents of the array are then bound as "Strings". + //As of now we always pass an "Array", but all of this has to be kept in mind if we ever change this. + db.update(tableName, SQLiteDatabase.CONFLICT_NONE, contentValues, Utils.blankToNull(whereClause.toString()), whereArgs.toArray(new String[whereArgs.size()])); + } } public static class SqlDropIndexBuilder { @@ -197,8 +234,7 @@ private SqlDropIndexBuilder(String index) { this.index = index; } - public void executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); + public void executeOn(SupportSQLiteDatabase db) { db.execSQL(format("DROP INDEX \"%s\"", index)); } @@ -227,8 +263,7 @@ public SqlUniqueIndexBuilder asc(String column) { return this; } - public void executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); + public void executeOn(SupportSQLiteDatabase db) { db.execSQL(format("CREATE UNIQUE INDEX \"%s\" ON \"%s\" (%s)", indexName, table, columns)); } } @@ -241,8 +276,7 @@ private SqlDropTableBuilder(String table) { this.table = table; } - public void executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); + public void executeOn(SupportSQLiteDatabase db) { db.execSQL(format("DROP TABLE \"%s\"", table)); } @@ -262,8 +296,7 @@ public SqlAlterTableBuilder renameTo(String newName) { return this; } - public void executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); + public void executeOn(SupportSQLiteDatabase db) { db.execSQL(format("ALTER TABLE \"%s\" RENAME TO \"%s\"", table, newName)); } } @@ -288,8 +321,7 @@ public SqlInsertSelectBuilder from(String sourceTableName) { return this; } - public void executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); + public void executeOn(SupportSQLiteDatabase db) { StringBuilder query = new StringBuilder().append("INSERT INTO \"").append(table).append("\" ("); appendColumns(query, columns, false); query.append(") SELECT "); @@ -319,6 +351,14 @@ private void appendColumns(StringBuilder query, String[] columns, boolean append } public SqlInsertSelectBuilder join(String targetTable, String sourceColumn) { + return join(targetTable, "id", sourceColumn); + } + + public SqlInsertSelectBuilder pre15Join(String targetTable, String sourceColumn) { + return join(targetTable, "_id", sourceColumn); + } + + public SqlInsertSelectBuilder join(String targetTable, String targetColumn, String sourceColumn) { sourceColumn = sourceColumn.replace(".", "\".\""); joinClauses.append(" JOIN \"") // .append(targetTable) // @@ -326,7 +366,9 @@ public SqlInsertSelectBuilder join(String targetTable, String sourceColumn) { .append(sourceColumn) // .append("\" = \"") // .append(targetTable) // - .append("\".\"_id\" "); + .append("\".\"") // + .append(targetColumn) // + .append("\" "); return this; } @@ -355,6 +397,11 @@ private SqlCreateTableBuilder(String table) { } public SqlCreateTableBuilder id() { + column("id", INTEGER, PRIMARY_KEY); + return this; + } + + public SqlCreateTableBuilder pre15Id() { column("_id", INTEGER, PRIMARY_KEY); return this; } @@ -400,12 +447,19 @@ public SqlCreateTableBuilder column(String name, ColumnType type, ColumnConstrai return this; } - public void executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); + public void executeOn(SupportSQLiteDatabase db) { db.execSQL(format("CREATE TABLE \"%s\" (%s%s)", table, columns, foreignKeys)); } public SqlCreateTableBuilder foreignKey(String column, String targetTable, ForeignKeyBehaviour... behaviours) { + return foreignKey(column, targetTable, "id", behaviours); + } + + public SqlCreateTableBuilder pre15ForeignKey(String column, String targetTable, ForeignKeyBehaviour... behaviours) { + return foreignKey(column, targetTable, "_id", behaviours); + } + + public SqlCreateTableBuilder foreignKey(String column, String targetTable, String targetColumn, ForeignKeyBehaviour... behaviours) { foreignKeys // .append(", CONSTRAINT FK_") // .append(column) // @@ -415,7 +469,9 @@ public SqlCreateTableBuilder foreignKey(String column, String targetTable, Forei .append(column) // .append(") REFERENCES ") // .append(targetTable) // - .append("(_id)"); + .append("(") // + .append(targetColumn) // + .append(")"); for (ForeignKeyBehaviour behaviour : behaviours) { foreignKeys.append(" ").append(behaviour.getText()); @@ -491,14 +547,16 @@ public SqlInsertBuilder integer(String column, Integer value) { return this; } - public SqlInsertBuilder bool(String column, Boolean value) { - contentValues.put(column, value); - return this; - } + public Long executeOn(SupportSQLiteDatabase db) { + if (contentValues.size() == 0) { + throw new IllegalStateException("At least one value must be set"); + } - public Long executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); - return db.insertOrThrow(table, null, contentValues); + //In contrast to "SupportSQLiteDatabase#update" "insert" doesn't define how the contents of "contentValues" are bound. + //As opposed to the other methods in this class, we do actually pass "Integers" and "Strings" here and again they appear + //to end up in the "Array" in "SQLiteProgram". Currently there is no issue, + //but this has to be kept in mind if we ever change this method. See: "SqlUpdateBuilder#executeOn" + return db.insert(this.table, SQLiteDatabase.CONFLICT_NONE, contentValues); } } @@ -521,10 +579,10 @@ public SqlDeleteBuilder where(String column, Criterion criterion) { return this; } - public void executeOn(Database wrapped) { - SQLiteDatabase db = unwrap(wrapped); - db.delete(tableName, whereClause.toString(), whereArgs.toArray(new String[whereArgs.size()])); + public void executeOn(SupportSQLiteDatabase db) { + //"SupportSQLiteDatabase#delete" always binds the contents of "whereArgs" as "Strings". + //As of now we always pass an "Array", but this has to be kept in mind if we ever change this. See: "SqlUpdateBuilder#executeOn" + db.delete(tableName, Utils.blankToNull(whereClause.toString()), whereArgs.toArray(new String[whereArgs.size()])); } } - } diff --git a/data/src/main/java/org/cryptomator/data/db/migrations/auto/AutoMigration14To15.kt b/data/src/main/java/org/cryptomator/data/db/migrations/auto/AutoMigration14To15.kt new file mode 100644 index 000000000..2087f6c5d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/migrations/auto/AutoMigration14To15.kt @@ -0,0 +1,38 @@ +package org.cryptomator.data.db.migrations.auto + +import androidx.room.RenameColumn +import org.cryptomator.data.db.DatabaseAutoMigrationSpec + +@RenameColumn.Entries( + RenameColumn("CLOUD_ENTITY", "_id", "id"), + RenameColumn("CLOUD_ENTITY", "TYPE", "type"), + RenameColumn("CLOUD_ENTITY", "ACCESS_TOKEN", "accessToken"), + RenameColumn("CLOUD_ENTITY", "ACCESS_TOKEN_CRYPTO_MODE", "accessTokenCryptoMode"), + RenameColumn("CLOUD_ENTITY", "URL", "url"), + RenameColumn("CLOUD_ENTITY", "USERNAME", "username"), + RenameColumn("CLOUD_ENTITY", "WEBDAV_CERTIFICATE", "webdavCertificate"), + RenameColumn("CLOUD_ENTITY", "S3_BUCKET", "s3Bucket"), + RenameColumn("CLOUD_ENTITY", "S3_REGION", "s3Region"), + RenameColumn("CLOUD_ENTITY", "S3_SECRET_KEY", "s3SecretKey"), + RenameColumn("CLOUD_ENTITY", "S3_SECRET_KEY_CRYPTO_MODE", "s3SecretKeyCryptoMode"), + // + RenameColumn("UPDATE_CHECK_ENTITY", "_id", "id"), + RenameColumn("UPDATE_CHECK_ENTITY", "LICENSE_TOKEN", "licenseToken"), + RenameColumn("UPDATE_CHECK_ENTITY", "RELEASE_NOTE", "releaseNote"), + RenameColumn("UPDATE_CHECK_ENTITY", "VERSION", "version"), + RenameColumn("UPDATE_CHECK_ENTITY", "URL_TO_APK", "urlToApk"), + RenameColumn("UPDATE_CHECK_ENTITY", "APK_SHA256", "apkSha256"), + RenameColumn("UPDATE_CHECK_ENTITY", "URL_TO_RELEASE_NOTE", "urlToReleaseNote"), + // + RenameColumn("VAULT_ENTITY", "_id", "id"), + RenameColumn("VAULT_ENTITY", "FOLDER_CLOUD_ID", "folderCloudId"), + RenameColumn("VAULT_ENTITY", "FOLDER_PATH", "folderPath"), + RenameColumn("VAULT_ENTITY", "FOLDER_NAME", "folderName"), + RenameColumn("VAULT_ENTITY", "CLOUD_TYPE", "cloudType"), + RenameColumn("VAULT_ENTITY", "PASSWORD", "password"), + RenameColumn("VAULT_ENTITY", "PASSWORD_CRYPTO_MODE", "passwordCryptoMode"), + RenameColumn("VAULT_ENTITY", "POSITION", "position"), + RenameColumn("VAULT_ENTITY", "FORMAT", "format"), + RenameColumn("VAULT_ENTITY", "SHORTENING_THRESHOLD", "shorteningThreshold"), +) +class AutoMigration14To15 : DatabaseAutoMigrationSpec() \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade0To1.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade0To1.kt similarity index 67% rename from data/src/main/java/org/cryptomator/data/db/Upgrade0To1.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade0To1.kt index a8f7849fb..71d0c13d7 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade0To1.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade0To1.kt @@ -1,15 +1,17 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy -import org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ForeignKeyBehaviour +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql +import org.cryptomator.data.db.migrations.Sql.SqlCreateTableBuilder.ForeignKeyBehaviour import org.cryptomator.domain.CloudType -import org.greenrobot.greendao.database.Database import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade0To1 @Inject constructor() : DatabaseUpgrade(0, 1) { +internal class Upgrade0To1 @Inject constructor() : DatabaseMigration(0, 1) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { createCloudEntityTable(db) createVaultEntityTable(db) createDropboxCloud(db) @@ -18,9 +20,9 @@ internal class Upgrade0To1 @Inject constructor() : DatabaseUpgrade(0, 1) { createOnedriveCloud(db) } - private fun createCloudEntityTable(db: Database) { + private fun createCloudEntityTable(db: SupportSQLiteDatabase) { Sql.createTable("CLOUD_ENTITY") // - .id() // + .pre15Id() // .requiredText("TYPE") // .optionalText("ACCESS_TOKEN") // .optionalText("WEBDAV_URL") // @@ -29,15 +31,15 @@ internal class Upgrade0To1 @Inject constructor() : DatabaseUpgrade(0, 1) { .executeOn(db) } - private fun createVaultEntityTable(db: Database) { + private fun createVaultEntityTable(db: SupportSQLiteDatabase) { Sql.createTable("VAULT_ENTITY") // - .id() // + .pre15Id() // .optionalInt("FOLDER_CLOUD_ID") // .optionalText("FOLDER_PATH") // .optionalText("FOLDER_NAME") // .requiredText("CLOUD_TYPE") // .optionalText("PASSWORD") // - .foreignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", ForeignKeyBehaviour.ON_DELETE_SET_NULL) // + .pre15ForeignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", ForeignKeyBehaviour.ON_DELETE_SET_NULL) // .executeOn(db) Sql.createUniqueIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID") // .on("VAULT_ENTITY") // @@ -46,7 +48,7 @@ internal class Upgrade0To1 @Inject constructor() : DatabaseUpgrade(0, 1) { .executeOn(db) } - private fun createDropboxCloud(db: Database) { + private fun createDropboxCloud(db: SupportSQLiteDatabase) { Sql.insertInto("CLOUD_ENTITY") // .integer("_id", 1) // .text("TYPE", CloudType.DROPBOX.name) // @@ -57,7 +59,7 @@ internal class Upgrade0To1 @Inject constructor() : DatabaseUpgrade(0, 1) { .executeOn(db) } - private fun createGoogleDriveCloud(db: Database) { + private fun createGoogleDriveCloud(db: SupportSQLiteDatabase) { Sql.insertInto("CLOUD_ENTITY") // .integer("_id", 2) // .text("TYPE", CloudType.GOOGLE_DRIVE.name) // @@ -68,7 +70,7 @@ internal class Upgrade0To1 @Inject constructor() : DatabaseUpgrade(0, 1) { .executeOn(db) } - private fun createOnedriveCloud(db: Database) { + private fun createOnedriveCloud(db: SupportSQLiteDatabase) { Sql.insertInto("CLOUD_ENTITY") // .integer("_id", 3) // .text("TYPE", CloudType.ONEDRIVE.name) // @@ -79,7 +81,7 @@ internal class Upgrade0To1 @Inject constructor() : DatabaseUpgrade(0, 1) { .executeOn(db) } - private fun createLocalStorageCloud(db: Database) { + private fun createLocalStorageCloud(db: SupportSQLiteDatabase) { Sql.insertInto("CLOUD_ENTITY") // .integer("_id", 4) // .text("TYPE", CloudType.LOCAL.name) // diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade10To11.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade10To11.kt similarity index 72% rename from data/src/main/java/org/cryptomator/data/db/Upgrade10To11.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade10To11.kt index 263f18fd3..ded90d86d 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade10To11.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade10To11.kt @@ -1,16 +1,19 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy -import org.greenrobot.greendao.database.Database +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade10To11 @Inject constructor() : DatabaseUpgrade(10, 11) { +internal class Upgrade10To11 @Inject constructor() : DatabaseMigration(10, 11) { + private val defaultThreshold = 220 private val defaultVaultFormat = 8 private val onedriveCloudId = 3L - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { db.beginTransaction() try { addFormatAndShorteningToDbEntity(db) @@ -24,10 +27,10 @@ internal class Upgrade10To11 @Inject constructor() : DatabaseUpgrade(10, 11) { } } - private fun addFormatAndShorteningToDbEntity(db: Database) { + private fun addFormatAndShorteningToDbEntity(db: SupportSQLiteDatabase) { Sql.alterTable("VAULT_ENTITY").renameTo("VAULT_ENTITY_OLD").executeOn(db) Sql.createTable("VAULT_ENTITY") // - .id() // + .pre15Id() // .optionalInt("FOLDER_CLOUD_ID") // .optionalText("FOLDER_PATH") // .optionalText("FOLDER_NAME") // @@ -36,14 +39,14 @@ internal class Upgrade10To11 @Inject constructor() : DatabaseUpgrade(10, 11) { .optionalInt("POSITION") // .optionalInt("FORMAT") // .optionalInt("SHORTENING_THRESHOLD") // - .foreignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // + .pre15ForeignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // .executeOn(db) Sql.insertInto("VAULT_ENTITY") // .select("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "POSITION", "CLOUD_ENTITY.TYPE") // .columns("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "POSITION", "CLOUD_TYPE") // .from("VAULT_ENTITY_OLD") // - .join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // + .pre15Join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // .executeOn(db) Sql.dropIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID").executeOn(db) @@ -58,14 +61,14 @@ internal class Upgrade10To11 @Inject constructor() : DatabaseUpgrade(10, 11) { } - private fun addDefaultFormatAndShorteningThresholdToVaults(db: Database) { + private fun addDefaultFormatAndShorteningThresholdToVaults(db: SupportSQLiteDatabase) { Sql.update("VAULT_ENTITY") .set("FORMAT", Sql.toInteger(defaultVaultFormat)) .set("SHORTENING_THRESHOLD", Sql.toInteger(defaultThreshold)) .executeOn(db) } - private fun deleteOnedriveCloudIfNotSetUp(db: Database) { + private fun deleteOnedriveCloudIfNotSetUp(db: SupportSQLiteDatabase) { Sql.deleteFrom("CLOUD_ENTITY") .where("_id", Sql.eq(onedriveCloudId)) .where("TYPE", Sql.eq("ONEDRIVE")) diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade11To12.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade11To12.kt similarity index 58% rename from data/src/main/java/org/cryptomator/data/db/Upgrade11To12.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade11To12.kt index 2ec846443..7b81d69a9 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade11To12.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade11To12.kt @@ -1,15 +1,16 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy +import androidx.sqlite.db.SupportSQLiteDatabase import com.google.common.base.Optional +import org.cryptomator.data.db.DatabaseMigration import org.cryptomator.util.SharedPreferencesHandler -import org.greenrobot.greendao.database.Database import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade11To12 @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) : DatabaseUpgrade(11, 12) { +internal class Upgrade11To12 @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) : DatabaseMigration(11, 12) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { when (sharedPreferencesHandler.updateIntervalInDays()) { Optional.of(7), Optional.of(30) -> sharedPreferencesHandler.setUpdateIntervalInDays(Optional.of(1)) } diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade12To13.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade12To13.kt similarity index 82% rename from data/src/main/java/org/cryptomator/data/db/Upgrade12To13.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade12To13.kt index a6b99a32f..befb30567 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade12To13.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade12To13.kt @@ -1,16 +1,18 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy import android.content.Context +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql import org.cryptomator.util.crypto.CredentialCryptor import org.cryptomator.util.crypto.CryptoMode -import org.greenrobot.greendao.database.Database import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade12To13 @Inject constructor(private val context: Context) : DatabaseUpgrade(12, 13) { +internal class Upgrade12To13 @Inject constructor(private val context: Context) : DatabaseMigration(12, 13) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { db.beginTransaction() try { moveLocalStorageUrlToUrlProperty(db) @@ -24,7 +26,7 @@ internal class Upgrade12To13 @Inject constructor(private val context: Context) : } } - private fun moveLocalStorageUrlToUrlProperty(db: Database) { + private fun moveLocalStorageUrlToUrlProperty(db: SupportSQLiteDatabase) { Sql.query("CLOUD_ENTITY").where("TYPE", Sql.eq("LOCAL")).executeOn(db).use { while (it.moveToNext()) { Sql.update("CLOUD_ENTITY") // @@ -36,18 +38,18 @@ internal class Upgrade12To13 @Inject constructor(private val context: Context) : } } - private fun dropGoogleDriveUsernameInAccessToken(db: Database) { + private fun dropGoogleDriveUsernameInAccessToken(db: SupportSQLiteDatabase) { Sql.update("CLOUD_ENTITY") .set("ACCESS_TOKEN", Sql.toNull()) // .where("TYPE", Sql.eq("GOOGLE_DRIVE")) .executeOn(db) } - private fun addCryptoModeToDbEntities(db: Database) { + private fun addCryptoModeToDbEntities(db: SupportSQLiteDatabase) { Sql.alterTable("CLOUD_ENTITY").renameTo("CLOUD_ENTITY_OLD").executeOn(db) Sql.createTable("CLOUD_ENTITY") // - .id() // + .pre15Id() // .requiredText("TYPE") // .optionalText("ACCESS_TOKEN") // .optionalText("ACCESS_TOKEN_CRYPTO_MODE") // @@ -72,10 +74,10 @@ internal class Upgrade12To13 @Inject constructor(private val context: Context) : Sql.dropTable("CLOUD_ENTITY_OLD").executeOn(db) } - private fun addPasswordCryptoModeToVaultDbEntity(db: Database) { + private fun addPasswordCryptoModeToVaultDbEntity(db: SupportSQLiteDatabase) { Sql.alterTable("VAULT_ENTITY").renameTo("VAULT_ENTITY_OLD").executeOn(db) Sql.createTable("VAULT_ENTITY") // - .id() // + .pre15Id() // .optionalInt("FOLDER_CLOUD_ID") // .optionalText("FOLDER_PATH") // .optionalText("FOLDER_NAME") // @@ -85,14 +87,14 @@ internal class Upgrade12To13 @Inject constructor(private val context: Context) : .optionalText("PASSWORD_CRYPTO_MODE") // .optionalInt("POSITION") // .optionalInt("SHORTENING_THRESHOLD") // - .foreignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // + .pre15ForeignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // .executeOn(db) Sql.insertInto("VAULT_ENTITY") // .select("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "FORMAT", "PASSWORD", "POSITION", "SHORTENING_THRESHOLD", "CLOUD_ENTITY.TYPE") // .columns("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "FORMAT", "PASSWORD", "POSITION", "SHORTENING_THRESHOLD", "CLOUD_TYPE") // .from("VAULT_ENTITY_OLD") // - .join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // + .pre15Join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // .executeOn(db) Sql.dropIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID").executeOn(db) @@ -106,14 +108,14 @@ internal class Upgrade12To13 @Inject constructor(private val context: Context) : Sql.dropTable("VAULT_ENTITY_OLD").executeOn(db) } - private fun applyVaultPasswordCryptoModeToDb(db: Database) { + private fun applyVaultPasswordCryptoModeToDb(db: SupportSQLiteDatabase) { Sql.update("VAULT_ENTITY") .set("PASSWORD_CRYPTO_MODE", Sql.toString(CryptoMode.CBC.name)) // .where("PASSWORD", Sql.isNotNull()) .executeOn(db) } - private fun upgradeCloudCryptoModeToGCM(db: Database) { + private fun upgradeCloudCryptoModeToGCM(db: SupportSQLiteDatabase) { val gcmCryptor = CredentialCryptor.getInstance(context, CryptoMode.GCM) val cbcCryptor = CredentialCryptor.getInstance(context, CryptoMode.CBC) diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade1To2.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade1To2.kt similarity index 61% rename from data/src/main/java/org/cryptomator/data/db/Upgrade1To2.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade1To2.kt index b4541bfef..599aeef9f 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade1To2.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade1To2.kt @@ -1,22 +1,24 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy -import org.greenrobot.greendao.database.Database +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade1To2 @Inject constructor() : DatabaseUpgrade(1, 2) { +internal class Upgrade1To2 @Inject constructor() : DatabaseMigration(1, 2) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { createUpdateCheckTable(db) createInitialUpdateStatus(db) } - private fun createUpdateCheckTable(db: Database) { + private fun createUpdateCheckTable(db: SupportSQLiteDatabase) { db.beginTransaction() try { Sql.createTable("UPDATE_CHECK_ENTITY") // - .id() // + .pre15Id() // .optionalText("LICENSE_TOKEN") // .optionalText("RELEASE_NOTE") // .optionalText("VERSION") // @@ -29,7 +31,7 @@ internal class Upgrade1To2 @Inject constructor() : DatabaseUpgrade(1, 2) { } } - private fun createInitialUpdateStatus(db: Database) { + private fun createInitialUpdateStatus(db: SupportSQLiteDatabase) { Sql.insertInto("UPDATE_CHECK_ENTITY") // .integer("_id", 1) // .text("LICENSE_TOKEN", null) // diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade2To3.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade2To3.kt similarity index 82% rename from data/src/main/java/org/cryptomator/data/db/Upgrade2To3.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade2To3.kt index 7938f5d27..566410960 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade2To3.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade2To3.kt @@ -1,16 +1,18 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy import android.content.Context import android.content.SharedPreferences +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql import org.cryptomator.util.crypto.CredentialCryptor -import org.greenrobot.greendao.database.Database import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade2To3 @Inject constructor(private val context: Context) : DatabaseUpgrade(2, 3) { +internal class Upgrade2To3 @Inject constructor(private val context: Context) : DatabaseMigration(2, 3) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { db.beginTransaction() try { Sql.query("CLOUD_ENTITY") diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade3To4.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade3To4.kt similarity index 65% rename from data/src/main/java/org/cryptomator/data/db/Upgrade3To4.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade3To4.kt index fd443ec8f..414e0bbf1 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade3To4.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade3To4.kt @@ -1,14 +1,16 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy -import org.cryptomator.data.db.Sql.SqlCreateTableBuilder.ForeignKeyBehaviour -import org.greenrobot.greendao.database.Database +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql +import org.cryptomator.data.db.migrations.Sql.SqlCreateTableBuilder.ForeignKeyBehaviour import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade3To4 @Inject constructor() : DatabaseUpgrade(3, 4) { +internal class Upgrade3To4 @Inject constructor() : DatabaseMigration(3, 4) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { db.beginTransaction() try { addPositionToVaultSchema(db) @@ -19,24 +21,24 @@ internal class Upgrade3To4 @Inject constructor() : DatabaseUpgrade(3, 4) { } } - private fun addPositionToVaultSchema(db: Database) { + private fun addPositionToVaultSchema(db: SupportSQLiteDatabase) { Sql.alterTable("VAULT_ENTITY").renameTo("VAULT_ENTITY_OLD").executeOn(db) Sql.createTable("VAULT_ENTITY") // - .id() // + .pre15Id() // .optionalInt("FOLDER_CLOUD_ID") // .optionalText("FOLDER_PATH") // .optionalText("FOLDER_NAME") // .requiredText("CLOUD_TYPE") // .optionalText("PASSWORD") // .optionalInt("POSITION") // - .foreignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", ForeignKeyBehaviour.ON_DELETE_SET_NULL) // + .pre15ForeignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", ForeignKeyBehaviour.ON_DELETE_SET_NULL) // .executeOn(db) Sql.insertInto("VAULT_ENTITY") // .select("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "CLOUD_ENTITY.TYPE") // .columns("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "CLOUD_TYPE") // .from("VAULT_ENTITY_OLD") // - .join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // + .pre15Join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // .executeOn(db) Sql.dropIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID").executeOn(db) @@ -50,7 +52,7 @@ internal class Upgrade3To4 @Inject constructor() : DatabaseUpgrade(3, 4) { Sql.dropTable("VAULT_ENTITY_OLD").executeOn(db) } - private fun initVaultPositionUsingCurrentSortOrder(db: Database) { + private fun initVaultPositionUsingCurrentSortOrder(db: SupportSQLiteDatabase) { Sql.query("VAULT_ENTITY").executeOn(db).use { while (it.moveToNext()) { Sql.update("VAULT_ENTITY") diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade4To5.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade4To5.kt similarity index 71% rename from data/src/main/java/org/cryptomator/data/db/Upgrade4To5.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade4To5.kt index 84baee330..48e830e8e 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade4To5.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade4To5.kt @@ -1,13 +1,15 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy -import org.greenrobot.greendao.database.Database +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade4To5 @Inject constructor() : DatabaseUpgrade(4, 5) { +internal class Upgrade4To5 @Inject constructor() : DatabaseMigration(4, 5) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { db.beginTransaction() try { changeWebdavUrlInCloudEntityToUrl(db) @@ -17,17 +19,17 @@ internal class Upgrade4To5 @Inject constructor() : DatabaseUpgrade(4, 5) { } } - private fun changeWebdavUrlInCloudEntityToUrl(db: Database) { + private fun changeWebdavUrlInCloudEntityToUrl(db: SupportSQLiteDatabase) { Sql.alterTable("CLOUD_ENTITY").renameTo("CLOUD_ENTITY_OLD").executeOn(db) Sql.createTable("CLOUD_ENTITY") // - .id() // + .pre15Id() // .requiredText("TYPE") // .optionalText("ACCESS_TOKEN") // .optionalText("URL") // .optionalText("USERNAME") // .optionalText("WEBDAV_CERTIFICATE") // - .executeOn(db); + .executeOn(db) Sql.insertInto("CLOUD_ENTITY") // .select("_id", "TYPE", "ACCESS_TOKEN", "WEBDAV_URL", "USERNAME", "WEBDAV_CERTIFICATE") // @@ -40,24 +42,24 @@ internal class Upgrade4To5 @Inject constructor() : DatabaseUpgrade(4, 5) { Sql.dropTable("CLOUD_ENTITY_OLD").executeOn(db) } - private fun recreateVaultEntity(db: Database) { + private fun recreateVaultEntity(db: SupportSQLiteDatabase) { Sql.alterTable("VAULT_ENTITY").renameTo("VAULT_ENTITY_OLD").executeOn(db) Sql.createTable("VAULT_ENTITY") // - .id() // + .pre15Id() // .optionalInt("FOLDER_CLOUD_ID") // .optionalText("FOLDER_PATH") // .optionalText("FOLDER_NAME") // .requiredText("CLOUD_TYPE") // .optionalText("PASSWORD") // .optionalInt("POSITION") // - .foreignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // + .pre15ForeignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // .executeOn(db) Sql.insertInto("VAULT_ENTITY") // .select("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "POSITION", "CLOUD_ENTITY.TYPE") // .columns("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "POSITION", "CLOUD_TYPE") // .from("VAULT_ENTITY_OLD") // - .join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // + .pre15Join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // .executeOn(db) Sql.dropIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID").executeOn(db) diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade5To6.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade5To6.kt similarity index 72% rename from data/src/main/java/org/cryptomator/data/db/Upgrade5To6.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade5To6.kt index 75fb443a0..b479dd83b 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade5To6.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade5To6.kt @@ -1,13 +1,15 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy -import org.greenrobot.greendao.database.Database +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade5To6 @Inject constructor() : DatabaseUpgrade(5, 6) { +internal class Upgrade5To6 @Inject constructor() : DatabaseMigration(5, 6) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { db.beginTransaction() try { changeCloudEntityToSupportS3(db) @@ -17,11 +19,11 @@ internal class Upgrade5To6 @Inject constructor() : DatabaseUpgrade(5, 6) { } } - private fun changeCloudEntityToSupportS3(db: Database) { + private fun changeCloudEntityToSupportS3(db: SupportSQLiteDatabase) { Sql.alterTable("CLOUD_ENTITY").renameTo("CLOUD_ENTITY_OLD").executeOn(db) Sql.createTable("CLOUD_ENTITY") // - .id() // + .pre15Id() // .requiredText("TYPE") // .optionalText("ACCESS_TOKEN") // .optionalText("URL") // @@ -30,7 +32,7 @@ internal class Upgrade5To6 @Inject constructor() : DatabaseUpgrade(5, 6) { .optionalText("S3_BUCKET") // .optionalText("S3_REGION") // .optionalText("S3_SECRET_KEY") // - .executeOn(db); + .executeOn(db) Sql.insertInto("CLOUD_ENTITY") // .select("_id", "TYPE", "ACCESS_TOKEN", "URL", "USERNAME", "WEBDAV_CERTIFICATE") // @@ -43,24 +45,24 @@ internal class Upgrade5To6 @Inject constructor() : DatabaseUpgrade(5, 6) { Sql.dropTable("CLOUD_ENTITY_OLD").executeOn(db) } - private fun recreateVaultEntity(db: Database) { + private fun recreateVaultEntity(db: SupportSQLiteDatabase) { Sql.alterTable("VAULT_ENTITY").renameTo("VAULT_ENTITY_OLD").executeOn(db) Sql.createTable("VAULT_ENTITY") // - .id() // + .pre15Id() // .optionalInt("FOLDER_CLOUD_ID") // .optionalText("FOLDER_PATH") // .optionalText("FOLDER_NAME") // .requiredText("CLOUD_TYPE") // .optionalText("PASSWORD") // .optionalInt("POSITION") // - .foreignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // + .pre15ForeignKey("FOLDER_CLOUD_ID", "CLOUD_ENTITY", Sql.SqlCreateTableBuilder.ForeignKeyBehaviour.ON_DELETE_SET_NULL) // .executeOn(db) Sql.insertInto("VAULT_ENTITY") // .select("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "POSITION", "CLOUD_ENTITY.TYPE") // .columns("_id", "FOLDER_CLOUD_ID", "FOLDER_PATH", "FOLDER_NAME", "PASSWORD", "POSITION", "CLOUD_TYPE") // .from("VAULT_ENTITY_OLD") // - .join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // + .pre15Join("CLOUD_ENTITY", "VAULT_ENTITY_OLD.FOLDER_CLOUD_ID") // .executeOn(db) Sql.dropIndex("IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID").executeOn(db) diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade6To7.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade6To7.kt similarity index 79% rename from data/src/main/java/org/cryptomator/data/db/Upgrade6To7.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade6To7.kt index 8e625516b..3476b6729 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade6To7.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade6To7.kt @@ -1,15 +1,17 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy import android.database.sqlite.SQLiteException -import org.greenrobot.greendao.database.Database +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql import javax.inject.Inject import javax.inject.Singleton import timber.log.Timber @Singleton -internal class Upgrade6To7 @Inject constructor() : DatabaseUpgrade(6, 7) { +internal class Upgrade6To7 @Inject constructor() : DatabaseMigration(6, 7) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { db.beginTransaction() try { changeUpdateEntityToSupportSha256Verification(db) @@ -19,11 +21,11 @@ internal class Upgrade6To7 @Inject constructor() : DatabaseUpgrade(6, 7) { } } - private fun changeUpdateEntityToSupportSha256Verification(db: Database) { + private fun changeUpdateEntityToSupportSha256Verification(db: SupportSQLiteDatabase) { Sql.alterTable("UPDATE_CHECK_ENTITY").renameTo("UPDATE_CHECK_ENTITY_OLD").executeOn(db) Sql.createTable("UPDATE_CHECK_ENTITY") // - .id() // + .pre15Id() // .optionalText("LICENSE_TOKEN") // .optionalText("RELEASE_NOTE") // .optionalText("VERSION") // @@ -46,7 +48,7 @@ internal class Upgrade6To7 @Inject constructor() : DatabaseUpgrade(6, 7) { Sql.dropTable("UPDATE_CHECK_ENTITY_OLD").executeOn(db) } - fun tryToRecoverFromSQLiteException(db: Database) { + fun tryToRecoverFromSQLiteException(db: SupportSQLiteDatabase) { var licenseToken: String? = null try { diff --git a/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade7To8.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade7To8.kt new file mode 100644 index 000000000..f6f3a145e --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade7To8.kt @@ -0,0 +1,34 @@ +package org.cryptomator.data.db.migrations.legacy + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class Upgrade7To8 @Inject constructor() : DatabaseMigration(7, 8) { + + override fun migrateInternal(db: SupportSQLiteDatabase) { + db.beginTransaction() + try { + dropS3Vaults(db) + dropS3Clouds(db) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private fun dropS3Vaults(db: SupportSQLiteDatabase) { + Sql.deleteFrom("VAULT_ENTITY") // + .where("CLOUD_TYPE", Sql.eq("S3")) + .executeOn(db) + } + + private fun dropS3Clouds(db: SupportSQLiteDatabase) { + Sql.deleteFrom("CLOUD_ENTITY") // + .where("TYPE", Sql.eq("S3")) + .executeOn(db) + } +} diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade8To9.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade8To9.kt similarity index 54% rename from data/src/main/java/org/cryptomator/data/db/Upgrade8To9.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade8To9.kt index a3fa283bc..fd9ce59fd 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade8To9.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade8To9.kt @@ -1,14 +1,15 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration import org.cryptomator.util.SharedPreferencesHandler -import org.greenrobot.greendao.database.Database import javax.inject.Inject import javax.inject.Singleton @Singleton -internal class Upgrade8To9 @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) : DatabaseUpgrade(8, 9) { +internal class Upgrade8To9 @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) : DatabaseMigration(8, 9) { - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { // toggle beta screen dialog already shown to display it again in this beta sharedPreferencesHandler.setBetaScreenDialogAlreadyShown(false) } diff --git a/data/src/main/java/org/cryptomator/data/db/Upgrade9To10.kt b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade9To10.kt similarity index 79% rename from data/src/main/java/org/cryptomator/data/db/Upgrade9To10.kt rename to data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade9To10.kt index 01cf79fa5..50c964edd 100644 --- a/data/src/main/java/org/cryptomator/data/db/Upgrade9To10.kt +++ b/data/src/main/java/org/cryptomator/data/db/migrations/legacy/Upgrade9To10.kt @@ -1,18 +1,20 @@ -package org.cryptomator.data.db +package org.cryptomator.data.db.migrations.legacy +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import org.cryptomator.data.db.migrations.Sql import org.cryptomator.domain.CloudType import org.cryptomator.util.SharedPreferencesHandler -import org.greenrobot.greendao.database.Database import javax.inject.Inject import javax.inject.Singleton import timber.log.Timber @Singleton -internal class Upgrade9To10 @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) : DatabaseUpgrade(9, 10) { +internal class Upgrade9To10 @Inject constructor(private val sharedPreferencesHandler: SharedPreferencesHandler) : DatabaseMigration(9, 10) { private val defaultLocalStorageCloudId = 4L - override fun internalApplyTo(db: Database, origin: Int) { + override fun migrateInternal(db: SupportSQLiteDatabase) { db.beginTransaction() try { diff --git a/data/src/main/java/org/cryptomator/data/db/migrations/manual/Migration13To14.kt b/data/src/main/java/org/cryptomator/data/db/migrations/manual/Migration13To14.kt new file mode 100644 index 000000000..a90ec3014 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/migrations/manual/Migration13To14.kt @@ -0,0 +1,32 @@ +package org.cryptomator.data.db.migrations.manual + +import androidx.sqlite.db.SupportSQLiteDatabase +import org.cryptomator.data.db.DatabaseMigration +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class Migration13To14 @Inject constructor() : DatabaseMigration(13, 14) { + + //After migrating from v13 to v14 the database differs as follows: + //1a) The `room_master_table` exists and contains id data + + //A v14 database created by room differs from an migrated v14 database as follows: + //1b) The `room_master_table` exists and contains id data + //2) The foreign key "VAULT_ENTITY.FOLDER_CLOUD_ID -> CLOUD_ENTITY._id" is unnamed + //3) The index "IDX_VAULT_ENTITY_FOLDER_PATH_FOLDER_CLOUD_ID" is formatted differently internally + //4) The database does not contain actual data + //-- This does never happen in practice as databases are created by upgrading, but illustrates the slightly different schemas. + //1a,b and 4 are intended behavior, but 2 and 3 cause the schema and the actual database to slightly drift out of sync. + //This *should* not cause any problems. + //The migration to v15 then recreates the tables and therefore resolves 2 and 3 as a side effect. Once the migration to + //v15 has finished, the schema and the database are back in sync. + + //Since this is a bit hacky, "UpgradeDatabaseTest" contains the "migrate13To15IndexSideEffects" + //and "migrate13To15ForeignKeySideEffects" methods to bring attention to any potential future changes + //that change this behavior. + + override fun migrateInternal(db: SupportSQLiteDatabase) { + //NO-OP + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/sqlmapping/AOP_SQLiteDatabase.java b/data/src/main/java/org/cryptomator/data/db/sqlmapping/AOP_SQLiteDatabase.java new file mode 100644 index 000000000..b297828b2 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/sqlmapping/AOP_SQLiteDatabase.java @@ -0,0 +1,98 @@ +/* + * Copyright (C) 2006 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * ** Notice of Modification ** + * + * This file has been altered from its original version by the Cryptomator team. + * For a detailed history of modifications, please refer to the version control log. + * + * The original file can be found at https://android.googlesource.com/platform/frameworks/base/+/7d3ffbae618e9e728644a96647ed709bf39ae75/core/java/android/database/sqlite/SQLiteDatabase.java + * + * -- + * + * https://cryptomator.org/ + */ + +package org.cryptomator.data.db.sqlmapping; + +import android.content.ContentValues; + +import static org.cryptomator.data.db.sqlmapping.HelpersKt.compatIsEmpty; + +final class AOP_SQLiteDatabase { + + private static final String[] CONFLICT_VALUES = new String[] {"", " OR ROLLBACK ", " OR ABORT ", " OR FAIL ", " OR IGNORE ", " OR REPLACE "}; + + InsertStatement insertWithOnConflict(String table, String nullColumnHack, ContentValues initialValues, int conflictAlgorithm) { + //acquireReference(); + try { + StringBuilder sql = new StringBuilder(); + sql.append("INSERT"); + sql.append(CONFLICT_VALUES[conflictAlgorithm]); + sql.append(" INTO "); + sql.append(table); + sql.append('('); + + Object[] bindArgs = null; + //int size = (initialValues != null && !initialValues.isEmpty()) ? initialValues.size() : 0; + int size = (initialValues != null && !compatIsEmpty(initialValues)) ? initialValues.size() : 0; + if (size > 0) { + bindArgs = new Object[size]; + int i = 0; + for (String colName : initialValues.keySet()) { + sql.append((i > 0) ? "," : ""); + sql.append(colName); + bindArgs[i++] = initialValues.get(colName); + } + sql.append(')'); + sql.append(" VALUES ("); + for (i = 0; i < size; i++) { + sql.append((i > 0) ? ",?" : "?"); + } + } else { + sql.append(nullColumnHack).append(") VALUES (NULL"); + } + sql.append(')'); + + return new InsertStatement(sql.toString(), bindArgs); + } finally { + //releaseReference(); + } + } + + boolean isValidConflictAlgorithm(int conflictAlgorithm) { + return conflictAlgorithm >= 0 && conflictAlgorithm < CONFLICT_VALUES.length; + } + + static class InsertStatement { + + private final String sql; + private final Object[] bindArgs; + + public InsertStatement(String sql, Object[] bindArgs) { + this.sql = sql; + this.bindArgs = bindArgs; + } + + public String getSql() { + return sql; + } + + public Object[] getBindArgs() { + return bindArgs; + } + } +} + diff --git a/data/src/main/java/org/cryptomator/data/db/sqlmapping/Helpers.kt b/data/src/main/java/org/cryptomator/data/db/sqlmapping/Helpers.kt new file mode 100644 index 000000000..dc462958f --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/sqlmapping/Helpers.kt @@ -0,0 +1,25 @@ +package org.cryptomator.data.db.sqlmapping + +import android.content.ContentValues +import android.os.Build + +internal fun ContentValues.compatIsEmpty(): Boolean { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + isEmpty + } else { + size() == 0 + } +} + +internal fun MutableList.setLeniently(index: Int, element: E?): E? { + val limit = index - size + for (i in 0..limit) { //The size of the range is 0 if limit < 0, 1 if limit == 0 and limit + 1 else. + add(null) + } + return set(index, element) +} + +internal inline fun Sequence.toArray(size: Int): Array { + val iterator = iterator() + return Array(size) { iterator.next() } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/sqlmapping/MappingSupportSQLiteDatabase.kt b/data/src/main/java/org/cryptomator/data/db/sqlmapping/MappingSupportSQLiteDatabase.kt new file mode 100644 index 000000000..774774e59 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/sqlmapping/MappingSupportSQLiteDatabase.kt @@ -0,0 +1,241 @@ +package org.cryptomator.data.db.sqlmapping + +import android.content.ContentValues +import android.database.Cursor +import android.database.sqlite.SQLiteException +import android.os.CancellationSignal +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.SupportSQLiteProgram +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.sqlite.db.SupportSQLiteStatement +import org.jetbrains.annotations.VisibleForTesting + +@VisibleForTesting +internal class MappingSupportSQLiteDatabase( + private val delegate: SupportSQLiteDatabase, + private val mappingFunction: SQLMappingFunction +) : SupportSQLiteDatabase by delegate { + + override fun execSQL(sql: String) { + return delegate.execSQL(map(sql)) + } + + override fun execSQL(sql: String, bindArgs: Array) { + return delegate.execSQL(map(sql), bindArgs) + } + + override fun query(query: SupportSQLiteQuery): Cursor { + return mapCursor(delegate.query(map(query))) + } + + override fun query(query: SupportSQLiteQuery, cancellationSignal: CancellationSignal?): Cursor { + return mapCursor(delegate.query(map(query), cancellationSignal)) + } + + override fun query(query: String): Cursor { + return mapCursor(delegate.query(map(query))) + } + + override fun query(query: String, bindArgs: Array): Cursor { + return mapCursor(delegate.query(map(query), bindArgs)) + } + + override fun insert(table: String, conflictAlgorithm: Int, values: ContentValues): Long { + if (values.compatIsEmpty()) { + throw SQLiteException("Can't insert empty set of values") + } + if (!helper.isValidConflictAlgorithm(conflictAlgorithm)) { + throw SQLiteException("Invalid conflict algorithm") + } + val processed = helper.insertWithOnConflict(table, null, values, conflictAlgorithm) + val statement = delegate.compileStatement(map(processed.sql)) + SimpleSQLiteQuery.bind(statement, processed.bindArgs) + + return statement.use { it.executeInsert() } + } + + override fun update( + table: String, + conflictAlgorithm: Int, + values: ContentValues, + whereClause: String?, + whereArgs: Array? + ): Int { + return delegate.update(table, conflictAlgorithm, values, mapWhereClause(whereClause), whereArgs) + } + + override fun delete(table: String, whereClause: String?, whereArgs: Array?): Int { + return delegate.delete(table, mapWhereClause(whereClause), whereArgs) + } + + override fun execPerConnectionSQL(sql: String, bindArgs: Array?) { + delegate.execPerConnectionSQL(map(sql), bindArgs) + } + + override fun compileStatement(sql: String): SupportSQLiteStatement { + if (!isOpen) { + throw SQLiteException("Database already closed") + } + return MappingSupportSQLiteStatement(sql) + } + + private fun map(sql: String): String { + return mappingFunction.map(sql) + } + + private fun map(query: SupportSQLiteQuery): SupportSQLiteQuery { + return MappingSupportSQLiteQuery(query) + } + + private fun mapCursor(cursor: Cursor): Cursor { + return mappingFunction.mapCursor(cursor) + } + + private fun mapWhereClause(whereClause: String?): String? { + if (whereClause != null && whereClause.isBlank()) { + throw IllegalArgumentException() + } + return mappingFunction.mapWhereClause(whereClause) + } + + + @VisibleForTesting + internal inner class MappingSupportSQLiteStatement( + private val sql: String + ) : SupportSQLiteStatement { + + private val bindings = mutableListOf() + + private fun saveBinding(index: Int, value: Any?): Any? = synchronized(bindings) { + return@synchronized bindings.setLeniently(index - 1, value) + } + + override fun bindBlob(index: Int, value: ByteArray) { + saveBinding(index, value.copyOf()) + } + + override fun bindDouble(index: Int, value: Double) { + saveBinding(index, value) + } + + override fun bindLong(index: Int, value: Long) { + saveBinding(index, value) + } + + override fun bindNull(index: Int) { + saveBinding(index, null) + } + + override fun bindString(index: Int, value: String) { + saveBinding(index, value) + } + + override fun clearBindings(): Unit = synchronized(bindings) { + return@synchronized bindings.clear() + } + + override fun close() { + //NO-OP + } + + override fun execute() { + newBoundStatement().use { it.execute() } + } + + override fun executeInsert(): Long { + return newBoundStatement().use { it.executeInsert() } + } + + override fun executeUpdateDelete(): Int { + return newBoundStatement().use { it.executeUpdateDelete() } + } + + override fun simpleQueryForLong(): Long { + return newBoundStatement().use { it.simpleQueryForLong() } + } + + override fun simpleQueryForString(): String? { + return newBoundStatement().use { it.simpleQueryForString() } + } + + @VisibleForTesting + internal fun newBoundStatement(): SupportSQLiteStatement { + return delegate.compileStatement(map(sql)).also { statement -> + SimpleSQLiteQuery.bind(statement, prepareBindArgs()) + } + } + + private fun prepareBindArgs(): Array = synchronized(bindings) { + return@synchronized bindings.asSequence().map { binding -> prepareSingleBindArg(binding) }.toArray(bindings.size) + } + + private fun prepareSingleBindArg(binding: Any?): Any? { + return when (binding) { + is ByteArray -> binding.copyOf() + else -> binding + } + } + } + + @VisibleForTesting + internal inner class MappingSupportSQLiteQuery( + private val delegateQuery: SupportSQLiteQuery + ) : SupportSQLiteQuery by delegateQuery { + + private val _sql = map(delegateQuery.sql) + private val sqlDelegate = OneOffDelegate { throw IllegalStateException("SQL queried twice") } + private val bindToDelegate = OneOffDelegate { throw IllegalStateException("bindTo called twice") } + + override val sql: String + get() = sqlDelegate.call { _sql } + + override fun bindTo(statement: SupportSQLiteProgram) { + bindToDelegate.call { delegateQuery.bindTo(statement) } + } + } +} + +private val helper = AOP_SQLiteDatabase() + +private class MappingSupportSQLiteOpenHelper( + private val delegate: SupportSQLiteOpenHelper, + private val mappingFunction: SQLMappingFunction +) : SupportSQLiteOpenHelper by delegate { + + override val writableDatabase: SupportSQLiteDatabase + get() = MappingSupportSQLiteDatabase(delegate.writableDatabase, mappingFunction) + + override val readableDatabase: SupportSQLiteDatabase + get() = MappingSupportSQLiteDatabase(delegate.readableDatabase, mappingFunction) +} + +private class MappingSupportSQLiteOpenHelperFactory( + private val delegate: SupportSQLiteOpenHelper.Factory, + private val mappingFunction: SQLMappingFunction +) : SupportSQLiteOpenHelper.Factory { + + override fun create( + configuration: SupportSQLiteOpenHelper.Configuration + ): SupportSQLiteOpenHelper { + return MappingSupportSQLiteOpenHelper(delegate.create(configuration), mappingFunction) + } +} + +fun SupportSQLiteOpenHelper.Factory.asMapped(mappingFunction: SQLMappingFunction): SupportSQLiteOpenHelper.Factory { + return MappingSupportSQLiteOpenHelperFactory(this, mappingFunction) +} + +/** + * Implementations must be threadsafe. + */ +interface SQLMappingFunction { + + fun map(sql: String): String + + fun mapWhereClause(whereClause: String?): String? + + fun mapCursor(cursor: Cursor): Cursor + +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/sqlmapping/OneOffDelegate.kt b/data/src/main/java/org/cryptomator/data/db/sqlmapping/OneOffDelegate.kt new file mode 100644 index 000000000..8d88ad43d --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/sqlmapping/OneOffDelegate.kt @@ -0,0 +1,15 @@ +package org.cryptomator.data.db.sqlmapping + +internal class OneOffDelegate(private val beforeExtraCalls: () -> Unit) { + + private val lock = Any() + private var called = false + + fun call(delegateCallable: () -> R): R = synchronized(lock) { + if (called) { + beforeExtraCalls() + } + called = true + return delegateCallable() + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateComponent.kt b/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateComponent.kt new file mode 100644 index 000000000..44131756b --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateComponent.kt @@ -0,0 +1,19 @@ +package org.cryptomator.data.db.templating + +import java.io.InputStream +import dagger.Subcomponent + +@DbTemplateScoped +@Subcomponent(modules = [DbTemplateModule::class]) +interface DbTemplateComponent { + + @DbTemplateScoped + fun templateStream(): InputStream + + @Subcomponent.Factory + interface Factory { + + fun create(): DbTemplateComponent + + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateModule.kt b/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateModule.kt new file mode 100644 index 000000000..1061cbda0 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateModule.kt @@ -0,0 +1,126 @@ +package org.cryptomator.data.db.templating + +import android.content.Context +import android.content.ContextWrapper +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import org.cryptomator.data.db.DATABASE_NAME +import org.cryptomator.data.db.applyDefaultConfiguration +import org.cryptomator.data.db.migrations.legacy.Upgrade0To1 +import org.cryptomator.data.util.useFinally +import org.cryptomator.util.ThreadUtil +import org.cryptomator.util.named +import java.io.File +import java.io.InputStream +import java.nio.file.Files +import java.nio.file.LinkOption +import java.nio.file.Path +import javax.inject.Inject +import dagger.Module +import dagger.Provides +import kotlin.io.path.Path +import kotlin.io.path.fileSize +import kotlin.io.path.isRegularFile +import kotlin.io.path.readBytes +import timber.log.Timber + +private val LOG = Timber.Forest.named("DbTemplateModule") + +@Module +class DbTemplateModule { + + @DbTemplateScoped + @Provides + internal fun provideDbTemplateStream(configuration: SupportSQLiteOpenHelper.Configuration): InputStream { + LOG.d("Creating database template file") + ThreadUtil.assumeNotMainThread() + + val templateDatabaseContext = configuration.context + require(templateDatabaseContext is TemplateDatabaseContext) + var parentDir: Path? = null + return useFinally({ + parentDir = Files.createTempDirectory(configuration.context.cacheDir.toPath(), "DbTemplate") + val templateFile: Path = parentDir!!.resolve(DATABASE_NAME) + templateDatabaseContext.templateFile = templateFile.toFile() + + val initializedPath = FrameworkSQLiteOpenHelperFactory().create(configuration).use { openHelper -> + openHelper.setWriteAheadLoggingEnabled(false) + initDatabase(openHelper) + } + verifyTemplate(templateFile, Path(requireNotNull(initializedPath))) + + val length: Long = templateFile.fileSize() + require(length > 0L && length < Int.MAX_VALUE.toLong()) { "Template database file must be readable and smaller than 2 GB; template file at \"$templateFile\" is $length B long" } + LOG.d("Created database template file ($length B) at \"$templateFile\"") + + //If this method throws an OutOfMemoryError, the db template mostly likely was larger than 2GB + return@useFinally templateFile.readBytes().inputStream().also { + LOG.d("Created database template stream (${it.available()} B) from \"$templateFile\"") + } + }, finallyBlock = { + try { + if (parentDir?.toFile()?.deleteRecursively() == false) { + LOG.w("Failed to clean up template database file in \"$parentDir\"") + } + } catch (e: Exception) { + LOG.e(e, "Exception while cleaning up template database file in \"$parentDir\"") + } + }) + } + + private fun initDatabase(openHelper: SupportSQLiteOpenHelper): String? { + return openHelper.writableDatabase.use { + require(it.version == 1) + require(it.compileStatement("SELECT COUNT(*) FROM `CLOUD_ENTITY`").simpleQueryForLong() == 4L) + it.path + } + } + + private fun verifyTemplate(templateFile: Path, initializedPath: Path) { + require(Files.isSameFile(templateFile, initializedPath)) + require(templateFile.isRegularFile(LinkOption.NOFOLLOW_LINKS)) + if (templateFile != initializedPath) { + LOG.i("\"$templateFile\" was initialized at different path (\"$initializedPath\")") + } + } + + @DbTemplateScoped + @Provides + internal fun provideConfiguration(templateDatabaseContext: TemplateDatabaseContext): SupportSQLiteOpenHelper.Configuration { + return SupportSQLiteOpenHelper.Configuration.builder(templateDatabaseContext) // + .name(DATABASE_NAME) // + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onConfigure(db: SupportSQLiteDatabase) = db.applyDefaultConfiguration( // + assertedWalEnabledStatus = false // + ) + + override fun onCreate(db: SupportSQLiteDatabase) { + Upgrade0To1().migrate(db) + } + + override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) { + throw IllegalStateException("Template may not be upgraded") + } + }).build() + } +} + +@DbTemplateScoped +internal class TemplateDatabaseContext @Inject constructor(context: Context) : ContextWrapper(context) { + + internal var templateFile: File? + get() = _templateFile + set(value) { + require(_templateFile == null) + requireNotNull(value) + _templateFile = value + } + + private var _templateFile: File? = null + + override fun getDatabasePath(name: String?): File { + require(name == DATABASE_NAME) + return requireNotNull(templateFile) { "Template file should be set by \"provideDbTemplateStream\"" } + } +} \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateScoped.kt b/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateScoped.kt new file mode 100644 index 000000000..1cfefdb86 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/db/templating/DbTemplateScoped.kt @@ -0,0 +1,6 @@ +package org.cryptomator.data.db.templating; + +import javax.inject.Scope; + +@Scope +annotation class DbTemplateScoped \ No newline at end of file diff --git a/data/src/main/java/org/cryptomator/data/repository/CloudRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/CloudRepositoryImpl.java index f08137f2f..ad1233721 100644 --- a/data/src/main/java/org/cryptomator/data/repository/CloudRepositoryImpl.java +++ b/data/src/main/java/org/cryptomator/data/repository/CloudRepositoryImpl.java @@ -3,8 +3,7 @@ import com.google.common.base.Optional; import org.cryptomator.data.cloud.crypto.CryptoCloudFactory; -import org.cryptomator.data.db.Database; -import org.cryptomator.data.db.entities.CloudEntity; +import org.cryptomator.data.db.CloudDao; import org.cryptomator.data.db.mappers.CloudEntityMapper; import org.cryptomator.domain.Cloud; import org.cryptomator.domain.CloudFolder; @@ -25,7 +24,7 @@ @Singleton class CloudRepositoryImpl implements CloudRepository { - private final Database database; + private final CloudDao cloudDao; private final CryptoCloudFactory cryptoCloudFactory; private final CloudEntityMapper mapper; private final DispatchingCloudContentRepository dispatchingCloudContentRepository; @@ -33,9 +32,9 @@ class CloudRepositoryImpl implements CloudRepository { @Inject public CloudRepositoryImpl(CloudEntityMapper mapper, // CryptoCloudFactory cryptoCloudFactory, // - Database database, // + CloudDao cloudDao, // DispatchingCloudContentRepository dispatchingCloudContentRepository) { - this.database = database; + this.cloudDao = cloudDao; this.cryptoCloudFactory = cryptoCloudFactory; this.mapper = mapper; this.dispatchingCloudContentRepository = dispatchingCloudContentRepository; @@ -44,7 +43,7 @@ public CloudRepositoryImpl(CloudEntityMapper mapper, // @Override public List clouds(CloudType cloudType) throws BackendException { List cloudsFromType = new ArrayList<>(); - List allClouds = mapper.fromEntities(database.loadAll(CloudEntity.class)); + List allClouds = mapper.fromEntities(cloudDao.loadAll()); for (Cloud cloud : allClouds) { if (cloud.type().equals(cloudType)) { @@ -57,7 +56,7 @@ public List clouds(CloudType cloudType) throws BackendException { @Override public List allClouds() throws BackendException { - return mapper.fromEntities(database.loadAll(CloudEntity.class)); + return mapper.fromEntities(cloudDao.loadAll()); } @Override @@ -66,8 +65,7 @@ public Cloud store(Cloud cloud) { throw new IllegalArgumentException("Can not store non persistent cloud"); } - Cloud storedCloud = mapper.fromEntity(database.store(mapper.toEntity(cloud))); - database.clearCache(); + Cloud storedCloud = mapper.fromEntity(cloudDao.storeReplacingAndReload(mapper.toEntity(cloud))); dispatchingCloudContentRepository.updateCloudContentRepositoryFor(storedCloud); @@ -79,7 +77,7 @@ public void delete(Cloud cloud) { if (!cloud.persistent()) { throw new IllegalArgumentException("Can not delete non persistent cloud"); } - database.delete(mapper.toEntity(cloud)); + cloudDao.delete(mapper.toEntity(cloud)); dispatchingCloudContentRepository.removeCloudContentRepositoryFor(cloud); } diff --git a/data/src/main/java/org/cryptomator/data/repository/RepositoryModule.java b/data/src/main/java/org/cryptomator/data/repository/RepositoryModule.java index 7bd1fff98..867088299 100644 --- a/data/src/main/java/org/cryptomator/data/repository/RepositoryModule.java +++ b/data/src/main/java/org/cryptomator/data/repository/RepositoryModule.java @@ -1,5 +1,6 @@ package org.cryptomator.data.repository; +import org.cryptomator.data.db.DatabaseModule; import org.cryptomator.domain.repository.CloudContentRepository; import org.cryptomator.domain.repository.CloudRepository; import org.cryptomator.domain.repository.UpdateCheckRepository; @@ -10,7 +11,7 @@ import dagger.Module; import dagger.Provides; -@Module +@Module(includes = {DatabaseModule.class}) public class RepositoryModule { @Singleton diff --git a/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java index 431438e41..e2f046793 100644 --- a/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java +++ b/data/src/main/java/org/cryptomator/data/repository/UpdateCheckRepositoryImpl.java @@ -11,7 +11,7 @@ import com.google.common.io.BaseEncoding; import org.apache.commons.codec.binary.Hex; -import org.cryptomator.data.db.Database; +import org.cryptomator.data.db.UpdateCheckDao; import org.cryptomator.data.db.entities.UpdateCheckEntity; import org.cryptomator.data.util.UserAgentInterceptor; import org.cryptomator.domain.exception.BackendException; @@ -48,14 +48,14 @@ public class UpdateCheckRepositoryImpl implements UpdateCheckRepository { private static final String HOSTNAME_LATEST_VERSION = "https://api.cryptomator.org/android/latest-version.json"; - private final Database database; + private final UpdateCheckDao updateCheckDao; private final OkHttpClient httpClient; private final Context context; @Inject - UpdateCheckRepositoryImpl(Database database, Context context) { + UpdateCheckRepositoryImpl(UpdateCheckDao updateCheckDao, Context context) { this.httpClient = httpClient(); - this.database = database; + this.updateCheckDao = updateCheckDao; this.context = context; } @@ -73,7 +73,7 @@ public Optional getUpdateCheck(final String appVersion) throws Back return Optional.absent(); } - final UpdateCheckEntity entity = database.load(UpdateCheckEntity.class, 1L); + final UpdateCheckEntity entity = updateCheckDao.load(1L); if (entity.getVersion() != null && entity.getVersion().equals(latestVersion.version) && entity.getApkSha256() != null) { return Optional.of(new UpdateCheckImpl("", entity)); @@ -84,7 +84,7 @@ public Optional getUpdateCheck(final String appVersion) throws Back entity.setVersion(updateCheck.getVersion()); entity.setApkSha256(updateCheck.getApkSha256()); - database.store(entity); + updateCheckDao.storeReplacing(entity); return Optional.of(updateCheck); } @@ -92,22 +92,22 @@ public Optional getUpdateCheck(final String appVersion) throws Back @Nullable @Override public String getLicense() { - return database.load(UpdateCheckEntity.class, 1L).getLicenseToken(); + return updateCheckDao.load(1L).getLicenseToken(); } @Override public void setLicense(String license) { - final UpdateCheckEntity entity = database.load(UpdateCheckEntity.class, 1L); + final UpdateCheckEntity entity = updateCheckDao.load(1L); entity.setLicenseToken(license); - database.store(entity); + updateCheckDao.storeReplacing(entity); } @Override public void update(File file) throws GeneralUpdateErrorException { try { - final UpdateCheckEntity entity = database.load(UpdateCheckEntity.class, 1L); + final UpdateCheckEntity entity = updateCheckDao.load(1L); final Request request = new Request // .Builder() // diff --git a/data/src/main/java/org/cryptomator/data/repository/VaultRepositoryImpl.java b/data/src/main/java/org/cryptomator/data/repository/VaultRepositoryImpl.java index 2c4e17934..bc6d23a45 100644 --- a/data/src/main/java/org/cryptomator/data/repository/VaultRepositoryImpl.java +++ b/data/src/main/java/org/cryptomator/data/repository/VaultRepositoryImpl.java @@ -4,8 +4,7 @@ import org.cryptomator.data.cloud.crypto.CryptoCloudContentRepositoryFactory; import org.cryptomator.data.cloud.crypto.CryptoCloudFactory; -import org.cryptomator.data.db.Database; -import org.cryptomator.data.db.entities.VaultEntity; +import org.cryptomator.data.db.VaultDao; import org.cryptomator.data.db.mappers.VaultEntityMapper; import org.cryptomator.domain.Vault; import org.cryptomator.domain.exception.BackendException; @@ -23,7 +22,7 @@ @Singleton class VaultRepositoryImpl implements VaultRepository { - private final Database database; + private final VaultDao vaultDao; private final VaultEntityMapper mapper; private final CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory; private final DispatchingCloudContentRepository dispatchingCloudContentRepository; @@ -35,9 +34,9 @@ public VaultRepositoryImpl( // CryptoCloudContentRepositoryFactory cryptoCloudContentRepositoryFactory, // CryptoCloudFactory cryptoCloudFactory, // DispatchingCloudContentRepository dispatchingCloudContentRepository, // - Database database) { + VaultDao vaultDao) { this.mapper = mapper; - this.database = database; + this.vaultDao = vaultDao; this.cryptoCloudContentRepositoryFactory = cryptoCloudContentRepositoryFactory; this.cryptoCloudFactory = cryptoCloudFactory; this.dispatchingCloudContentRepository = dispatchingCloudContentRepository; @@ -46,7 +45,7 @@ public VaultRepositoryImpl( // @Override public List vaults() throws BackendException { List result = new ArrayList<>(); - for (Vault vault : mapper.fromEntities(database.loadAll(VaultEntity.class))) { + for (Vault vault : mapper.fromEntities(vaultDao.loadAll())) { result.add(aCopyOf(vault).withUnlocked(isUnlocked(vault)).build()); } return result; @@ -55,7 +54,7 @@ public List vaults() throws BackendException { @Override public Vault store(Vault vault) throws BackendException { try { - return mapper.fromEntity(database.store(mapper.toEntity(vault))); + return mapper.fromEntity(vaultDao.storeReplacingAndReload(mapper.toEntity(vault))); } catch (SQLiteConstraintException e) { throw new VaultAlreadyExistException(); } @@ -65,13 +64,13 @@ public Vault store(Vault vault) throws BackendException { public Long delete(Vault vault) throws BackendException { deregisterUnlocked(vault); dispatchingCloudContentRepository.removeCloudContentRepositoryFor(cryptoCloudFactory.decryptedViewOf(vault)); - database.delete(mapper.toEntity(vault)); + vaultDao.delete(mapper.toEntity(vault)); return vault.getId(); } @Override public Vault load(Long id) throws BackendException { - Vault vault = mapper.fromEntity(database.load(VaultEntity.class, id)); + Vault vault = mapper.fromEntity(vaultDao.load(id)); return aCopyOf(vault).withUnlocked(isUnlocked(vault)).build(); } diff --git a/data/src/main/java/org/cryptomator/data/util/Utils.kt b/data/src/main/java/org/cryptomator/data/util/Utils.kt new file mode 100644 index 000000000..ed6e20705 --- /dev/null +++ b/data/src/main/java/org/cryptomator/data/util/Utils.kt @@ -0,0 +1,44 @@ +@file:JvmName("Utils") + +package org.cryptomator.data.util + +import java.io.Closeable + +fun String?.blankToNull(): String? { + return if (isNullOrBlank()) null else this +} + +fun Array?.emptyToNull(): Array? { + return if (isNullOrEmpty()) null else this +} + +fun String?.requireNullOrNotBlank(): String? { + if (this != null && this.isBlank()) { + throw IllegalArgumentException("String is blank") + } + return this +} + +/** + * Executes the given [tryBlock] function on this object and then always executes the [finallyBlock] + * whether an exception is thrown or not, similar to a regular `finally` block. + * + * When using a regular `finally` block, an exception thrown by it will cause any exceptions + * thrown by the `try` block to be discarded. + * In contrast, [use][kotlin.io.use] and this method ensure that exceptions thrown by the [finallyBlock] do not suppress + * exceptions thrown by the [tryBlock.][tryBlock] If both the [tryBlock] and the [finallyBlock] throw exceptions, + * the exception from the [finallyBlock] is added to the list of suppressed exceptions of the exception + * thrown by the [tryBlock.][tryBlock] + * + * This method effectively turns any object into a [Closeable,][Closeable] where the [finallyBlock] is used as the implementation of the [Closeable.close] method + * and the [tryBlock] is executed on it with [use.][kotlin.io.use] + * + * @see kotlin.io.use + */ +inline fun T.useFinally(tryBlock: (T) -> R, crossinline finallyBlock: (T) -> Unit): R { + return Closeable { + finallyBlock(this) + }.use { + tryBlock(this) + } +} \ No newline at end of file diff --git a/data/src/test/java/org/cryptomator/data/db/BomVerificationTest.kt b/data/src/test/java/org/cryptomator/data/db/BomVerificationTest.kt new file mode 100644 index 000000000..b0762a3d2 --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/db/BomVerificationTest.kt @@ -0,0 +1,186 @@ +package org.cryptomator.data.db + +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.sqlite.db.SupportSQLiteStatement +import org.junit.Assert.assertEquals +import org.junit.Test +import java.lang.reflect.Field +import java.lang.reflect.Method +import java.util.SortedSet + +class BomVerificationTest { + + @Test //For org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabase + fun verifySupportSQLiteDatabase() { + val bom = sortedSetOf( + "androidx.sqlite.db.SupportSQLiteDatabase.beginTransaction(): void", + "androidx.sqlite.db.SupportSQLiteDatabase.beginTransactionNonExclusive(): void", + "androidx.sqlite.db.SupportSQLiteDatabase.beginTransactionWithListener(android.database.sqlite.SQLiteTransactionListener): void", + "androidx.sqlite.db.SupportSQLiteDatabase.beginTransactionWithListenerNonExclusive(android.database.sqlite.SQLiteTransactionListener): void", + "java.io.Closeable.close(): void", + "androidx.sqlite.db.SupportSQLiteDatabase.compileStatement(java.lang.String): androidx.sqlite.db.SupportSQLiteStatement", + "androidx.sqlite.db.SupportSQLiteDatabase.delete(java.lang.String, java.lang.String, [Ljava.lang.Object;): int", + "androidx.sqlite.db.SupportSQLiteDatabase.disableWriteAheadLogging(): void", + "androidx.sqlite.db.SupportSQLiteDatabase.enableWriteAheadLogging(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.endTransaction(): void", + "androidx.sqlite.db.SupportSQLiteDatabase.execPerConnectionSQL(java.lang.String, [Ljava.lang.Object;): void", + "androidx.sqlite.db.SupportSQLiteDatabase.execSQL(java.lang.String): void", + "androidx.sqlite.db.SupportSQLiteDatabase.execSQL(java.lang.String, [Ljava.lang.Object;): void", + "androidx.sqlite.db.SupportSQLiteDatabase.getAttachedDbs(): java.util.List", + "androidx.sqlite.db.SupportSQLiteDatabase.getMaximumSize(): long", + "androidx.sqlite.db.SupportSQLiteDatabase.getPageSize(): long", + "androidx.sqlite.db.SupportSQLiteDatabase.getPath(): java.lang.String", + "androidx.sqlite.db.SupportSQLiteDatabase.getVersion(): int", + "androidx.sqlite.db.SupportSQLiteDatabase.inTransaction(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.insert(java.lang.String, int, android.content.ContentValues): long", + "androidx.sqlite.db.SupportSQLiteDatabase.isDatabaseIntegrityOk(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.isDbLockedByCurrentThread(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.isExecPerConnectionSQLSupported(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.isOpen(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.isReadOnly(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.isWriteAheadLoggingEnabled(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.needUpgrade(int): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.query(androidx.sqlite.db.SupportSQLiteQuery): android.database.Cursor", + "androidx.sqlite.db.SupportSQLiteDatabase.query(java.lang.String): android.database.Cursor", + "androidx.sqlite.db.SupportSQLiteDatabase.query(androidx.sqlite.db.SupportSQLiteQuery, android.os.CancellationSignal): android.database.Cursor", + "androidx.sqlite.db.SupportSQLiteDatabase.query(java.lang.String, [Ljava.lang.Object;): android.database.Cursor", + "androidx.sqlite.db.SupportSQLiteDatabase.setForeignKeyConstraintsEnabled(boolean): void", + "androidx.sqlite.db.SupportSQLiteDatabase.setLocale(java.util.Locale): void", + "androidx.sqlite.db.SupportSQLiteDatabase.setMaxSqlCacheSize(int): void", + "androidx.sqlite.db.SupportSQLiteDatabase.setMaximumSize(long): long", + "androidx.sqlite.db.SupportSQLiteDatabase.setPageSize(long): void", + "androidx.sqlite.db.SupportSQLiteDatabase.setTransactionSuccessful(): void", + "androidx.sqlite.db.SupportSQLiteDatabase.setVersion(int): void", + "androidx.sqlite.db.SupportSQLiteDatabase.update(java.lang.String, int, android.content.ContentValues, java.lang.String, [Ljava.lang.Object;): int", + "androidx.sqlite.db.SupportSQLiteDatabase.yieldIfContendedSafely(): boolean", + "androidx.sqlite.db.SupportSQLiteDatabase.yieldIfContendedSafely(long): boolean" + ) + assertEquals(bom, SupportSQLiteDatabase::class.java.getFieldAndMethodNames()) + } + + @Test //For org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabase.MappingSupportSQLiteStatement + fun verifySupportSQLiteStatement() { + val bom = sortedSetOf( + "androidx.sqlite.db.SupportSQLiteProgram.bindBlob(int, [B): void", + "androidx.sqlite.db.SupportSQLiteProgram.bindDouble(int, double): void", + "androidx.sqlite.db.SupportSQLiteProgram.bindLong(int, long): void", + "androidx.sqlite.db.SupportSQLiteProgram.bindNull(int): void", + "androidx.sqlite.db.SupportSQLiteProgram.bindString(int, java.lang.String): void", + "androidx.sqlite.db.SupportSQLiteProgram.clearBindings(): void", + "java.io.Closeable.close(): void", + "androidx.sqlite.db.SupportSQLiteStatement.execute(): void", + "androidx.sqlite.db.SupportSQLiteStatement.executeInsert(): long", + "androidx.sqlite.db.SupportSQLiteStatement.executeUpdateDelete(): int", + "androidx.sqlite.db.SupportSQLiteStatement.simpleQueryForLong(): long", + "androidx.sqlite.db.SupportSQLiteStatement.simpleQueryForString(): java.lang.String" + ) + assertEquals(bom, SupportSQLiteStatement::class.java.getFieldAndMethodNames()) + } + + @Test //For org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabase.MappingSupportSQLiteQuery + fun verifySupportSQLiteQuery() { + val bom = sortedSetOf( + "androidx.sqlite.db.SupportSQLiteQuery.bindTo(androidx.sqlite.db.SupportSQLiteProgram): void", + "androidx.sqlite.db.SupportSQLiteQuery.getArgCount(): int", + "androidx.sqlite.db.SupportSQLiteQuery.getSql(): java.lang.String" + ) + assertEquals(bom, SupportSQLiteQuery::class.java.getFieldAndMethodNames()) + } + + @Test //For org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteOpenHelper + fun verifySupportSQLiteOpenHelper() { + val bom = sortedSetOf( + "androidx.sqlite.db.SupportSQLiteOpenHelper.close(): void", + "androidx.sqlite.db.SupportSQLiteOpenHelper.getDatabaseName(): java.lang.String", + "androidx.sqlite.db.SupportSQLiteOpenHelper.getReadableDatabase(): androidx.sqlite.db.SupportSQLiteDatabase", + "androidx.sqlite.db.SupportSQLiteOpenHelper.getWritableDatabase(): androidx.sqlite.db.SupportSQLiteDatabase", + "androidx.sqlite.db.SupportSQLiteOpenHelper.setWriteAheadLoggingEnabled(boolean): void" + ) + assertEquals(bom, SupportSQLiteOpenHelper::class.java.getFieldAndMethodNames()) + } + + @Test //For org.cryptomator.data.db.PatchedCallback + fun verifySupportSQLiteOpenHelperCallback() { + val bom = sortedSetOf( + "androidx.sqlite.db.SupportSQLiteOpenHelper#Callback.version: int", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Callback.onConfigure(androidx.sqlite.db.SupportSQLiteDatabase): void", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Callback.onCorruption(androidx.sqlite.db.SupportSQLiteDatabase): void", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Callback.onCreate(androidx.sqlite.db.SupportSQLiteDatabase): void", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Callback.onDowngrade(androidx.sqlite.db.SupportSQLiteDatabase, int, int): void", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Callback.onOpen(androidx.sqlite.db.SupportSQLiteDatabase): void", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Callback.onUpgrade(androidx.sqlite.db.SupportSQLiteDatabase, int, int): void", + // + "androidx.sqlite.db.SupportSQLiteOpenHelper#Callback.Companion: androidx.sqlite.db.SupportSQLiteOpenHelper#Callback#Companion" + ) + assertEquals(bom, SupportSQLiteOpenHelper.Callback::class.java.getFieldAndMethodNames()) + } + + @Test //For org.cryptomator.data.db.DatabaseOpenHelperFactoryKt.patchConfiguration + fun verifySupportSQLiteOpenHelperConfiguration() { + val bom = sortedSetOf( + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration.allowDataLossOnRecovery: boolean", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration.callback: androidx.sqlite.db.SupportSQLiteOpenHelper#Callback", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration.context: android.content.Context", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration.name: java.lang.String", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration.useNoBackupDirectory: boolean", + // + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration.Companion: androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Companion", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration.builder(android.content.Context): androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder" + ) + assertEquals(bom, SupportSQLiteOpenHelper.Configuration::class.java.getFieldAndMethodNames()) + } + + @Test //For org.cryptomator.data.db.DatabaseOpenHelperFactoryKt.patchConfiguration + fun verifySupportSQLiteOpenHelperConfigurationBuilder() { + val bom = sortedSetOf( + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder.allowDataLossOnRecovery(boolean): androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder.callback(androidx.sqlite.db.SupportSQLiteOpenHelper#Callback): androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder.name(java.lang.String): androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder", + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder.noBackupDirectory(boolean): androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder", + // + "androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration#Builder.build(): androidx.sqlite.db.SupportSQLiteOpenHelper#Configuration" + ) + assertEquals(bom, SupportSQLiteOpenHelper.Configuration.Builder::class.java.getFieldAndMethodNames()) + } +} + +private val DEFAULT_MEMBERS = sortedSetOf( + "java.lang.Object.equals(java.lang.Object): boolean", + "java.lang.Object.getClass(): java.lang.Class", + "java.lang.Object.hashCode(): int", + "java.lang.Object.notify(): void", + "java.lang.Object.notifyAll(): void", + "java.lang.Object.toString(): java.lang.String", + "java.lang.Object.wait(): void", + "java.lang.Object.wait(long): void", + "java.lang.Object.wait(long, int): void" +) + +private val FORBIDDEN_CLASS_PREFIXES = setOf( + "android.", // + "java.", // + "javax.", // +) + +private fun Class<*>.getFieldAndMethodNames(): SortedSet { + for (prefix in FORBIDDEN_CLASS_PREFIXES) { + require(!name.startsWith(prefix)) { + "Class \"$name\" starts with invalid prefix \"$prefix\":\n" + + "Class is probably shipped with android and should not be validated in a non-android unit test." + } + } + val fieldsSequence = fields.asSequence().map { it.signature } + val methodsSequence = methods.asSequence().map { it.signature } + return (fieldsSequence + methodsSequence - DEFAULT_MEMBERS).toSortedSet() +} + +private val Field.signature: String + get() = "${declaringClass.name}.$name: ${type.name}".replace('$', '#') + +private val Method.signature: String + get() { + val parameters = parameterTypes.asSequence().map { it.name }.joinToString(", ") + return "${declaringClass.name}.$name($parameters): ${returnType.name}".replace('$', '#') + } \ No newline at end of file diff --git a/data/src/test/java/org/cryptomator/data/db/sqlmapping/MappingSupportSQLiteDatabaseTest.kt b/data/src/test/java/org/cryptomator/data/db/sqlmapping/MappingSupportSQLiteDatabaseTest.kt new file mode 100644 index 000000000..8721e1408 --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/db/sqlmapping/MappingSupportSQLiteDatabaseTest.kt @@ -0,0 +1,1133 @@ +package org.cryptomator.data.db.sqlmapping + +import org.mockito.kotlin.any as reifiedAny +import org.mockito.kotlin.anyOrNull as reifiedAnyOrNull +import android.content.ContentValues +import android.database.Cursor +import android.database.MatrixCursor +import android.database.SQLException +import android.database.sqlite.SQLiteDatabase +import android.os.CancellationSignal +import androidx.sqlite.db.SimpleSQLiteQuery +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteProgram +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.sqlite.db.SupportSQLiteStatement +import org.cryptomator.data.db.sqlmapping.Mapping.COMMENT +import org.cryptomator.data.db.sqlmapping.Mapping.COUNTER +import org.cryptomator.data.db.sqlmapping.Mapping.IDENTITY +import org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabase.MappingSupportSQLiteQuery +import org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabase.MappingSupportSQLiteStatement +import org.cryptomator.data.testing.ValueExtractor +import org.cryptomator.data.testing.anyPseudoEquals +import org.cryptomator.data.testing.anyPseudoEqualsUnlessNull +import org.cryptomator.data.testing.argCount +import org.cryptomator.data.testing.asCached +import org.cryptomator.data.testing.cartesianProductFour +import org.cryptomator.data.testing.cartesianProductThree +import org.cryptomator.data.testing.cartesianProductTwo +import org.cryptomator.data.testing.nullCount +import org.cryptomator.data.testing.toArgumentsStream +import org.cryptomator.data.testing.toBindingsMap +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertNotSame +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.Arguments +import org.junit.jupiter.params.provider.MethodSource +import org.mockito.ArgumentMatchers.anyInt +import org.mockito.ArgumentMatchers.anyLong +import org.mockito.Mockito.anyString +import org.mockito.Mockito.mock +import org.mockito.Mockito.verify +import org.mockito.Mockito.verifyNoInteractions +import org.mockito.Mockito.verifyNoMoreInteractions +import org.mockito.internal.verification.VerificationModeFactory.times +import org.mockito.invocation.InvocationOnMock +import org.mockito.kotlin.KInOrder +import org.mockito.kotlin.and +import org.mockito.kotlin.anyArray +import org.mockito.kotlin.eq +import org.mockito.kotlin.inOrder +import org.mockito.kotlin.whenever +import org.mockito.stubbing.Answer +import org.mockito.stubbing.OngoingStubbing +import java.util.LinkedList +import java.util.concurrent.atomic.AtomicInteger +import java.util.stream.Stream +import kotlin.streams.asStream + + +class MappingSupportSQLiteDatabaseTest { + + private lateinit var delegateMock: SupportSQLiteDatabase + + private lateinit var identityMapping: MappingSupportSQLiteDatabase + private lateinit var commentMapping: MappingSupportSQLiteDatabase + + @BeforeEach + fun beforeEach() { + delegateMock = mock(SupportSQLiteDatabase::class.java) + + identityMapping = MappingSupportSQLiteDatabase(delegateMock, object : SQLMappingFunction { + override fun map(sql: String): String = sql + override fun mapWhereClause(whereClause: String?): String? = whereClause + override fun mapCursor(cursor: Cursor): Cursor = cursor + }) + commentMapping = MappingSupportSQLiteDatabase(delegateMock, object : SQLMappingFunction { + override fun map(sql: String): String = "$sql -- Comment!" + override fun mapWhereClause(whereClause: String?): String = map(whereClause ?: "1 = 1") + override fun mapCursor(cursor: Cursor): Cursor = cursor //TODO + }) + } + + @Test + fun testExecSQL() { + identityMapping.execSQL("INSERT INTO `id_test` (`col`) VALUES ('test1')") + commentMapping.execSQL("INSERT INTO `comment_test` (`col`) VALUES ('test2')") + + verify(delegateMock).execSQL("INSERT INTO `id_test` (`col`) VALUES ('test1')") + verify(delegateMock).execSQL("INSERT INTO `comment_test` (`col`) VALUES ('test2') -- Comment!") + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testExecSQLWithBindings() { + identityMapping.execSQL("INSERT INTO `id_test` (`col`) VALUES (?)", arrayOf("test1")) + commentMapping.execSQL("INSERT INTO `comment_test` (`col`) VALUES (?)", arrayOf("test2")) + + verify(delegateMock).execSQL("INSERT INTO `id_test` (`col`) VALUES (?)", arrayOf("test1")) + verify(delegateMock).execSQL("INSERT INTO `comment_test` (`col`) VALUES (?) -- Comment!", arrayOf("test2")) + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testQueryString() { + whenever(delegateMock.query(anyString())).thenReturn(DUMMY_CURSOR) + + identityMapping.query("SELECT `col` FROM `id_test`") + commentMapping.query("SELECT `col` FROM `comment_test`") + + verify(delegateMock).query("SELECT `col` FROM `id_test`") + verify(delegateMock).query("SELECT `col` FROM `comment_test` -- Comment!") + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testQueryStringWithBindings() { + whenever(delegateMock.query(anyString(), anyArray())).thenReturn(DUMMY_CURSOR) + + identityMapping.query("SELECT `col` FROM `id_test` WHERE `col` = ?", arrayOf("test1")) + commentMapping.query("SELECT `col` FROM `comment_test` WHERE `col` = ?", arrayOf("test2")) + + verify(delegateMock).query("SELECT `col` FROM `id_test` WHERE `col` = ?", arrayOf("test1")) + verify(delegateMock).query("SELECT `col` FROM `comment_test` WHERE `col` = ? -- Comment!", arrayOf("test2")) + verifyNoMoreInteractions(delegateMock) + } + + + @Test + fun testQueryBindable() { + whenever(delegateMock.query(reifiedAny())).thenReturn(DUMMY_CURSOR) + + identityMapping.query(SimpleSQLiteQuery("SELECT `col` FROM `id_test`")) + commentMapping.query(SimpleSQLiteQuery("SELECT `col` FROM `comment_test`")) + + val supportSQLiteQueryProperties = newCachedSupportSQLiteQueryProperties() + verify(delegateMock).query( // + and( // + reifiedAny(), // + anyPseudoEquals(SimpleSQLiteQuery("SELECT `col` FROM `id_test`"), supportSQLiteQueryProperties) // + ) + ) + verify(delegateMock).query( // + and( // + reifiedAny(), // + anyPseudoEquals(SimpleSQLiteQuery("SELECT `col` FROM `comment_test` -- Comment!"), supportSQLiteQueryProperties) // + ) + ) + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testQueryBindableWithBindings() { + whenever(delegateMock.query(reifiedAny())).thenReturn(DUMMY_CURSOR) + + identityMapping.query(SimpleSQLiteQuery("SELECT `col` FROM `id_test` WHERE `col` = ?", arrayOf("test1"))) + commentMapping.query(SimpleSQLiteQuery("SELECT `col` FROM `comment_test` WHERE `col` = ?", arrayOf("test2"))) + + val supportSQLiteQueryProperties = newCachedSupportSQLiteQueryProperties() + verify(delegateMock).query( // + and( // + reifiedAny(), // + anyPseudoEquals(SimpleSQLiteQuery("SELECT `col` FROM `id_test` WHERE `col` = ?", arrayOf("test1")), supportSQLiteQueryProperties) // + ) + ) + verify(delegateMock).query( // + and( // + reifiedAny(), // + anyPseudoEquals(SimpleSQLiteQuery("SELECT `col` FROM `comment_test` WHERE `col` = ? -- Comment!", arrayOf("test2")), supportSQLiteQueryProperties) // + ) + ) + verifyNoMoreInteractions(delegateMock) + } + + @ParameterizedTest + @MethodSource("org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabaseTestKt#sourceForTestQueryCancelable") + fun testQueryCancelable(queries: CallData, signals: CallData) { + whenever(delegateMock.query(reifiedAny(), reifiedAnyOrNull())).thenReturn(DUMMY_CURSOR) + + identityMapping.query(queries.idCall, signals.idCall) + commentMapping.query(queries.commentCall, signals.commentCall) + + val supportSQLiteQueryProperties = newCachedSupportSQLiteQueryProperties() + verify(delegateMock).query( // + and( + reifiedAny(), // + anyPseudoEquals(queries.idExpected, supportSQLiteQueryProperties), // + ), // + anyPseudoEqualsUnlessNull(signals.idExpected, setOf>(CancellationSignal::isCanceled)) + ) + verify(delegateMock).query( // + and( + reifiedAny(), // + anyPseudoEquals(queries.commentExpected, supportSQLiteQueryProperties), // + ), // + anyPseudoEqualsUnlessNull(signals.commentExpected, setOf>(CancellationSignal::isCanceled)) + ) + verifyNoMoreInteractions(delegateMock) + } + + @ParameterizedTest + @MethodSource("org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabaseTestKt#sourceForTestInsert") + fun testInsert(arguments: CallDataTwo) { + val (idCompiledStatement: SupportSQLiteStatement, idBindings: Map) = mockSupportSQLiteStatement() + val (commentCompiledStatement: SupportSQLiteStatement, commentBindings: Map) = mockSupportSQLiteStatement() + + whenever(delegateMock.compileStatement(arguments.idExpected)).thenReturn(idCompiledStatement) + whenever(delegateMock.compileStatement(arguments.commentExpected)).thenReturn(commentCompiledStatement) + + val order = inOrder(delegateMock, idCompiledStatement, commentCompiledStatement) + + testSingleInsert(order, { identityMapping.insert("id_test", 1, arguments.idCall) }, idCompiledStatement, arguments.idExpected, arguments.idCall, idBindings, false) + testSingleInsert(order, { commentMapping.insert("comment_test", 2, arguments.commentCall) }, commentCompiledStatement, arguments.commentExpected, arguments.commentCall, commentBindings, true) + + order.verifyNoMoreInteractions() + } + + private fun testSingleInsert( // + order: KInOrder, // + toVerify: () -> Unit, // + compiledStatement: SupportSQLiteStatement, // + expected: String, // + values: ContentValues, // + bindings: Map, // + lastTest: Boolean // + ) { + val valueSet: Set?> = values.valueSet() + val alsoVerify = { + order.verify(compiledStatement).executeInsert() + order.verify(compiledStatement).close() + } + testSingleCompiledStatement(order, toVerify, compiledStatement, expected, valueSet.asSequence().requireNoNulls().map { it.value }.toList(), bindings, alsoVerify, lastTest) + } + + @Test + fun testInsertEmptyValues() { + val emptyContentValues = mockContentValues() + + assertThrows { identityMapping.insert("id_test", 1, emptyContentValues) } + assertThrows { commentMapping.insert("comment_test", 2, emptyContentValues) } + + verifyNoInteractions(delegateMock) + } + + @ParameterizedTest + @MethodSource("org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabaseTestKt#sourceForTestInsertConflictAlgorithms") + fun testInsertConflictAlgorithms(arguments: Triple) { + val (conflictAlgorithm: Int, idStatement: String, commentStatement: String) = arguments + val idContentValues = mockContentValues("col1" to "val1") //Inlining this declaration causes problems for some reason + val commentContentValues = mockContentValues("col2" to "val2") + + val (idCompiledStatement: SupportSQLiteStatement, idBindings: Map) = mockSupportSQLiteStatement() + val (commentCompiledStatement: SupportSQLiteStatement, commentBindings: Map) = mockSupportSQLiteStatement() + + whenever(delegateMock.compileStatement(idStatement)).thenReturn(idCompiledStatement) + whenever(delegateMock.compileStatement(commentStatement)).thenReturn(commentCompiledStatement) + + val order = inOrder(delegateMock, idCompiledStatement, commentCompiledStatement) + + testSingleInsert( // + order, { assertDoesNotThrow { identityMapping.insert("id_test", conflictAlgorithm, idContentValues) } }, idCompiledStatement, idStatement, idContentValues, idBindings, false // + ) + testSingleInsert( // + order, { assertDoesNotThrow { commentMapping.insert("comment_test", conflictAlgorithm, commentContentValues) } }, commentCompiledStatement, commentStatement, commentContentValues, commentBindings, true // + ) + + order.verifyNoMoreInteractions() + } + + @Test + fun testInsertInvalidConflictAlgorithms() { + val idContentValues = mockContentValues("col1" to "val1") //Inlining this declaration causes problems for some reason + val commentContentValues = mockContentValues("col2" to "val2") + + assertThrows { identityMapping.insert("id_test", -1, idContentValues) } + assertThrows { commentMapping.insert("comment_test", -1, commentContentValues) } + + assertThrows { identityMapping.insert("id_test", 6, idContentValues) } + assertThrows { commentMapping.insert("comment_test", 6, commentContentValues) } + + verifyNoInteractions(delegateMock) + } + + @ParameterizedTest + @MethodSource("org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabaseTestKt#sourceForTestUpdate") + fun testUpdate(contentValues: CallData, whereClauses: CallData, whereArgs: CallData?>) { + identityMapping.update("id_test", 1001, contentValues.idCall, whereClauses.idCall, whereArgs.idCall?.toTypedArray()) + commentMapping.update("comment_test", 1002, contentValues.commentCall, whereClauses.commentCall, whereArgs.commentCall?.toTypedArray()) + + verify(delegateMock).update(eq("id_test"), eq(1001), anyPseudoEquals(contentValues.idExpected, contentValuesProperties), eq(whereClauses.idExpected), eq(whereArgs.idExpected?.toTypedArray())) + verify(delegateMock).update(eq("comment_test"), eq(1002), anyPseudoEquals(contentValues.commentExpected, contentValuesProperties), eq(whereClauses.commentExpected), eq(whereArgs.commentExpected?.toTypedArray())) + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testDelete() { + identityMapping.delete("id_test", "`col` = 'test1'", null) + commentMapping.delete("comment_test", "`col` = 'test2'", null) + + verify(delegateMock).delete("id_test", "`col` = 'test1'", null) + verify(delegateMock).delete("comment_test", "`col` = 'test2' -- Comment!", null) + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testDeleteWithBindings() { + identityMapping.delete("id_test", "`col` = ?", arrayOf("test1")) + commentMapping.delete("comment_test", "`col` = ?", arrayOf("test2")) + + verify(delegateMock).delete("id_test", "`col` = ?", arrayOf("test1")) + verify(delegateMock).delete("comment_test", "`col` = ? -- Comment!", arrayOf("test2")) + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testDeleteNull() { + identityMapping.delete("id_test", null, null) + commentMapping.delete("comment_test", null, null) + + verify(delegateMock).delete("id_test", null, null) + verify(delegateMock).delete("comment_test", "1 = 1 -- Comment!", null) + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testDeleteNullWithBindings() { + identityMapping.delete("id_test", null, arrayOf("included but not used id")) + commentMapping.delete("comment_test", null, arrayOf("included but not used comment")) + + verify(delegateMock).delete("id_test", null, arrayOf("included but not used id")) + verify(delegateMock).delete("comment_test", "1 = 1 -- Comment!", arrayOf("included but not used comment")) + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testExecPerConnectionSQL() { + identityMapping.execPerConnectionSQL("INSERT INTO `id_test` (`col`) VALUES (?)", arrayOf("test1")) + commentMapping.execPerConnectionSQL("INSERT INTO `comment_test` (`col`) VALUES (?)", arrayOf("test2")) + + verify(delegateMock).execPerConnectionSQL("INSERT INTO `id_test` (`col`) VALUES (?)", arrayOf("test1")) + verify(delegateMock).execPerConnectionSQL("INSERT INTO `comment_test` (`col`) VALUES (?) -- Comment!", arrayOf("test2")) + verifyNoMoreInteractions(delegateMock) + } + + @Test + fun testCompileStatement() { + whenever(delegateMock.isOpen).thenReturn(true) + + val idSql = "INSERT INTO `id_test` (`col1`) VALUES ('val1')" + val commentSql = "INSERT INTO `comment_test` (`col2`) VALUES ('val2')" + + val order = inOrder(delegateMock) + order.verifyNoMoreInteractions() + + val idStatement1 = identityMapping.compileStatement(idSql) + order.verify(delegateMock).isOpen + order.verifyNoMoreInteractions() + val idStatement2 = identityMapping.compileStatement(idSql) + val commentStatement1 = commentMapping.compileStatement(commentSql) + val commentStatement2 = commentMapping.compileStatement(commentSql) + + order.verify(delegateMock, times(3)).isOpen + order.verifyNoMoreInteractions() + + assertInstanceOf(MappingSupportSQLiteStatement::class.java, idStatement1) + assertInstanceOf(MappingSupportSQLiteStatement::class.java, idStatement2) + assertInstanceOf(MappingSupportSQLiteStatement::class.java, commentStatement1) + assertInstanceOf(MappingSupportSQLiteStatement::class.java, commentStatement2) + + assertNotSame(idStatement1, idStatement2) + assertNotSame(commentStatement1, commentStatement2) + } + + @Nested + inner class MappingSupportSQLiteStatementTest { + + private lateinit var counter: AtomicInteger + private lateinit var counterMapping: MappingSupportSQLiteDatabase + + @BeforeEach + fun beforeEachInner() { //Don't shadow "beforeEach" of outer class + counter = AtomicInteger(0) + counterMapping = MappingSupportSQLiteDatabase(delegateMock, object : SQLMappingFunction { + override fun map(sql: String): String = "$sql -- ${counter.getAndIncrement()}!" + override fun mapWhereClause(whereClause: String?): String = map(whereClause ?: "1 = 1") + override fun mapCursor(cursor: Cursor): Cursor = cursor + }) + } + + private fun resolveMapping(mapping: Mapping) = when (mapping) { + IDENTITY -> identityMapping + COMMENT -> commentMapping + COUNTER -> counterMapping + } + + @ParameterizedTest + @MethodSource("org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabaseTestKt#sourceForTestNewBoundStatementSingle") + fun testNewBoundStatementSingle(statementData: Triple>, values: List?) { + val (mapping: MappingSupportSQLiteDatabase, call: String, expected: List) = statementData.resolve(::resolveMapping) + val expectedSize = expected.size + require(expectedSize >= 1) + + val mappingStatement = mapping.createAndBindStatement(call, values) + testConsecutiveNewBoundStatements(List(expectedSize) { statementData }, List(expectedSize) { values }) { _: Mapping, _: String, _: List? -> mappingStatement } + } + + @ParameterizedTest + @MethodSource("org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabaseTestKt#sourceForTestNewBoundStatementMultiple") + fun testNewBoundStatementMultiple(statementData: List>>, values: List?>) { + testConsecutiveNewBoundStatements(statementData, values) + } + + @EnabledIfEnvironmentVariable(named = "RUN_VERY_LARGE_TESTS", matches = "(?i)true|1|yes", disabledReason = "Very large tests are disabled") + @ParameterizedTest + @MethodSource("org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabaseTestKt#sourceForTestNewBoundStatementNumerous1") + fun testNewBoundStatementNumerous1(statementData: List>>, values: List?>) { + testConsecutiveNewBoundStatements(statementData, values) + } + + @EnabledIfEnvironmentVariable(named = "RUN_VERY_LARGE_TESTS", matches = "(?i)true|1|yes", disabledReason = "Very large tests are disabled") + @ParameterizedTest + @MethodSource("org.cryptomator.data.db.sqlmapping.MappingSupportSQLiteDatabaseTestKt#sourceForTestNewBoundStatementNumerous2") + fun testNewBoundStatementNumerous2(statementData: List>>, values: List?>) { + testConsecutiveNewBoundStatements(statementData, values) + } + + private fun testConsecutiveNewBoundStatements(statementData: List>>, values: List?>) { + val mappingStatementSupplier = { mapping: Mapping, callSql: String, boundValues: List? -> + resolveMapping(mapping).createAndBindStatement(callSql, boundValues) + } + testConsecutiveNewBoundStatements(statementData, values, mappingStatementSupplier) + } + + private fun testConsecutiveNewBoundStatements( // + statementData: List>>, // + values: List?>, // + mappingStatementSupplier: (Mapping, String, List?) -> MappingSupportSQLiteStatement // + ) { + val statementCount = statementData.size + require(statementCount > 0) + require(values.size == statementCount) + + val expected = statementData.fold(emptyList() to 0) { acc, current -> + val nextExpectedValue = current.third.let { it.getOrNull(acc.second) ?: it.first() } + require(nextExpectedValue != SENTINEL) { + "Invalid test data; received sentinel to be added to ${acc.first} from $current @ ${acc.second}" + } + val nextExpectedAcc = acc.first + nextExpectedValue + val nextIndex = if (current.first == COUNTER) acc.second + 1 else acc.second + nextExpectedAcc to nextIndex + }.first + require(expected.size == statementCount) + + val compiledStatements = mutableListOf() + val newBoundStatementData = mutableListOf() + for ((index, entry) in statementData.withIndex()) { + val (compiledStatement: SupportSQLiteStatement, binding: Map) = mockSupportSQLiteStatement() + val mappingStatement = mappingStatementSupplier(entry.first, entry.second, values[index]) + compiledStatements.add(compiledStatement) + newBoundStatementData.add(NewBoundStatementData(mappingStatement, compiledStatement, expected[index], values[index], binding)) + } + + val order = inOrder(delegateMock, *compiledStatements.toTypedArray()) + order.verifyNoMoreInteractions() + verifyNoMoreInteractions(delegateMock) + + val results = expected.asSequence().zip(compiledStatements.asSequence()).groupBy({ it.first }) { it.second } + whenever(delegateMock.compileStatement(reifiedAnyOrNull())).then(throwingInvocationHandler(false, results)) + + repeat(statementCount) { index -> + val data = newBoundStatementData[index] + testSingleNewBoundStatement(order, data.mappingStatement, data.compiledStatement, data.expected, data.values, data.bindings, index == statementCount - 1) + } + + order.verifyNoMoreInteractions() + } + } + + private fun testSingleNewBoundStatement( // + order: KInOrder, // + mappingStatement: MappingSupportSQLiteStatement, // + compiledStatement: SupportSQLiteStatement, // + expected: String, // + values: List?, // + bindings: Map, // + lastTest: Boolean // + ) = testSingleCompiledStatement( // + order, { assertSame(compiledStatement, mappingStatement.newBoundStatement()) }, compiledStatement, expected, values, bindings, { }, lastTest // + ) + + private fun testSingleCompiledStatement( // + order: KInOrder, // + toVerify: () -> Unit, // + compiledStatement: SupportSQLiteStatement, // + expected: String, // + values: List?, // + bindings: Map, // + alsoVerify: () -> Unit, // + lastTest: Boolean // + ) { + order.verifyNoMoreInteractions() + toVerify() + + order.verify(delegateMock).compileStatement(expected) + if (lastTest) verifyNoMoreInteractions(delegateMock) + order.verify(compiledStatement, times(values.argCount())).bindString(anyInt(), anyString()) + order.verify(compiledStatement, times(values.nullCount())).bindNull(anyInt()) + order.verify(compiledStatement, times(values.argCount())).bindLong(anyInt(), anyLong()) + alsoVerify() + verifyNoMoreInteractions(compiledStatement) + + assertEquals(values.toBindingsMap(), bindings) + } +} + +private data class NewBoundStatementData( + val mappingStatement: MappingSupportSQLiteStatement, // + val compiledStatement: SupportSQLiteStatement, // + val expected: String, // + val values: List?, // + val bindings: Map, // +) + +enum class Mapping { IDENTITY, COMMENT, COUNTER } + +private const val SENTINEL = "::SENTINEL::" + +private inline fun OngoingStubbing.thenDo(crossinline action: (invocation: InvocationOnMock) -> Unit): OngoingStubbing = thenAnswer { action(it) } + +private fun newCachedSupportSQLiteQueryProperties(): Set> = setOf( // + SupportSQLiteQuery::sql.asCached(), // + SupportSQLiteQuery::argCount, // + { query: SupportSQLiteQuery -> // + CachingSupportSQLiteProgram().also { query.bindTo(it) }.bindings // + }.asCached() // +) + +private class CachingSupportSQLiteProgram : SupportSQLiteProgram { + + val bindings = mutableMapOf() + override fun bindBlob(index: Int, value: ByteArray) { + bindings[index] = value + } + + override fun bindDouble(index: Int, value: Double) { + bindings[index] = value + } + + override fun bindLong(index: Int, value: Long) { + bindings[index] = value + } + + override fun bindNull(index: Int) { + bindings[index] = Unit + } + + override fun bindString(index: Int, value: String) { + bindings[index] = value + } + + override fun clearBindings() { + bindings.clear() + } + + override fun close() = throw UnsupportedOperationException("Stub!") +} + +private val contentValuesProperties: Set> + get() = setOf( // + ContentValues::valueSet + ) + +private val DUMMY_CURSOR: Cursor + get() = MatrixCursor(arrayOf()) + +private fun throwingInvocationHandler(retainLast: Boolean, handledResults: Map>): Answer = object : Answer { + val values: Map> = handledResults.asSequence().map { entry -> + require(entry.value.isNotEmpty()) + entry.key to LinkedList(entry.value) + }.toMap() + + override fun answer(invocation: InvocationOnMock): R { + val argument = invocation.getArgument(0) + val resultsForArg = requireNotNull(values[argument]) { "Undefined invocation $invocation" } + require(resultsForArg.isNotEmpty()) { "No results for invocation $invocation" } + return if (resultsForArg.size == 1 && retainLast) resultsForArg.first() else resultsForArg.removeFirst() + } +} + +private fun mockCancellationSignal(isCanceled: Boolean): CancellationSignal { + val mock = mock(CancellationSignal::class.java) + whenever(mock.isCanceled).thenReturn(isCanceled) + whenever(mock.toString()).thenReturn("Mock(isCanceled=$isCanceled)") + return mock +} + +private fun mockSupportSQLiteStatement(): Pair> { + val bindings: MutableMap = mutableMapOf() + val mock = mock(SupportSQLiteStatement::class.java) + whenever(mock.bindString(anyInt(), anyString())).thenDo { + bindings[it.getArgument(0, Integer::class.java).toInt()] = it.getArgument(1, String::class.java) + } + whenever(mock.bindLong(anyInt(), anyLong())).thenDo { + bindings[it.getArgument(0, Integer::class.java).toInt()] = it.getArgument(1, java.lang.Long::class.java) + } + whenever(mock.bindNull(anyInt())).thenDo { + bindings[it.getArgument(0, Integer::class.java).toInt()] = null + } + return mock to bindings +} + +private fun mockContentValues(vararg elements: Pair): ContentValues { + return mockContentValues(mapOf(*elements)) +} + +private fun mockContentValues(entries: Map): ContentValues { + val mock = mock(ContentValues::class.java) + whenever(mock.valueSet()).thenReturn(entries.entries) + whenever(mock.size()).thenReturn(entries.size) + whenever(mock.isEmpty).thenReturn(entries.isEmpty()) + whenever(mock.keySet()).thenReturn(entries.keys) + whenever(mock.get(anyString())).then { + entries[it.getArgument(0, String::class.java)] + } + whenever(mock.toString()).thenReturn("Mock${entries}") + return mock +} + +private fun MappingSupportSQLiteDatabase.createAndBindStatement(sql: String, values: List?): MappingSupportSQLiteStatement { + val mappingStatement = this.MappingSupportSQLiteStatement(sql) + SimpleSQLiteQuery.bind(mappingStatement, values?.toTypedArray()) + return mappingStatement +} + +data class CallData( // + val idCall: T, // + val commentCall: T, // + val idExpected: T, // + val commentExpected: T // +) + +data class CallDataTwo( // + val idCall: C, // + val commentCall: C, // + val idExpected: E, // + val commentExpected: E // +) + +private fun Triple>.resolve(resolver: (Mapping) -> MappingSupportSQLiteDatabase) = Triple(resolver(first), second, third) + +fun sourceForTestQueryCancelable(): Stream { + val queries = sequenceOf( // + CallData( // + SimpleSQLiteQuery("SELECT `col` FROM `id_test`"), // + SimpleSQLiteQuery("SELECT `col` FROM `comment_test`"), // + SimpleSQLiteQuery("SELECT `col` FROM `id_test`"), // + SimpleSQLiteQuery("SELECT `col` FROM `comment_test` -- Comment!") // + ), CallData( // + SimpleSQLiteQuery("SELECT `col` FROM `id_test` WHERE `col` = ?", arrayOf("test1")), // + SimpleSQLiteQuery("SELECT `col` FROM `comment_test` WHERE `col` = ?", arrayOf("test2")), // + SimpleSQLiteQuery("SELECT `col` FROM `id_test` WHERE `col` = ?", arrayOf("test1")), // + SimpleSQLiteQuery("SELECT `col` FROM `comment_test` WHERE `col` = ? -- Comment!", arrayOf("test2")) // + ) + ) + val signals = listOf>( // + CallData( // + mockCancellationSignal(true), // + mockCancellationSignal(false), // + mockCancellationSignal(true), // + mockCancellationSignal(false) // + ), CallData( // + null, // + null, // + null, // + null // + ) + ) + + return queries.cartesianProductTwo(signals).map { it.toList() }.toArgumentsStream() +} + +fun sourceForTestInsert(): Stream> = sequenceOf( // + //The ContentValues in this dataset always have the following order and counts: + //String [0,2], null[0,1], Int[0,1] + //This makes the ordered verification a lot easier + CallDataTwo( // + mockContentValues("key1" to null), // + mockContentValues("key2" to null), // + "INSERT OR ROLLBACK INTO id_test(key1) VALUES (?)", // + "INSERT OR ABORT INTO comment_test(key2) VALUES (?) -- Comment!" // + ), CallDataTwo( // + mockContentValues("key1" to "value1"), // + mockContentValues("key2" to "value2"), // + "INSERT OR ROLLBACK INTO id_test(key1) VALUES (?)", // + "INSERT OR ABORT INTO comment_test(key2) VALUES (?) -- Comment!" // + ), CallDataTwo( // + mockContentValues("key1-1" to "value1-1", "key1-2" to "value1-2"), // + mockContentValues("key2-1" to "value2-1", "key2-2" to "value2-2"), // + "INSERT OR ROLLBACK INTO id_test(key1-1,key1-2) VALUES (?,?)", // + "INSERT OR ABORT INTO comment_test(key2-1,key2-2) VALUES (?,?) -- Comment!" // + ), CallDataTwo( // + mockContentValues("key1" to "value1", "intKey1" to 10101), // + mockContentValues("key2" to "value2", "intKey2" to 20202), // + "INSERT OR ROLLBACK INTO id_test(key1,intKey1) VALUES (?,?)", // + "INSERT OR ABORT INTO comment_test(key2,intKey2) VALUES (?,?) -- Comment!" // + ), CallDataTwo( // + mockContentValues("key1" to "value1", "nullKey1" to null), // + mockContentValues("key2" to "value2", "nullKey2" to null), // + "INSERT OR ROLLBACK INTO id_test(key1,nullKey1) VALUES (?,?)", // + "INSERT OR ABORT INTO comment_test(key2,nullKey2) VALUES (?,?) -- Comment!" // + ), CallDataTwo( // + mockContentValues("key1" to "value1", "nullKey1" to null, "intKey1" to 10101), // + mockContentValues("key2" to "value2"), // + "INSERT OR ROLLBACK INTO id_test(key1,nullKey1,intKey1) VALUES (?,?,?)", // + "INSERT OR ABORT INTO comment_test(key2) VALUES (?) -- Comment!" // + ), CallDataTwo( // + mockContentValues("key1" to "value1"), // + mockContentValues("key2" to "value2", "nullKey2" to null, "intKey2" to 20202), // + "INSERT OR ROLLBACK INTO id_test(key1) VALUES (?)", // + "INSERT OR ABORT INTO comment_test(key2,nullKey2,intKey2) VALUES (?,?,?) -- Comment!" // + ) +).asStream() + +fun sourceForTestInsertConflictAlgorithms(): Stream> = sequenceOf( // + Triple( // + SQLiteDatabase.CONFLICT_NONE, // + "INSERT INTO id_test(col1) VALUES (?)", // + "INSERT INTO comment_test(col2) VALUES (?) -- Comment!" // + ), Triple( // + SQLiteDatabase.CONFLICT_ROLLBACK, // + "INSERT OR ROLLBACK INTO id_test(col1) VALUES (?)", // + "INSERT OR ROLLBACK INTO comment_test(col2) VALUES (?) -- Comment!" // + ), Triple( // + SQLiteDatabase.CONFLICT_ABORT, // + "INSERT OR ABORT INTO id_test(col1) VALUES (?)", // + "INSERT OR ABORT INTO comment_test(col2) VALUES (?) -- Comment!" // + ), Triple( // + SQLiteDatabase.CONFLICT_FAIL, // + "INSERT OR FAIL INTO id_test(col1) VALUES (?)", // + "INSERT OR FAIL INTO comment_test(col2) VALUES (?) -- Comment!" // + ), Triple( // + SQLiteDatabase.CONFLICT_IGNORE, // + "INSERT OR IGNORE INTO id_test(col1) VALUES (?)", // + "INSERT OR IGNORE INTO comment_test(col2) VALUES (?) -- Comment!" // + ), Triple( // + SQLiteDatabase.CONFLICT_REPLACE, // + "INSERT OR REPLACE INTO id_test(col1) VALUES (?)", // + "INSERT OR REPLACE INTO comment_test(col2) VALUES (?) -- Comment!" // + ) +).asStream() + +fun sourceForTestUpdate(): Stream { + val contentValues = sequenceOf( // + CallData( // + mockContentValues("key1" to "value1"), // + mockContentValues("key2" to "value2"), // + mockContentValues("key1" to "value1"), // + mockContentValues("key2" to "value2") // + ), CallData( // + mockContentValues("key1" to null), // + mockContentValues(), // + mockContentValues("key1" to null), // + mockContentValues() // + ) + ) + val whereClauses = listOf>( // + CallData( // + "`col1` = ?", // + "`col2` = ?", // + "`col1` = ?", // + "`col2` = ? -- Comment!" // + ), CallData( // + null, // + null, // + null, // + "1 = 1 -- Comment!" // + ) + ) + val whereArgs = listOf?>>( //Use List instead of Array to make result data more readable + CallData( // + listOf(), // + null, // + listOf(), // + null // + ), CallData( // + listOf("val1"), // + listOf("val2"), // + listOf("val1"), // + listOf("val2") // + ) + ) + + return contentValues.cartesianProductTwo(whereClauses).cartesianProductThree(whereArgs).map { it.toList() }.toArgumentsStream() +} + +private val newBoundStatementValues = listOf?>( // + //The ContentValues in this dataset always have the following order and counts: + //String [0,2], null[0,1], Int[0,1] + //This makes the ordered verification a lot easier + null, // + listOf(), // + listOf(null), // + listOf("value"), // + listOf("value1", "value2"), // + listOf("value", 10101), // + listOf("value", null), // + listOf("value", null, 10101) // +) + +fun sourceForTestNewBoundStatementSingle(): Stream { + val statementData = sequenceOf( // + Triple( // + IDENTITY, "INSERT INTO `id_test` (`id_col`) VALUES (?)", listOf( // + "INSERT INTO `id_test` (`id_col`) VALUES (?)" // + ) + ), Triple( // + COMMENT, "INSERT INTO `comment_test` (`comment_col`) VALUES (?)", listOf( // + "INSERT INTO `comment_test` (`comment_col`) VALUES (?) -- Comment!" // + ) + ), Triple( // + COUNTER, "INSERT INTO `counter_test` (`counter_col`) VALUES (?)", listOf( // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 0!", // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 1!", // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 2!" // + ) + ), Triple( // + IDENTITY, "SELECT count(*) FROM `id_test`", listOf( // + "SELECT count(*) FROM `id_test`" // + ) + ), Triple( // + COMMENT, "SELECT count(*) FROM `comment_test`", listOf( // + "SELECT count(*) FROM `comment_test` -- Comment!" // + ) + ), Triple( // + COUNTER, "SELECT count(*) FROM `counter_test`", listOf( // + "SELECT count(*) FROM `counter_test` -- 0!", // + "SELECT count(*) FROM `counter_test` -- 1!", // + "SELECT count(*) FROM `counter_test` -- 2!" // + ) + ), Triple( // + IDENTITY, "DELETE FROM `id_test` WHERE `id_col1` = 'id_value' AND `id_col2` = ?", listOf( // + "DELETE FROM `id_test` WHERE `id_col1` = 'id_value' AND `id_col2` = ?" // + ) + ), Triple( // + COMMENT, "DELETE FROM `comment_test` WHERE `comment_col1` = 'comment_value' AND `comment_col2` = ?", listOf( // + "DELETE FROM `comment_test` WHERE `comment_col1` = 'comment_value' AND `comment_col2` = ? -- Comment!" // + ) + ), Triple( // + COUNTER, "DELETE FROM `counter_test` WHERE `counter_col1` = 'counter_value' AND `counter_col2` = ?", listOf( // + "DELETE FROM `counter_test` WHERE `counter_col1` = 'counter_value' AND `counter_col2` = ? -- 0!", // + "DELETE FROM `counter_test` WHERE `counter_col1` = 'counter_value' AND `counter_col2` = ? -- 1!", // + "DELETE FROM `counter_test` WHERE `counter_col1` = 'counter_value' AND `counter_col2` = ? -- 2!" // + ) + ) + ) + return statementData // + .cartesianProductTwo(newBoundStatementValues) // + .map { it.toList() } // + .toArgumentsStream() +} + +private val newBoundStatementValuesSmall = listOf?>( // + //The ContentValues in this dataset always have the following order and counts: + //String [0,2], null[0,1], Int[0,1] + //This makes the ordered verification a lot easier + null, // + listOf(), // + listOf("value"), // + listOf("value", null, 10101) // +) + +fun sourceForTestNewBoundStatementMultiple(): Stream { + //result.count() == 6 * 18 == 108 + val statementData = listOf( // + Triple( // + Triple( // + IDENTITY, "INSERT INTO `id_test` (`id_col`) VALUES (?)", listOf( // + "INSERT INTO `id_test` (`id_col`) VALUES (?)" // + ) + ), // + Triple( // + COMMENT, "INSERT INTO `comment_test` (`comment_col`) VALUES (?)", listOf( // + "INSERT INTO `comment_test` (`comment_col`) VALUES (?) -- Comment!" // + ) + ), // + Triple( // + COUNTER, "INSERT INTO `counter_test` (`counter_col`) VALUES (?)", listOf( // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 0!", // + SENTINEL, // + SENTINEL // + ) + ) + ), // + Triple( // + Triple( // + COMMENT, "INSERT INTO `comment_test1` (`comment_col1`) VALUES (?)", listOf( // + "INSERT INTO `comment_test1` (`comment_col1`) VALUES (?) -- Comment!" // + ) + ), // + Triple( // + COMMENT, "INSERT INTO `comment_test2` (`comment_col2`) VALUES (?)", listOf( // + "INSERT INTO `comment_test2` (`comment_col2`) VALUES (?) -- Comment!" // + ) + ), // + Triple( // + COUNTER, "INSERT INTO `counter_test` (`counter_col`) VALUES (?)", listOf( // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 0!", // + SENTINEL, // + SENTINEL // + ) + ) + ), // + Triple( // + Triple( // + COUNTER, "INSERT INTO `counter_test` (`counter_col`) VALUES (?)", listOf( // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 0!", // + SENTINEL, // + SENTINEL // + ) + ), // + Triple( // + COMMENT, "INSERT INTO `comment_test` (`comment_col`) VALUES (?)", listOf( // + "INSERT INTO `comment_test` (`comment_col`) VALUES (?) -- Comment!" // + ) + ), // + Triple( // + COUNTER, "INSERT INTO `counter_test` (`counter_col`) VALUES (?)", listOf( // + SENTINEL, // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 1!", // + SENTINEL // + ) + ) + ), // + Triple( // + Triple( // + COUNTER, "INSERT INTO `counter_test1` (`counter_col1`) VALUES (?)", listOf( // + "INSERT INTO `counter_test1` (`counter_col1`) VALUES (?) -- 0!", // + SENTINEL, // + SENTINEL // + ) + ), // + Triple( // + COMMENT, "INSERT INTO `comment_test` (`comment_col`) VALUES (?)", listOf( // + "INSERT INTO `comment_test` (`comment_col`) VALUES (?) -- Comment!" // + ) + ), // + Triple( // + COUNTER, "INSERT INTO `counter_test2` (`counter_col2`) VALUES (?)", listOf( // + SENTINEL, // + "INSERT INTO `counter_test2` (`counter_col2`) VALUES (?) -- 1!", // + SENTINEL // + ) + ) + ), // + Triple( // + Triple( // + COUNTER, "DELETE FROM `counter_test`", listOf( // + "DELETE FROM `counter_test` -- 0!", // + SENTINEL, // + SENTINEL // + ) + ), // + Triple( // + COUNTER, "DELETE FROM `counter_test`", listOf( // + SENTINEL, // + "DELETE FROM `counter_test` -- 1!", // + SENTINEL // + ) + ), // + Triple( // + COUNTER, "DELETE FROM `counter_test`", listOf( // + SENTINEL, // + SENTINEL, // + "DELETE FROM `counter_test` -- 2!" // + ) + ) + ), // + Triple( // + Triple( // + COUNTER, "INSERT INTO `counter_test1` (`counter_col1`) VALUES (?)", listOf( // + "INSERT INTO `counter_test1` (`counter_col1`) VALUES (?) -- 0!", // + SENTINEL, // + SENTINEL // + ) + ), // + Triple( // + COUNTER, "DELETE FROM `counter_test2`", listOf( // + SENTINEL, // + "DELETE FROM `counter_test2` -- 1!", // + SENTINEL // + ) + ), // + Triple( // + COUNTER, "SELECT count(*) FROM `counter_test3` WHERE `counter_col3` = ?", listOf( // + SENTINEL, // + SENTINEL, // + "SELECT count(*) FROM `counter_test3` WHERE `counter_col3` = ? -- 2!" // + ) + ) + ) + ) + val values = listOf( // + Triple( // + null, null, null // + ), Triple( // + listOf(), listOf(), listOf() // + ), Triple( // + listOf(null), listOf(null), listOf(null) // + ), Triple( // + listOf("value"), listOf("value"), listOf("value") // + ), Triple( // + listOf("value"), listOf(null), listOf("value") // + ), Triple( // + listOf("value1"), listOf("value2"), listOf("value3") // + ), Triple( // + listOf("value"), listOf(2_000_0), listOf() // + ), Triple( // + listOf("value"), listOf(2_000_0), listOf(null) // + ), Triple( // + listOf("value", "value"), listOf("value"), listOf() // + ), Triple( // + listOf("value1-1", "value1-2"), listOf("value2-1"), listOf() // + ), Triple( // + listOf("value1-1", 1_000_2), listOf(null), listOf() // + ), Triple( // + listOf("value", "value", "value"), listOf("value"), listOf() // + ), Triple( // + listOf("value", "value", "value"), listOf("value", "value", "value"), listOf("value", "value", "value") // + ), Triple( // + listOf("value1", "value2", "value3"), listOf("value1", "value2", "value3"), listOf("value1", "value2", "value3") // + ), Triple( // + listOf("value1", "value1", "value1"), listOf("value2", "value2", "value2"), listOf("value3", "value3", "value3") // + ), Triple( // + listOf("value1-1", "value1-2", "value1-3"), listOf("value2-1", "value2-2", "value2-3"), listOf("value3-1", "value3-2", "value3-3") // + ), Triple( // + listOf("value1-1", "value1-2", null), listOf("value2-1", null, null), listOf("value3-1", "value3-2", "value3-3") // + ), Triple( // + listOf("value1-1", null, 1_000_3), listOf("value2-1", 2_000_2, 2_000_3), listOf(null, null, 3_000_3) // + ) + ) + + val statementDataSets: Sequence>>> = statementData.asSequence() // + .map { it.toList() } + + val valueSets: List?>> = values.asSequence() // + .map { it.toList() } // + .toList() + val result: Sequence>>, List?>>> = statementDataSets.cartesianProductTwo(valueSets) + return result // + .map { it.toList() } // + .toArgumentsStream() +} + +fun sourceForTestNewBoundStatementNumerous1(): Stream { + //result.count() == 3 ^ 3 * 4 ^ 3 == 1,728 + val statementData = listOf( // + Triple( // + IDENTITY, "INSERT INTO `id_test` (`id_col`) VALUES (?)", listOf( // + "INSERT INTO `id_test` (`id_col`) VALUES (?)" // + ) + ), Triple( // + COMMENT, "INSERT INTO `comment_test` (`comment_col`) VALUES (?)", listOf( // + "INSERT INTO `comment_test` (`comment_col`) VALUES (?) -- Comment!" // + ) + ), Triple( // + COUNTER, "INSERT INTO `counter_test` (`counter_col`) VALUES (?)", listOf( // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 0!", // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 1!", // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 2!", // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 3!" // + ) + ) + ) + + val statementDataSets: Sequence>>> = statementData.asSequence() // + .cartesianProductTwo(statementData) // + .cartesianProductThree(statementData) // + .map { it.toList() } + + val valueSets: List?>> = newBoundStatementValuesSmall.asSequence() // + .cartesianProductTwo(newBoundStatementValuesSmall) // + .cartesianProductThree(newBoundStatementValuesSmall) // + .map { it.toList() } // + .toList() + val result: Sequence>>, List?>>> = statementDataSets.cartesianProductTwo(valueSets) + return result // + .map { it.toList() } // + .toArgumentsStream() +} + +fun sourceForTestNewBoundStatementNumerous2(): Stream { + //result.count() == 3 ^ 4 * 8 ^ 4 == 331,776 + val statementData = listOf( // + Triple( // + IDENTITY, "INSERT INTO `id_test` (`id_col`) VALUES (?)", listOf( // + "INSERT INTO `id_test` (`id_col`) VALUES (?)" // + ) + ), Triple( // + COMMENT, "INSERT INTO `comment_test` (`comment_col`) VALUES (?)", listOf( // + "INSERT INTO `comment_test` (`comment_col`) VALUES (?) -- Comment!" // + ) + ), Triple( // + COUNTER, "INSERT INTO `counter_test` (`counter_col`) VALUES (?)", listOf( // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 0!", // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 1!", // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 2!", // + "INSERT INTO `counter_test` (`counter_col`) VALUES (?) -- 3!" // + ) + ) + ) + + val statementDataSets: Sequence>>> = statementData.asSequence() // + .cartesianProductTwo(statementData) // + .cartesianProductThree(statementData) // + .cartesianProductFour(statementData) + + val valueSets: List?>> = newBoundStatementValues.asSequence() // + .cartesianProductTwo(newBoundStatementValues) // + .cartesianProductThree(newBoundStatementValues) // + .cartesianProductFour(newBoundStatementValues) // + .toList() + val result: Sequence>>, List?>>> = statementDataSets.cartesianProductTwo(valueSets) + return result // + .map { it.toList() } // + .toArgumentsStream() +} \ No newline at end of file diff --git a/data/src/test/java/org/cryptomator/data/testing/Iterables.kt b/data/src/test/java/org/cryptomator/data/testing/Iterables.kt new file mode 100644 index 000000000..8949a7dbb --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/testing/Iterables.kt @@ -0,0 +1,32 @@ +package org.cryptomator.data.testing + +import org.junit.jupiter.params.provider.Arguments +import java.util.stream.Stream +import kotlin.streams.asStream + +fun Sequence.cartesianProductTwo(other: Iterable): Sequence> = flatMap { a -> + other.asSequence().map { b -> a to b } +} + +fun Sequence>.cartesianProductThree(other: Iterable): Sequence> = flatMap { abPair -> + other.asSequence().map { c -> Triple(abPair.first, abPair.second, c) } +} + +fun Sequence>.cartesianProductFour(other: Iterable): Sequence> = flatMap { triple -> + other.asSequence().map { otherElement -> listOf(triple.first, triple.second, triple.third, otherElement) } +} + +fun Sequence>.toArgumentsStream(): Stream = map { + Arguments { it.toTypedArray() } +}.asStream() + +fun Iterable?.nullCount(): Int = this?.count { it == null } ?: 0 + +inline fun Iterable?.argCount(): Int = this?.asSequence()?.filterIsInstance()?.count() ?: 0 + +fun Iterable?.toBindingsMap(): Map { + return this?.asSequence() // + ?.map { if (it is Int) it.toLong() else it } // Required because java.lang.Integer.valueOf(x) != java.lang.Long.valueOf(x) + ?.mapIndexed { index, value -> index + 1 to value } // + ?.toMap() ?: emptyMap() +} \ No newline at end of file diff --git a/data/src/test/java/org/cryptomator/data/testing/PseudoEquality.kt b/data/src/test/java/org/cryptomator/data/testing/PseudoEquality.kt new file mode 100644 index 000000000..ed6addf47 --- /dev/null +++ b/data/src/test/java/org/cryptomator/data/testing/PseudoEquality.kt @@ -0,0 +1,85 @@ +package org.cryptomator.data.testing + +import org.mockito.ArgumentMatchers.argThat as defaultArgThat +import org.mockito.kotlin.argThat as reifiedArgThat +import org.mockito.ArgumentMatcher +import org.mockito.kotlin.isNull + +internal inline fun anyPseudoEqualsUnlessNull(other: T?, valueExtractors: Set>): T? { + return if (other != null) defaultArgThat(NullHandlingMatcher(pseudoEquals(other, valueExtractors), false)) else isNull() +} + +internal inline fun anyPseudoEquals(other: T, valueExtractors: Set>): T { + return reifiedArgThat(pseudoEquals(other, valueExtractors)) +} + +internal fun pseudoEquals(other: T, valueExtractors: Set>): ArgumentMatcher { + require(valueExtractors.isNotEmpty()) + return PseudoEqualsMatcher(other, valueExtractors) +} + +internal class PseudoEqualsMatcher( // + private val other: T, // + private val valueExtractors: Set> // +) : ArgumentMatcher { + + override fun matches(argument: T): Boolean { + if (argument === other) { + return true + } + return valueExtractors.all { extractor -> extractor(argument) == extractor(other) } + } +} + +internal typealias ValueExtractor = (T) -> Any? + +private data class CacheKey(val wrappedKey: T) { + + override fun hashCode(): Int { + return if (isPrimitive(wrappedKey)) { + wrappedKey!!.hashCode() + } else { + System.identityHashCode(wrappedKey) + } + } + + override fun equals(other: Any?): Boolean { + if (other == null || other !is CacheKey<*>) { + return false + } + return if (isPrimitive(this.wrappedKey) && isPrimitive(other.wrappedKey)) { + this.wrappedKey == other.wrappedKey + } else { + this.wrappedKey === other.wrappedKey + } + } +} + +private data class CacheValue(val wrappedValue: Any?) //Allows correct handling of nulls + +private fun isPrimitive(obj: Any?): Boolean { + return when (obj) { + is Boolean, Char, Byte, Short, Int, Long, Float, Double -> true + else -> false + } +} + +internal fun ValueExtractor.asCached(): ValueExtractor { + val cache = mutableMapOf, CacheValue>() + return { + cache.computeIfAbsent(CacheKey(it)) { key -> CacheValue(this@asCached(key.wrappedKey)) }.wrappedValue + } +} + +internal class NullHandlingMatcher( // + private val delegate: ArgumentMatcher, // + private val matchNull: Boolean // +) : ArgumentMatcher { + + override fun matches(argument: T?): Boolean { + if (argument == null) { + return matchNull + } + return delegate.matches(argument) + } +} \ No newline at end of file diff --git a/presentation/build.gradle b/presentation/build.gradle index 39e5fcf06..b06f9a456 100644 --- a/presentation/build.gradle +++ b/presentation/build.gradle @@ -162,6 +162,9 @@ dependencies { implementation dependencies.scaleImageView implementation dependencies.recyclerViewFastScroll + // room (required for adding DatabaseModule to the dagger graph) + implementation dependencies.room + // android x implementation dependencies.androidxCore implementation dependencies.androidxFragment diff --git a/util/src/main/java/org/cryptomator/util/ThreadUtil.kt b/util/src/main/java/org/cryptomator/util/ThreadUtil.kt new file mode 100644 index 000000000..7e4be646a --- /dev/null +++ b/util/src/main/java/org/cryptomator/util/ThreadUtil.kt @@ -0,0 +1,20 @@ +package org.cryptomator.util + +import android.os.Looper +import timber.log.Timber + +object ThreadUtil { + + val isMainThread: Boolean + get() = Looper.getMainLooper().isCurrentThread + + fun assertNotMainThread() { + check(!isMainThread) { "Error: Currently executing on main thread; aborting" } + } + + fun assumeNotMainThread() { + if (isMainThread) { + Timber.tag("ThreadUtil").w(Exception(), "Warning: Currently executing on main thread") + } + } +} \ No newline at end of file