diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle index 191fbbb7e347cdd8d6df9fc373f71139b67b7667..38ce5d0924bf439d02e79b8d87b85ccf0ae25b15 100644 --- a/Corona-Warn-App/build.gradle +++ b/Corona-Warn-App/build.gradle @@ -254,6 +254,7 @@ android { } androidTest { java.srcDirs += "$projectDir/src/testShared/java" + androidTest.assets.srcDirs += files("$projectDir/schemas".toString()) } } } @@ -416,6 +417,7 @@ dependencies { implementation "androidx.room:room-guava:$room_version" kapt "androidx.room:room-compiler:$room_version" implementation "androidx.sqlite:sqlite:2.1.0" + androidTestImplementation "androidx.room:room-testing:$room_version" // UTILS implementation project(":Server-Protocol-Buffer") diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase/2.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase/2.json new file mode 100644 index 0000000000000000000000000000000000000000..5e35d70ab24482260df16038420f12db1d297824 --- /dev/null +++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase/2.json @@ -0,0 +1,209 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "c9b87dec5a9991bf14dc60dee54d4181", + "entities": [ + { + "tableName": "riskresults", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`monotonicId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT NOT NULL, `calculatedAt` TEXT NOT NULL, `failureReason` TEXT, `totalRiskLevel` INTEGER, `totalMinimumDistinctEncountersWithLowRisk` INTEGER, `totalMinimumDistinctEncountersWithHighRisk` INTEGER, `mostRecentDateWithLowRisk` TEXT, `mostRecentDateWithHighRisk` TEXT, `numberOfDaysWithLowRisk` INTEGER, `numberOfDaysWithHighRisk` INTEGER)", + "fields": [ + { + "fieldPath": "monotonicId", + "columnName": "monotonicId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "calculatedAt", + "columnName": "calculatedAt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "failureReason", + "columnName": "failureReason", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "aggregatedRiskResult.totalRiskLevel", + "columnName": "totalRiskLevel", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "aggregatedRiskResult.totalMinimumDistinctEncountersWithLowRisk", + "columnName": "totalMinimumDistinctEncountersWithLowRisk", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "aggregatedRiskResult.totalMinimumDistinctEncountersWithHighRisk", + "columnName": "totalMinimumDistinctEncountersWithHighRisk", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "aggregatedRiskResult.mostRecentDateWithLowRisk", + "columnName": "mostRecentDateWithLowRisk", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "aggregatedRiskResult.mostRecentDateWithHighRisk", + "columnName": "mostRecentDateWithHighRisk", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "aggregatedRiskResult.numberOfDaysWithLowRisk", + "columnName": "numberOfDaysWithLowRisk", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "aggregatedRiskResult.numberOfDaysWithHighRisk", + "columnName": "numberOfDaysWithHighRisk", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "monotonicId" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "exposurewindows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `riskLevelResultId` TEXT NOT NULL, `dateMillisSinceEpoch` INTEGER NOT NULL, `calibrationConfidence` INTEGER NOT NULL, `infectiousness` INTEGER NOT NULL, `reportType` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "riskLevelResultId", + "columnName": "riskLevelResultId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dateMillisSinceEpoch", + "columnName": "dateMillisSinceEpoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calibrationConfidence", + "columnName": "calibrationConfidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "infectiousness", + "columnName": "infectiousness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reportType", + "columnName": "reportType", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "scaninstances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `exposureWindowId` INTEGER NOT NULL, `minAttenuationDb` INTEGER NOT NULL, `secondsSinceLastScan` INTEGER NOT NULL, `typicalAttenuationDb` INTEGER NOT NULL, FOREIGN KEY(`exposureWindowId`) REFERENCES `exposurewindows`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exposureWindowId", + "columnName": "exposureWindowId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAttenuationDb", + "columnName": "minAttenuationDb", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondsSinceLastScan", + "columnName": "secondsSinceLastScan", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "typicalAttenuationDb", + "columnName": "typicalAttenuationDb", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_scaninstances_exposureWindowId", + "unique": false, + "columnNames": [ + "exposureWindowId" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_scaninstances_exposureWindowId` ON `${TABLE_NAME}` (`exposureWindowId`)" + } + ], + "foreignKeys": [ + { + "table": "exposurewindows", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "exposureWindowId" + ], + "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, 'c9b87dec5a9991bf14dc60dee54d4181')" + ] + } +} \ No newline at end of file diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/risk/storage/RiskResultDatabaseMigrationTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/risk/storage/RiskResultDatabaseMigrationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..af16ce9d74ebb2c11e86b1689e934988951ab379 --- /dev/null +++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/risk/storage/RiskResultDatabaseMigrationTest.kt @@ -0,0 +1,224 @@ +package de.rki.coronawarnapp.risk.storage + +import androidx.room.testing.MigrationTestHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import de.rki.coronawarnapp.risk.RiskLevelResult +import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase +import de.rki.coronawarnapp.risk.storage.internal.migrations.RiskResultDatabaseMigration1To2 +import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.joda.time.Instant +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import testhelpers.BaseTest +import timber.log.Timber + +@RunWith(AndroidJUnit4::class) +class RiskResultDatabaseMigrationTest : BaseTest() { + private val DB_NAME = "riskresults_migration_test.db" + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + RiskResultDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + /** + * Test migration to create new primary key "monotonicId" column + */ + @Test + fun migrate1To2() { + helper.createDatabase(DB_NAME, 1).apply { + execSQL( + """ + INSERT INTO "riskresults" ( + "id", + "calculatedAt", + "totalRiskLevel", + "totalMinimumDistinctEncountersWithLowRisk", + "totalMinimumDistinctEncountersWithHighRisk", + "mostRecentDateWithLowRisk", + "mostRecentDateWithHighRisk", + "numberOfDaysWithLowRisk", + "numberOfDaysWithHighRisk" + ) VALUES ( + '72c4084a-43a9-4fcf-86d4-36103bfbd492', + '2020-12-31T16:41:50.207Z', + '2', + '8', + '1', + '2020-12-29T16:41:50.038Z', + '2020-12-30T16:41:50.038Z', + '3', + '1' + ); + """.trimIndent() + ) + execSQL( + """ + INSERT INTO "riskresults" ( + "id", + "calculatedAt", + "totalRiskLevel", + "totalMinimumDistinctEncountersWithLowRisk", + "totalMinimumDistinctEncountersWithHighRisk", + "mostRecentDateWithLowRisk", + "mostRecentDateWithHighRisk", + "numberOfDaysWithLowRisk", + "numberOfDaysWithHighRisk" + ) VALUES ( + '48a57f54-467b-4a0b-89c4-3c14e7ce65b5', + '2020-12-31T16:41:38.663Z', + '1', + '0', + '0', + NULL, + NULL, + '0', + '0' + ); + """.trimIndent() + ) + execSQL( + """ + INSERT INTO "riskresults" ( + "id", + "calculatedAt", + "failureReason" + ) VALUES ( + '0235fef8-4332-4a43-b7d8-f5eacb54a6ee', + '2020-12-31T16:28:25.400Z', + 'tracingOff' + ); + """.trimIndent() + ) + + close() + } + + // Run migration + helper.runMigrationsAndValidate( + DB_NAME, + 2, + true, + RiskResultDatabaseMigration1To2 + ) + + val daoDb = RiskResultDatabase.Factory( + context = ApplicationProvider.getApplicationContext() + ).create(databaseName = DB_NAME) + + val allEntries = runBlocking { daoDb.riskResults().allEntries().first() } + + allEntries.size shouldBe 3 + // Newest entry + allEntries[0] shouldBe PersistedRiskLevelResultDao( + monotonicId = 3, + id = "0235fef8-4332-4a43-b7d8-f5eacb54a6ee", + calculatedAt = Instant.parse("2020-12-31T16:28:25.400Z"), + failureReason = RiskLevelResult.FailureReason.TRACING_OFF, + aggregatedRiskResult = null + ) + + allEntries[1] shouldBe PersistedRiskLevelResultDao( + monotonicId = 2, + id = "48a57f54-467b-4a0b-89c4-3c14e7ce65b5", + calculatedAt = Instant.parse("2020-12-31T16:41:38.663Z"), + failureReason = null, + aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult( + totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.forNumber( + 1 + ), + totalMinimumDistinctEncountersWithLowRisk = 0, + totalMinimumDistinctEncountersWithHighRisk = 0, + mostRecentDateWithLowRisk = null, + mostRecentDateWithHighRisk = null, + numberOfDaysWithLowRisk = 0, + numberOfDaysWithHighRisk = 0 + ) + ) + // Oldest entry, i.e. first one inserted + allEntries[2] shouldBe PersistedRiskLevelResultDao( + monotonicId = 1, + id = "72c4084a-43a9-4fcf-86d4-36103bfbd492", + calculatedAt = Instant.parse("2020-12-31T16:41:50.207Z"), + failureReason = null, + aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult( + totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.forNumber( + 2 + ), + totalMinimumDistinctEncountersWithLowRisk = 8, + totalMinimumDistinctEncountersWithHighRisk = 1, + mostRecentDateWithLowRisk = Instant.parse("2020-12-29T16:41:50.038Z"), + mostRecentDateWithHighRisk = Instant.parse("2020-12-30T16:41:50.038Z"), + numberOfDaysWithLowRisk = 3, + numberOfDaysWithHighRisk = 1 + ) + ) + } + + /** + * If migration fails, drop the whole table and recreate it according to v2 schema + */ + @Test + fun migrate1To2_failure_drops_db() { + helper.createDatabase(DB_NAME, 1).apply { + execSQL("DROP TABLE IF EXISTS riskresults") + execSQL("CREATE TABLE IF NOT EXISTS `riskresults` (`id` TEXT NOT NULL, `calculatedAt` INTEGER, `failureReason` INTEGER)") + execSQL("INSERT INTO `riskresults` (`id`, `calculatedAt`, `failureReason`) VALUES ('1', '2', '3')") + + close() + } + + // Run migration + helper.runMigrationsAndValidate( + DB_NAME, + 2, + true, + RiskResultDatabaseMigration1To2 + ) + + val daoDb = RiskResultDatabase.Factory( + context = ApplicationProvider.getApplicationContext() + ).create(databaseName = DB_NAME) + + val emptyResults = runBlocking { daoDb.riskResults().allEntries().first() } + emptyResults.size shouldBe 0 + + val expectedResult = PersistedRiskLevelResultDao( + id = "48a57f54-467b-4a0b-89c4-3c14e7ce65b5", + calculatedAt = Instant.parse("2020-12-31T16:41:38.663Z"), + failureReason = null, + aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult( + totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.forNumber( + 1 + ), + totalMinimumDistinctEncountersWithLowRisk = 0, + totalMinimumDistinctEncountersWithHighRisk = 0, + mostRecentDateWithLowRisk = null, + mostRecentDateWithHighRisk = null, + numberOfDaysWithLowRisk = 0, + numberOfDaysWithHighRisk = 0 + ) + ) + + val insertedResult = runBlocking { + daoDb.riskResults().insertEntry(expectedResult) + daoDb.riskResults().allEntries().first().let { + it.size shouldBe 1 + it.first() + } + } + + Timber.v("insertedResult=%s", insertedResult) + insertedResult shouldBe expectedResult.copy(monotonicId = 1) + } +} diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/risk/storage/RiskResultDatabaseTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/risk/storage/RiskResultDatabaseTest.kt index 36f8696601620b48a4d3863f5c2a4948a47ff02f..8dffeef996f4c1efbdc79e9e4a3f1018b5b90685 100644 --- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/risk/storage/RiskResultDatabaseTest.kt +++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/risk/storage/RiskResultDatabaseTest.kt @@ -22,6 +22,7 @@ class RiskResultDatabaseTest { private val riskResultDao = database.riskResults() private val oldestSuccessfulEntry = PersistedRiskLevelResultDao( + monotonicId = 1, id = UUID.randomUUID().toString(), calculatedAt = Instant.now().minus(9000), aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult( @@ -37,6 +38,7 @@ class RiskResultDatabaseTest { ) private val olderEntryFailedEntry = PersistedRiskLevelResultDao( + monotonicId = 2, id = UUID.randomUUID().toString(), calculatedAt = Instant.now().minus(4500), aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult( @@ -52,6 +54,7 @@ class RiskResultDatabaseTest { ) private val newestEntryFailed = PersistedRiskLevelResultDao( + monotonicId = 3, id = UUID.randomUUID().toString(), calculatedAt = Instant.now(), aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult( diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt index 5110334a3cbb27f5b89cf0671428fc95b3de6b32..900ce5bef4f3179a4b9cc020ab53cdf4316407ae 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt @@ -54,7 +54,7 @@ fun ExposureDetectionParametersAndroid?.maxExposureDetectionsPerDay(): Int = fun ExposureDetectionParametersAndroid?.minTimeBetweenExposureDetections(): Duration { val detectionsPerDay = this.maxExposureDetectionsPerDay() return if (detectionsPerDay == 0) { - Duration.standardDays(99) + Duration.standardDays(1) } else { (24 / detectionsPerDay).let { Duration.standardHours(it.toLong()) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryLocation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryLocation.kt index c5315a98646599c1a8773a3f99fe58e9cdb11a90..d4192af041ce8789f72bb20a020d7e0d645126e0 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryLocation.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryLocation.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.contactdiary.model import de.rki.coronawarnapp.util.lists.HasStableId +import java.util.Locale interface ContactDiaryLocation : HasStableId { val locationId: Long @@ -8,4 +9,4 @@ interface ContactDiaryLocation : HasStableId { } fun List<ContactDiaryLocation>.sortByNameAndIdASC(): List<ContactDiaryLocation> = - this.sortedWith(compareBy({ it.locationName }, { it.locationId })) + this.sortedWith(compareBy({ it.locationName.toLowerCase(Locale.ROOT) }, { it.locationId })) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryLocationVisit.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryLocationVisit.kt index a1f4061d7966086715a548e5c50784a8f9f1a085..2d477bcee18248a08aa8ecdfd9d4937f7e635ed2 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryLocationVisit.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryLocationVisit.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.contactdiary.model import org.joda.time.LocalDate +import java.util.Locale interface ContactDiaryLocationVisit { val id: Long @@ -9,4 +10,9 @@ interface ContactDiaryLocationVisit { } fun List<ContactDiaryLocationVisit>.sortByNameAndIdASC(): List<ContactDiaryLocationVisit> = - this.sortedWith(compareBy({ it.contactDiaryLocation.locationName }, { it.contactDiaryLocation.locationId })) + this.sortedWith( + compareBy( + { it.contactDiaryLocation.locationName.toLowerCase(Locale.ROOT) }, + { it.contactDiaryLocation.locationId } + ) + ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryPerson.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryPerson.kt index 95badab5576060f02a888551d0c416be8ad9ba9b..de391152bcfba7267c0d095124025137b2fc3214 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryPerson.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryPerson.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.contactdiary.model import de.rki.coronawarnapp.util.lists.HasStableId +import java.util.Locale interface ContactDiaryPerson : HasStableId { val personId: Long @@ -8,4 +9,4 @@ interface ContactDiaryPerson : HasStableId { } fun List<ContactDiaryPerson>.sortByNameAndIdASC(): List<ContactDiaryPerson> = - this.sortedWith(compareBy({ it.fullName }, { it.personId })) + this.sortedWith(compareBy({ it.fullName.toLowerCase(Locale.ROOT) }, { it.personId })) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryPersonEncounter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryPersonEncounter.kt index 60bf3887a3680388cbb6ec527eaf5e0bfc86ffec..47975a267757d5a18018d5a21c46d088bf32da3f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryPersonEncounter.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/model/ContactDiaryPersonEncounter.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.contactdiary.model import org.joda.time.LocalDate +import java.util.Locale interface ContactDiaryPersonEncounter { val id: Long @@ -9,4 +10,9 @@ interface ContactDiaryPersonEncounter { } fun List<ContactDiaryPersonEncounter>.sortByNameAndIdASC(): List<ContactDiaryPersonEncounter> = - this.sortedWith(compareBy({ it.contactDiaryPerson.fullName }, { it.contactDiaryPerson.personId })) + this.sortedWith( + compareBy( + { it.contactDiaryPerson.fullName.toLowerCase(Locale.ROOT) }, + { it.contactDiaryPerson.personId } + ) + ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt index 7be1a88e9bbe32a26abafa3bf6784c0c9cf9888b..6a741421493b0958044f56fd38a3d6775b35eaf6 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt @@ -140,9 +140,23 @@ class DownloadDiagnosisKeysTask @Inject constructor( trackedDetections: Collection<TrackedExposureDetection> ): Boolean { val lastDetection = trackedDetections.maxByOrNull { it.startedAt } - val nextDetectionAt = lastDetection?.startedAt?.plus(exposureConfig.minTimeBetweenDetections) + if (lastDetection == null) { + Timber.tag(TAG).d("No previous detections exist, don't abort.") + return false + } + + if (lastDetection.startedAt.isAfter(now.plus(Duration.standardHours(1)))) { + Timber.tag(TAG).w("Last detection happened in our future? Don't abort as precaution.") + return false + } + + val nextDetectionAt = lastDetection.startedAt.plus(exposureConfig.minTimeBetweenDetections) + + Duration(now, nextDetectionAt).also { + Timber.tag(TAG).d("Next detection is available in %d min", it.standardMinutes) + } - return (nextDetectionAt != null && now.isBefore(nextDetectionAt)).also { + return (now.isBefore(nextDetectionAt)).also { if (it) Timber.tag(TAG).w("Aborting. Last detection is recent: %s (now=%s)", lastDetection, now) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/RiskResultDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/RiskResultDatabase.kt index 0bb56250707a0e0321608056295f547868a7be92..83c786c58bf2531cfe654e160ad6588ddbd12f31 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/RiskResultDatabase.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/RiskResultDatabase.kt @@ -9,6 +9,7 @@ import androidx.room.Query import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters +import de.rki.coronawarnapp.risk.storage.internal.migrations.RiskResultDatabaseMigration1To2 import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao import de.rki.coronawarnapp.risk.storage.internal.windows.PersistedExposureWindowDao import de.rki.coronawarnapp.risk.storage.internal.windows.PersistedExposureWindowDaoWrapper @@ -25,7 +26,7 @@ import javax.inject.Inject PersistedExposureWindowDao::class, PersistedExposureWindowDao.PersistedScanInstance::class ], - version = 1, + version = 2, exportSchema = true ) @TypeConverters( @@ -41,20 +42,20 @@ abstract class RiskResultDatabase : RoomDatabase() { @Dao interface RiskResultsDao { - @Query("SELECT * FROM riskresults ORDER BY calculatedAt DESC") + @Query("SELECT * FROM riskresults ORDER BY monotonicId DESC") fun allEntries(): Flow<List<PersistedRiskLevelResultDao>> - @Query("SELECT * FROM riskresults ORDER BY calculatedAt DESC LIMIT :limit") + @Query("SELECT * FROM riskresults ORDER BY monotonicId DESC LIMIT :limit") fun latestEntries(limit: Int): Flow<List<PersistedRiskLevelResultDao>> - @Query("SELECT * FROM (SELECT * FROM riskresults ORDER BY calculatedAt DESC LIMIT 1) UNION ALL SELECT * FROM (SELECT * FROM riskresults where failureReason is null ORDER BY calculatedAt DESC LIMIT 1)") + @Query("SELECT * FROM (SELECT * FROM riskresults ORDER BY monotonicId DESC LIMIT 1) UNION ALL SELECT * FROM (SELECT * FROM riskresults where failureReason is null ORDER BY monotonicId DESC LIMIT 1)") fun latestAndLastSuccessful(): Flow<List<PersistedRiskLevelResultDao>> @Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insertEntry(riskResultDao: PersistedRiskLevelResultDao) @Query( - "DELETE FROM riskresults where id NOT IN (SELECT id from riskresults ORDER BY calculatedAt DESC LIMIT :keep)" + "DELETE FROM riskresults where id NOT IN (SELECT id from riskresults ORDER BY monotonicId DESC LIMIT :keep)" ) suspend fun deleteOldest(keep: Int): Int } @@ -81,11 +82,11 @@ abstract class RiskResultDatabase : RoomDatabase() { class Factory @Inject constructor(@AppContext private val context: Context) { - fun create(): RiskResultDatabase { + fun create(databaseName: String = DATABASE_NAME): RiskResultDatabase { Timber.d("Instantiating risk result database.") return Room - .databaseBuilder(context, RiskResultDatabase::class.java, DATABASE_NAME) - .fallbackToDestructiveMigrationFrom() + .databaseBuilder(context, RiskResultDatabase::class.java, databaseName) + .addMigrations(RiskResultDatabaseMigration1To2) .build() } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/migrations/RiskResultDatabaseMigration1To2.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/migrations/RiskResultDatabaseMigration1To2.kt new file mode 100644 index 0000000000000000000000000000000000000000..b396d72fcc20f85a5091d7b903dd03d8e3e99e73 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/migrations/RiskResultDatabaseMigration1To2.kt @@ -0,0 +1,117 @@ +package de.rki.coronawarnapp.risk.storage.internal.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase +import de.rki.coronawarnapp.exception.ExceptionCategory +import de.rki.coronawarnapp.exception.reporting.report +import timber.log.Timber + +/** + * Migrates the RiskResultDataBase from schema version 1 to schema version 2 + * The primary key column "id" was replaced with the newly added "monotonicId" column + * This was done to allow the app to determine the "latest" calculation independent of the calculation timestamp, + * which could be problematic if timetravel happens. + */ +object RiskResultDatabaseMigration1To2 : Migration(1, 2) { + + override fun migrate(database: SupportSQLiteDatabase) { + try { + Timber.i("Attempting migration 1->2...") + performMigration(database) + Timber.i("Migration 1->2 successful.") + } catch (e: Exception) { + Timber.e(e, "Migration 1->2 failed, dropping tables...") + e.report(ExceptionCategory.INTERNAL, "RiskResult database migration failed.") + + try { + recreateRiskResults(database) + } catch (e: Exception) { + e.report(ExceptionCategory.INTERNAL, "Migration failed, table recreation failed too!") + throw e + } + + Timber.w("Migration failed, but fallback via reset was successful.") + } + } + + private fun performMigration(database: SupportSQLiteDatabase) = with(database) { + Timber.i("Running MIGRATION_1_2: Create new table.") + execSQL( + """ + CREATE TABLE IF NOT EXISTS `riskresults_new` ( + `monotonicId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `id` TEXT NOT NULL, + `calculatedAt` TEXT NOT NULL, + `failureReason` TEXT, + `totalRiskLevel` INTEGER, + `totalMinimumDistinctEncountersWithLowRisk` INTEGER, + `totalMinimumDistinctEncountersWithHighRisk` INTEGER, + `mostRecentDateWithLowRisk` TEXT, + `mostRecentDateWithHighRisk` TEXT, + `numberOfDaysWithLowRisk` INTEGER, + `numberOfDaysWithHighRisk` INTEGER + ) + """.trimIndent() + ) + + Timber.i("Running MIGRATION_1_2: Insert old data.") + execSQL( + """ + INSERT INTO riskresults_new( + id, + calculatedAt, + failureReason, + totalRiskLevel, + totalMinimumDistinctEncountersWithLowRisk, + totalMinimumDistinctEncountersWithHighRisk, + mostRecentDateWithLowRisk, + mostRecentDateWithHighRisk, + numberOfDaysWithLowRisk, + numberOfDaysWithHighRisk + ) SELECT + id, + calculatedAt, + failureReason, + totalRiskLevel, + totalMinimumDistinctEncountersWithLowRisk, + totalMinimumDistinctEncountersWithHighRisk, + mostRecentDateWithLowRisk, + mostRecentDateWithHighRisk, + numberOfDaysWithLowRisk, + numberOfDaysWithHighRisk + FROM riskresults + """.trimIndent() + ) + + Timber.i("Running MIGRATION_1_2: Drop old table.") + execSQL("DROP TABLE riskresults") + + Timber.i("Running MIGRATION_1_2: Rename temporary table.") + execSQL("ALTER TABLE riskresults_new RENAME TO riskresults") + } + + private fun recreateRiskResults(database: SupportSQLiteDatabase) = with(database) { + Timber.i("Dropping and creating new riskResults v2 table.") + + execSQL("DROP TABLE IF EXISTS riskresults") + execSQL("DROP TABLE IF EXISTS riskresults_new") + + execSQL( + """ + CREATE TABLE `riskresults` ( + `monotonicId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `id` TEXT NOT NULL, + `calculatedAt` TEXT NOT NULL, + `failureReason` TEXT, + `totalRiskLevel` INTEGER, + `totalMinimumDistinctEncountersWithLowRisk` INTEGER, + `totalMinimumDistinctEncountersWithHighRisk` INTEGER, + `mostRecentDateWithLowRisk` TEXT, + `mostRecentDateWithHighRisk` TEXT, + `numberOfDaysWithLowRisk` INTEGER, + `numberOfDaysWithHighRisk` INTEGER + ) + """.trimIndent() + ) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskLevelResultDao.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskLevelResultDao.kt index 51a596d47376a04e95a941f6177d565969312e84..1be333a7f979ebae4eb0c95cb2fda7c4bf9e4616 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskLevelResultDao.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskLevelResultDao.kt @@ -15,7 +15,8 @@ import timber.log.Timber @Entity(tableName = "riskresults") data class PersistedRiskLevelResultDao( - @PrimaryKey @ColumnInfo(name = "id") val id: String, + @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "monotonicId") val monotonicId: Long = 0, + @ColumnInfo(name = "id") val id: String, @ColumnInfo(name = "calculatedAt") val calculatedAt: Instant, @ColumnInfo(name = "failureReason") val failureReason: FailureReason?, @Embedded val aggregatedRiskResult: PersistedAggregatedRiskResult? @@ -81,7 +82,7 @@ data class PersistedRiskLevelResultDao( class Converter { @TypeConverter fun toType(value: String?): FailureReason? = value?.let { - FailureReason.values().singleOrNull { it.failureCode == value } ?: FailureReason.UNKNOWN + FailureReason.values().singleOrNull { it.failureCode == value } } @TypeConverter diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt index f39e4f9dee0a89f0d4d073cca27b76c99cabfe1b..7a6e080ae54e1f5908871d854a9e0045b2432faf 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt @@ -21,13 +21,13 @@ class ExposureDetectionConfigMapperTest : BaseTest() { } @Test - fun `detection interval 0 defaults to almost infinite delay`() { + fun `detection interval 0 defaults to sane delay`() { val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder() val rawConfig = AppConfigAndroid.ApplicationConfigurationAndroid.newBuilder() .setExposureDetectionParameters(exposureDetectionParameters) .build() createInstance().map(rawConfig).apply { - minTimeBetweenDetections shouldBe Duration.standardDays(99) + minTimeBetweenDetections shouldBe Duration.standardDays(1) maxExposureDetectionsPerUTCDay shouldBe 0 } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryExtensionsTest.kt index ea4feec334a5b47f0792f5c24d615b686902b27f..ee98a610d59b9994cbd3d652d0ca837c7bd877a9 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryExtensionsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryExtensionsTest.kt @@ -1,5 +1,8 @@ package de.rki.coronawarnapp.contactdiary.util +import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryLocation +import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryPerson +import de.rki.coronawarnapp.contactdiary.model.sortByNameAndIdASC import org.junit.Assert import org.junit.jupiter.api.Test @@ -12,4 +15,78 @@ class ContactDiaryExtensionsTest { Assert.assertEquals("Granny ".formatContactDiaryNameField(5), "Grann") Assert.assertEquals(" ".formatContactDiaryNameField(2), " ") } + + @Test + fun `upper and lowercase mix sorting for names`() { + val testList = listOf( + DefaultContactDiaryPerson(1, "Max Mustermann"), + DefaultContactDiaryPerson(2, "Erika Musterfrau"), + DefaultContactDiaryPerson(3, "erika musterfrau2"), + ) + + val expectedResult = listOf( + DefaultContactDiaryPerson(2, "Erika Musterfrau"), + DefaultContactDiaryPerson(3, "erika musterfrau2"), + DefaultContactDiaryPerson(1, "Max Mustermann"), + ) + + // Test that lowercase "erika musterfrau2" is sorted to the 2nd position instead of the end + Assert.assertEquals(expectedResult, testList.sortByNameAndIdASC()) + } + + @Test + fun `sort by id when names are equal for names`() { + val testList = listOf( + DefaultContactDiaryPerson(1, "Max Mustermann"), + DefaultContactDiaryPerson(3, "Erika Musterfrau"), + DefaultContactDiaryPerson(2, "Erika Musterfrau"), + ) + + val expectedResult = listOf( + DefaultContactDiaryPerson(2, "Erika Musterfrau"), + DefaultContactDiaryPerson(3, "Erika Musterfrau"), + DefaultContactDiaryPerson(1, "Max Mustermann"), + ) + + // Test that "Erika Musterfrau" with lower personId comes before the other one, even though it was + // added as the last entry to the testList + Assert.assertEquals(expectedResult, testList.sortByNameAndIdASC()) + } + + @Test + fun `upper and lowercase mix sorting for places`() { + val testList = listOf( + DefaultContactDiaryLocation(1, "Berlin"), + DefaultContactDiaryLocation(2, "At home"), + DefaultContactDiaryLocation(3, "at home"), + ) + + val expectedResult = listOf( + DefaultContactDiaryLocation(2, "At home"), + DefaultContactDiaryLocation(3, "at home"), + DefaultContactDiaryLocation(1, "Berlin"), + ) + + // Test that lowercase "at home" is sorted to the 2nd position instead of the end + Assert.assertEquals(expectedResult, testList.sortByNameAndIdASC()) + } + + @Test + fun `sort by id when names are equal for places`() { + val testList = listOf( + DefaultContactDiaryLocation(1, "Berlin"), + DefaultContactDiaryLocation(3, "At home"), + DefaultContactDiaryLocation(2, "At home"), + ) + + val expectedResult = listOf( + DefaultContactDiaryLocation(2, "At home"), + DefaultContactDiaryLocation(3, "At home"), + DefaultContactDiaryLocation(1, "Berlin"), + ) + + // Test that "At home" with lower locationId comes before the other one, even though it was + // added as the last entry to the testList + Assert.assertEquals(expectedResult, testList.sortByNameAndIdASC()) + } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt index f85ce9af769bebddcbdd955c45a40b1268c79e07..c22593689bdb3fb649c31a47c0c244abc18a0b52 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt @@ -141,7 +141,7 @@ class DownloadDiagnosisKeysTaskTest : BaseTest() { } @Test - fun `execution is skipped if last detection was recent via`() = runBlockingTest { + fun `execution is skipped if last detection was recent`() = runBlockingTest { // Last detection was at T+2h every { timeStamper.nowUTC } returns Instant.EPOCH.plus(Duration.standardHours(2)) @@ -157,6 +157,18 @@ class DownloadDiagnosisKeysTaskTest : BaseTest() { } } + @Test + fun `execution is NOT skipped if last detection is in our future`() = runBlockingTest { + // Last detection was at T, i.e. our time is now T-1h, so it was in our future. + every { timeStamper.nowUTC } returns Instant.EPOCH.minus(Duration.standardHours(1).plus(1)) + + createInstance().run(DownloadDiagnosisKeysTask.Arguments()) + + coVerify { + enfClient.provideDiagnosisKeys(any(), any()) + } + } + @Test fun `wasLastDetectionPerformedRecently honors paramters from config`() = runBlockingTest { every { timeStamper.nowUTC } returns Instant.EPOCH.plus(Duration.standardHours(4)) diff --git a/Corona-Warn-App/src/test/java/testhelpers/IsAUnitTest.kt b/Corona-Warn-App/src/testShared/java/testhelpers/IsAUnitTest.kt similarity index 100% rename from Corona-Warn-App/src/test/java/testhelpers/IsAUnitTest.kt rename to Corona-Warn-App/src/testShared/java/testhelpers/IsAUnitTest.kt