diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.contactdiary.storage.ContactDiaryDatabase/2.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.contactdiary.storage.ContactDiaryDatabase/2.json index 4f53fc0540c1c155dab5a6b98fd26c8a07a280d4..7fac999cccd2e6a75fb830f388529d813b2b4de6 100644 --- a/Corona-Warn-App/schemas/de.rki.coronawarnapp.contactdiary.storage.ContactDiaryDatabase/2.json +++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.contactdiary.storage.ContactDiaryDatabase/2.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 2, - "identityHash": "abaa3557b994e3bc2a61d8ee2edff8ba", + "identityHash": "d702472d6dd506b73ff6a7b340686c9a", "entities": [ { "tableName": "locations", @@ -44,7 +44,7 @@ }, { "tableName": "locationvisits", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `fkLocationId` INTEGER NOT NULL, FOREIGN KEY(`fkLocationId`) REFERENCES `locations`(`locationId`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `fkLocationId` INTEGER NOT NULL, `duration` INTEGER, `circumstances` TEXT, FOREIGN KEY(`fkLocationId`) REFERENCES `locations`(`locationId`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "id", @@ -63,6 +63,18 @@ "columnName": "fkLocationId", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "circumstances", + "columnName": "circumstances", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -135,7 +147,7 @@ }, { "tableName": "personencounters", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `fkPersonId` INTEGER NOT NULL, FOREIGN KEY(`fkPersonId`) REFERENCES `persons`(`personId`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `fkPersonId` INTEGER NOT NULL, `durationClassification` TEXT, `withMask` INTEGER, `wasOutside` INTEGER, `circumstances` TEXT, FOREIGN KEY(`fkPersonId`) REFERENCES `persons`(`personId`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", "fields": [ { "fieldPath": "id", @@ -154,6 +166,30 @@ "columnName": "fkPersonId", "affinity": "INTEGER", "notNull": true + }, + { + "fieldPath": "durationClassification", + "columnName": "durationClassification", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "withMask", + "columnName": "withMask", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "wasOutside", + "columnName": "wasOutside", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "circumstances", + "columnName": "circumstances", + "affinity": "TEXT", + "notNull": false } ], "primaryKey": { @@ -190,7 +226,7 @@ "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, 'abaa3557b994e3bc2a61d8ee2edff8ba')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'd702472d6dd506b73ff6a7b340686c9a')" ] } } \ No newline at end of file diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabaseMigrationTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabaseMigrationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..bd23cf8461818998bacbad0daf0f931762ab63a4 --- /dev/null +++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabaseMigrationTest.kt @@ -0,0 +1,196 @@ +package de.rki.coronawarnapp.contactdiary.storage + +import android.database.sqlite.SQLiteException +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.contactdiary.storage.entity.ContactDiaryLocationEntity +import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryLocationVisitEntity +import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryLocationVisitWrapper +import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryPersonEncounterEntity +import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryPersonEncounterWrapper +import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryPersonEntity +import de.rki.coronawarnapp.contactdiary.storage.internal.migrations.ContactDiaryDatabaseMigration1To2 +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.joda.time.LocalDate +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import testhelpers.BaseTest +import java.io.IOException + +@RunWith(AndroidJUnit4::class) +class ContactDiaryDatabaseMigrationTest : BaseTest() { + private val DB_NAME = "contactdiary_migration_test.db" + + @get:Rule + val helper: MigrationTestHelper = MigrationTestHelper( + InstrumentationRegistry.getInstrumentation(), + ContactDiaryDatabase::class.java.canonicalName, + FrameworkSQLiteOpenHelperFactory() + ) + + /** + * Test migration to add new optional attributes + */ + @Test + fun migrate1To2() { + helper.createDatabase(DB_NAME, 1).apply { + execSQL( + """ + INSERT INTO "locations" ( + "locationId", + "locationName" + ) VALUES ( + '1', + 'Location1' + ); + """.trimIndent() + ) + execSQL( + """ + INSERT INTO "persons" ( + "personId", + "fullName" + ) VALUES ( + '100', + 'Person100' + ); + """.trimIndent() + ) + execSQL( + """ + INSERT INTO "locationvisits" ( + "id", + "date", + "fkLocationId" + ) VALUES ( + '2', + '2020-04-20', + '1' + ); + """.trimIndent() + ) + + execSQL( + """ + INSERT INTO "personencounters" ( + "id", + "date", + "fkPersonId" + ) VALUES ( + '3', + '2020-12-31', + '100' + ); + """.trimIndent() + ) + + close() + } + + // Run migration + helper.runMigrationsAndValidate( + DB_NAME, + 2, + true, + ContactDiaryDatabaseMigration1To2 + ) + + val daoDb = ContactDiaryDatabase.Factory( + ctx = ApplicationProvider.getApplicationContext() + ).create(databaseName = DB_NAME) + + val location = ContactDiaryLocationEntity( + locationId = 1, + locationName = "Location1", + phoneNumber = null, + emailAddress = null + ) + runBlocking { daoDb.locationDao().allEntries().first() }.single() shouldBe location + + val person = ContactDiaryPersonEntity( + personId = 100, + fullName = "Person100", + phoneNumber = null, + emailAddress = null + ) + runBlocking { daoDb.personDao().allEntries().first() }.single() shouldBe person + + runBlocking { + daoDb.locationVisitDao().allEntries().first() + }.single() shouldBe ContactDiaryLocationVisitWrapper( + contactDiaryLocationEntity = location, + contactDiaryLocationVisitEntity = ContactDiaryLocationVisitEntity( + id = 2, + date = LocalDate.parse("2020-04-20"), + fkLocationId = 1, + duration = null, + circumstances = null + ) + ) + + runBlocking { + daoDb.personEncounterDao().allEntries().first() + }.single() shouldBe ContactDiaryPersonEncounterWrapper( + contactDiaryPersonEntity = person, + contactDiaryPersonEncounterEntity = ContactDiaryPersonEncounterEntity( + id = 3, + date = LocalDate.parse("2020-12-31"), + fkPersonId = 100, + withMask = null, + wasOutside = null, + durationClassification = null, + circumstances = null + ) + ) + } + + @Test + fun migrate1To2_failure_throws() { + helper.createDatabase(DB_NAME, 1).apply { + execSQL("DROP TABLE IF EXISTS locations") + execSQL("DROP TABLE IF EXISTS locationvisits") + execSQL("DROP TABLE IF EXISTS persons") + execSQL("DROP TABLE IF EXISTS personencounters") + // Has incompatible existing column phoneNumber of wrong type + execSQL("CREATE TABLE IF NOT EXISTS `locations` (`locationId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `locationName` TEXT NOT NULL, `phoneNumber` INTEGER )") + execSQL("CREATE TABLE IF NOT EXISTS `persons` (`personId` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `fullName` TEXT NOT NULL)") + execSQL("CREATE TABLE IF NOT EXISTS `locationvisits` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `fkLocationId` INTEGER NOT NULL, FOREIGN KEY(`fkLocationId`) REFERENCES `locations`(`locationId`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + execSQL("CREATE TABLE IF NOT EXISTS `personencounters` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `date` TEXT NOT NULL, `fkPersonId` INTEGER NOT NULL, FOREIGN KEY(`fkPersonId`) REFERENCES `persons`(`personId`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)") + close() + } + + shouldThrow<SQLiteException> { + // Run migration + helper.runMigrationsAndValidate( + DB_NAME, + 2, + true, + ContactDiaryDatabaseMigration1To2 + ) + } + } + + @Test + @Throws(IOException::class) + fun migrateAll() { + helper.createDatabase(DB_NAME, 1).apply { + close() + } + + // Open latest version of the database. Room will validate the schema + // once all migrations execute. + ContactDiaryDatabase.Factory( + ctx = ApplicationProvider.getApplicationContext() + ).create(databaseName = DB_NAME).apply { + openHelper.writableDatabase + close() + } + } +} diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabaseTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabaseTest.kt index 11fb352a5b7f982f910ade41c89c384dd121783c..a0ea106ef20d1d27a0bcf94a6dbfcedfe0a050fc 100644 --- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabaseTest.kt +++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabaseTest.kt @@ -3,6 +3,7 @@ package de.rki.coronawarnapp.contactdiary.storage import androidx.room.Room import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryLocationEntity import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryLocationVisitEntity import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryLocationVisitWrapper @@ -13,6 +14,7 @@ import io.kotest.matchers.shouldBe import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.runBlocking +import org.joda.time.Duration import org.joda.time.LocalDate import org.junit.After import org.junit.Test @@ -24,18 +26,40 @@ class ContactDiaryDatabaseTest : BaseTest() { // TestData private val date = LocalDate.now() - private val person = ContactDiaryPersonEntity(personId = 1, fullName = "Peter") - private val location = ContactDiaryLocationEntity(locationId = 2, locationName = "Rewe Wiesloch") - private val personEncounter = ContactDiaryPersonEncounterEntity(id = 3, date = date, fkPersonId = person.personId) - private val locationVisit = ContactDiaryLocationVisitEntity(id = 4, date = date, fkLocationId = location.locationId) + private val person = ContactDiaryPersonEntity( + personId = 1, + fullName = "Peter", + emailAddress = "person-emailAddress", + phoneNumber = "person-phoneNumber" + ) + private val location = ContactDiaryLocationEntity( + locationId = 2, + locationName = "Rewe Wiesloch", + emailAddress = "location-emailAddress", + phoneNumber = "location-phoneNumber" + ) + private val personEncounter = ContactDiaryPersonEncounterEntity( + id = 3, + date = date, + fkPersonId = person.personId, + durationClassification = ContactDiaryPersonEncounter.DurationClassification.MORE_THAN_15_MINUTES, + withMask = true, + wasOutside = false, + circumstances = "You could see the smile under his mask." + ) + private val locationVisit = ContactDiaryLocationVisitEntity( + id = 4, + date = date, + fkLocationId = location.locationId, + duration = Duration.standardMinutes(99).millis, + circumstances = "I had to buy snacks." + ) // DB - private val contactDiaryDatabase: ContactDiaryDatabase = - Room.inMemoryDatabaseBuilder( - ApplicationProvider.getApplicationContext(), - ContactDiaryDatabase::class.java - ) - .build() + private val contactDiaryDatabase: ContactDiaryDatabase = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + ContactDiaryDatabase::class.java + ).build() private val personDao = contactDiaryDatabase.personDao() private val locationDao = contactDiaryDatabase.locationDao() @@ -97,14 +121,38 @@ class ContactDiaryDatabaseTest : BaseTest() { fun getCorrectEntityForDate() = runBlocking { val yesterday = LocalDate.now().minusDays(1) val tomorrow = LocalDate.now().plusDays(1) - val personEncounterYesterday = - ContactDiaryPersonEncounterEntity(id = 5, date = yesterday, fkPersonId = person.personId) - val personEncounterTomorrow = - ContactDiaryPersonEncounterEntity(id = 6, date = tomorrow, fkPersonId = person.personId) - val locationVisitYesterday = - ContactDiaryLocationVisitEntity(id = 7, date = yesterday, fkLocationId = location.locationId) - val locationVisitTomorrow = - ContactDiaryLocationVisitEntity(id = 8, date = tomorrow, fkLocationId = location.locationId) + val personEncounterYesterday = ContactDiaryPersonEncounterEntity( + id = 5, + date = yesterday, + fkPersonId = person.personId, + durationClassification = ContactDiaryPersonEncounter.DurationClassification.LESS_THAN_15_MINUTES, + withMask = false, + wasOutside = false, + circumstances = "encounter-yesterday" + ) + val personEncounterTomorrow = ContactDiaryPersonEncounterEntity( + id = 6, + date = tomorrow, + fkPersonId = person.personId, + durationClassification = ContactDiaryPersonEncounter.DurationClassification.MORE_THAN_15_MINUTES, + withMask = true, + wasOutside = true, + circumstances = "encounter-today" + ) + val locationVisitYesterday = ContactDiaryLocationVisitEntity( + id = 7, + date = yesterday, + fkLocationId = location.locationId, + duration = Duration.standardMinutes(42).millis, + circumstances = "visit-yesterday" + ) + val locationVisitTomorrow = ContactDiaryLocationVisitEntity( + id = 8, + date = tomorrow, + fkLocationId = location.locationId, + duration = Duration.standardMinutes(1).millis, + circumstances = "visit-today" + ) val encounterList = listOf(personEncounter, personEncounterYesterday, personEncounterTomorrow) val visitList = listOf(locationVisit, locationVisitYesterday, locationVisitTomorrow) val personEncounterFlow = personEncounterDao.allEntries().map { it.toContactDiaryPersonEncounterEntityList() } @@ -119,12 +167,61 @@ class ContactDiaryDatabaseTest : BaseTest() { personEncounterFlow.first() shouldBe encounterList locationVisitFlow.first() shouldBe visitList - personEncounterDao.entitiesForDate(yesterday).first().toContactDiaryPersonEncounterEntityList() shouldBe listOf(personEncounterYesterday) - personEncounterDao.entitiesForDate(date).first().toContactDiaryPersonEncounterEntityList() shouldBe listOf(personEncounter) - personEncounterDao.entitiesForDate(tomorrow).first().toContactDiaryPersonEncounterEntityList() shouldBe listOf(personEncounterTomorrow) + personEncounterDao.entitiesForDate(yesterday).first().toContactDiaryPersonEncounterEntityList() shouldBe listOf( + personEncounterYesterday + ) + personEncounterDao.entitiesForDate(date).first().toContactDiaryPersonEncounterEntityList() shouldBe listOf( + personEncounter + ) + personEncounterDao.entitiesForDate(tomorrow).first().toContactDiaryPersonEncounterEntityList() shouldBe listOf( + personEncounterTomorrow + ) + + locationVisitDao.entitiesForDate(yesterday).first().toContactDiaryLocationVisitEntityList() shouldBe listOf( + locationVisitYesterday + ) + locationVisitDao.entitiesForDate(date).first().toContactDiaryLocationVisitEntityList() shouldBe listOf( + locationVisit + ) + locationVisitDao.entitiesForDate(tomorrow).first().toContactDiaryLocationVisitEntityList() shouldBe listOf( + locationVisitTomorrow + ) + } + + @Test + fun updatingLocationVisits(): Unit = runBlocking { + val locationVisitFlow = locationVisitDao.allEntries().map { it.toContactDiaryLocationVisitEntityList() } + + locationDao.insert(location) + locationVisitDao.insert(listOf(locationVisit)) + + locationVisitFlow.first().single() shouldBe locationVisit + + val updatedLocation = locationVisit.copy( + duration = 123L, + circumstances = "Suspicious" + ) + locationVisitDao.update(updatedLocation) + + locationVisitFlow.first().single() shouldBe updatedLocation + } - locationVisitDao.entitiesForDate(yesterday).first().toContactDiaryLocationVisitEntityList() shouldBe listOf(locationVisitYesterday) - locationVisitDao.entitiesForDate(date).first().toContactDiaryLocationVisitEntityList() shouldBe listOf(locationVisit) - locationVisitDao.entitiesForDate(tomorrow).first().toContactDiaryLocationVisitEntityList() shouldBe listOf(locationVisitTomorrow) + @Test + fun updatingPersonEncounters(): Unit = runBlocking { + val personEncounterFlow = personEncounterDao.allEntries().map { it.toContactDiaryPersonEncounterEntityList() } + + personDao.insert(person) + personEncounterDao.insert(personEncounter) + + personEncounterFlow.first().single() shouldBe personEncounter + + val updatedEncounter = personEncounter.copy( + withMask = true, + wasOutside = false, + durationClassification = ContactDiaryPersonEncounter.DurationClassification.MORE_THAN_15_MINUTES, + circumstances = "He lend me a coffee cup but the handle broke and it dropped onto my laptop." + ) + personEncounterDao.update(updatedEncounter) + personEncounterFlow.first().single() shouldBe updatedEncounter } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabase.kt index fa8e6a74885d030de15cde3238fdc3c4cc4fa9d2..bb7eb9c19a64048d56d2f992a978bdb80011065a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabase.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/ContactDiaryDatabase.kt @@ -13,6 +13,8 @@ import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryLocationEnti import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryLocationVisitEntity import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryPersonEncounterEntity import de.rki.coronawarnapp.contactdiary.storage.entity.ContactDiaryPersonEntity +import de.rki.coronawarnapp.contactdiary.storage.internal.converters.ContactDiaryRoomConverters +import de.rki.coronawarnapp.contactdiary.storage.internal.migrations.ContactDiaryDatabaseMigration1To2 import de.rki.coronawarnapp.util.database.CommonConverters import de.rki.coronawarnapp.util.di.AppContext import javax.inject.Inject @@ -24,10 +26,10 @@ import javax.inject.Inject ContactDiaryPersonEntity::class, ContactDiaryPersonEncounterEntity::class ], - version = 2, // TODO check migration patterns + version = 2, exportSchema = true ) -@TypeConverters(CommonConverters::class) +@TypeConverters(CommonConverters::class, ContactDiaryRoomConverters::class) abstract class ContactDiaryDatabase : RoomDatabase() { abstract fun locationDao(): ContactDiaryLocationDao @@ -36,9 +38,9 @@ abstract class ContactDiaryDatabase : RoomDatabase() { abstract fun personEncounterDao(): ContactDiaryPersonEncounterDao class Factory @Inject constructor(@AppContext private val ctx: Context) { - fun create(): ContactDiaryDatabase = Room - .databaseBuilder(ctx, ContactDiaryDatabase::class.java, CONTACT_DIARY_DATABASE_NAME) - .fallbackToDestructiveMigration() // TODO we increased schema version, need to migrate? + fun create(databaseName: String = CONTACT_DIARY_DATABASE_NAME): ContactDiaryDatabase = Room + .databaseBuilder(ctx, ContactDiaryDatabase::class.java, databaseName) + .addMigrations(ContactDiaryDatabaseMigration1To2) .build() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationEntity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationEntity.kt index dc68c85122ca3412781deb22c367da62faa61c4f..c7e21989fd781d408136627899c6b122adea96bc 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationEntity.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationEntity.kt @@ -12,12 +12,17 @@ import kotlinx.parcelize.Parcelize data class ContactDiaryLocationEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "locationId") override val locationId: Long = 0L, @ColumnInfo(name = "locationName") override var locationName: String, - override val phoneNumber: String? = null, - override val emailAddress: String? = null + override val phoneNumber: String?, + override val emailAddress: String? ) : ContactDiaryLocation, Parcelable { override val stableId: Long get() = locationId } fun ContactDiaryLocation.toContactDiaryLocationEntity(): ContactDiaryLocationEntity = - ContactDiaryLocationEntity(this.locationId, this.locationName) + ContactDiaryLocationEntity( + locationId = this.locationId, + locationName = this.locationName, + phoneNumber = this.phoneNumber, + emailAddress = this.emailAddress + ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationVisitEntity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationVisitEntity.kt index 3bfdd7541c082e0559f490ae39a3fda5bef1ff6b..84195ab51ce5366b2e44e9f2630d96e767686b98 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationVisitEntity.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationVisitEntity.kt @@ -25,8 +25,16 @@ import org.joda.time.LocalDate data class ContactDiaryLocationVisitEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, @ColumnInfo(name = "date") val date: LocalDate, - @ColumnInfo(name = "fkLocationId") val fkLocationId: Long + @ColumnInfo(name = "fkLocationId") val fkLocationId: Long, + @ColumnInfo(name = "duration") val duration: Long?, + @ColumnInfo(name = "circumstances") val circumstances: String? ) fun ContactDiaryLocationVisit.toContactDiaryLocationVisitEntity(): ContactDiaryLocationVisitEntity = - ContactDiaryLocationVisitEntity(id = this.id, date = this.date, fkLocationId = this.contactDiaryLocation.locationId) + ContactDiaryLocationVisitEntity( + id = this.id, + date = this.date, + fkLocationId = this.contactDiaryLocation.locationId, + duration = this.duration, + circumstances = this.circumstances + ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationVisitWrapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationVisitWrapper.kt index 333659e2cdb0f3e8a116457c1bea579581906dcc..040de1b5c260a36759ae000e8196454d4e49b09e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationVisitWrapper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryLocationVisitWrapper.kt @@ -6,7 +6,7 @@ import de.rki.coronawarnapp.contactdiary.model.ContactDiaryLocationVisit import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryLocationVisit import de.rki.coronawarnapp.contactdiary.model.sortByNameAndIdASC -class ContactDiaryLocationVisitWrapper( +data class ContactDiaryLocationVisitWrapper( @Embedded val contactDiaryLocationVisitEntity: ContactDiaryLocationVisitEntity, @Relation(parentColumn = "fkLocationId", entityColumn = "locationId") val contactDiaryLocationEntity: ContactDiaryLocationEntity @@ -16,7 +16,9 @@ fun ContactDiaryLocationVisitWrapper.toContactDiaryLocationVisit(): ContactDiary DefaultContactDiaryLocationVisit( id = this.contactDiaryLocationVisitEntity.id, date = this.contactDiaryLocationVisitEntity.date, - contactDiaryLocation = this.contactDiaryLocationEntity + contactDiaryLocation = this.contactDiaryLocationEntity, + duration = contactDiaryLocationVisitEntity.duration, + circumstances = contactDiaryLocationVisitEntity.circumstances ) fun List<ContactDiaryLocationVisitWrapper>.toContactDiaryLocationVisitSortedList(): List<ContactDiaryLocationVisit> = diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEncounterEntity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEncounterEntity.kt index a98ba8661d9f28757e2adee4ce7cdd28f6655e89..35e70d6f7373aec5e728c16e842c29e360aa943d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEncounterEntity.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEncounterEntity.kt @@ -6,6 +6,7 @@ import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter +import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter.DurationClassification import org.joda.time.LocalDate @Entity( @@ -25,8 +26,20 @@ import org.joda.time.LocalDate data class ContactDiaryPersonEncounterEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L, @ColumnInfo(name = "date") val date: LocalDate, - @ColumnInfo(name = "fkPersonId") val fkPersonId: Long + @ColumnInfo(name = "fkPersonId") val fkPersonId: Long, + @ColumnInfo(name = "durationClassification") val durationClassification: DurationClassification?, + @ColumnInfo(name = "withMask") val withMask: Boolean?, + @ColumnInfo(name = "wasOutside") val wasOutside: Boolean?, + @ColumnInfo(name = "circumstances") val circumstances: String? ) fun ContactDiaryPersonEncounter.toContactDiaryPersonEncounterEntity(): ContactDiaryPersonEncounterEntity = - ContactDiaryPersonEncounterEntity(id = this.id, date = this.date, fkPersonId = this.contactDiaryPerson.personId) + ContactDiaryPersonEncounterEntity( + id = this.id, + date = this.date, + fkPersonId = this.contactDiaryPerson.personId, + durationClassification = this.durationClassification, + withMask = this.withMask, + wasOutside = this.wasOutside, + circumstances = this.circumstances + ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEncounterWrapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEncounterWrapper.kt index 26acf434fef972a91e789c3f2286fe2629f5ca23..8478183523726e43c48045f4e074ca58fb34c4bf 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEncounterWrapper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEncounterWrapper.kt @@ -16,7 +16,11 @@ fun ContactDiaryPersonEncounterWrapper.toContactDiaryPersonEncounter(): ContactD DefaultContactDiaryPersonEncounter( id = this.contactDiaryPersonEncounterEntity.id, date = this.contactDiaryPersonEncounterEntity.date, - contactDiaryPerson = contactDiaryPersonEntity + contactDiaryPerson = this.contactDiaryPersonEntity, + durationClassification = this.contactDiaryPersonEncounterEntity.durationClassification, + withMask = this.contactDiaryPersonEncounterEntity.withMask, + wasOutside = this.contactDiaryPersonEncounterEntity.wasOutside, + circumstances = this.contactDiaryPersonEncounterEntity.circumstances ) fun List<ContactDiaryPersonEncounterWrapper>.toContactDiaryPersonEncounterSortedList(): diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEntity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEntity.kt index 458988feed21613a77e69e41bdaf355934e21354..f9a10824c76675487d7466cfb996a0a3131b517b 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEntity.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/entity/ContactDiaryPersonEntity.kt @@ -12,12 +12,17 @@ import kotlinx.parcelize.Parcelize data class ContactDiaryPersonEntity( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "personId") override val personId: Long = 0L, @ColumnInfo(name = "fullName") override var fullName: String, - override val phoneNumber: String? = null, - override val emailAddress: String? = null + @ColumnInfo(name = "phoneNumber") override val phoneNumber: String?, + @ColumnInfo(name = "emailAddress") override val emailAddress: String? ) : ContactDiaryPerson, Parcelable { override val stableId: Long get() = personId } fun ContactDiaryPerson.toContactDiaryPersonEntity(): ContactDiaryPersonEntity = - ContactDiaryPersonEntity(this.personId, this.fullName) + ContactDiaryPersonEntity( + personId = this.personId, + fullName = this.fullName, + phoneNumber = this.phoneNumber, + emailAddress = this.emailAddress + ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/internal/converters/ContactDiaryRoomConverters.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/internal/converters/ContactDiaryRoomConverters.kt new file mode 100644 index 0000000000000000000000000000000000000000..5189e70c60b4034486559cea099d047f10210ac0 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/internal/converters/ContactDiaryRoomConverters.kt @@ -0,0 +1,20 @@ +package de.rki.coronawarnapp.contactdiary.storage.internal.converters + +import androidx.room.TypeConverter +import com.google.gson.Gson +import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter + +class ContactDiaryRoomConverters { + private val gson = Gson() + + @TypeConverter + fun toContactDurationClassification(value: String?): ContactDiaryPersonEncounter.DurationClassification? { + if (value == null) return null + return ContactDiaryPersonEncounter.DurationClassification.values().singleOrNull { it.key == value } + } + + @TypeConverter + fun fromContactDurationClassification(value: ContactDiaryPersonEncounter.DurationClassification?): String? { + return value?.key + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/internal/migrations/ContactDiaryDatabaseMigration1To2.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/internal/migrations/ContactDiaryDatabaseMigration1To2.kt new file mode 100644 index 0000000000000000000000000000000000000000..ca5ad82ca3e31d1e445a847a528826e3feefc448 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/internal/migrations/ContactDiaryDatabaseMigration1To2.kt @@ -0,0 +1,77 @@ +package de.rki.coronawarnapp.contactdiary.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 contact diary database from schema version 1 to schema version 2. + * We are adding additional columns for new optional attributes + * Person: PhoneNumber, Email + * Location: PhoneNumber, Email + * PersonEncounter: DurationType, Mask?, Outside?, Comment + * LocationVisit: Duration, Comment + */ +object ContactDiaryDatabaseMigration1To2 : 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") + e.report(ExceptionCategory.INTERNAL, "ContactDiary database migration failed.") + throw e + } + } + + private fun performMigration(database: SupportSQLiteDatabase) = with(database) { + Timber.d("Running MIGRATION_1_2") + + migratePersonsTable() + migrateLocationsTable() + migratePersonEncounterTable() + migrateLocationVisitTable() + } + + private val migratePersonsTable: SupportSQLiteDatabase.() -> Unit = { + Timber.d("Table 'persons': Add column 'phoneNumber'") + execSQL("ALTER TABLE `persons` ADD COLUMN `phoneNumber` TEXT") + + Timber.d("Table 'emailAddress': Add column 'phoneNumber'") + execSQL("ALTER TABLE `persons` ADD COLUMN `emailAddress` TEXT") + } + + private val migrateLocationsTable: SupportSQLiteDatabase.() -> Unit = { + Timber.d("Table 'locations': Add column 'phoneNumber'") + execSQL("ALTER TABLE `locations` ADD COLUMN `phoneNumber` TEXT") + + Timber.d("Table 'locations': Add column 'emailAddress'") + execSQL("ALTER TABLE `locations` ADD COLUMN `emailAddress` TEXT") + } + + private val migratePersonEncounterTable: SupportSQLiteDatabase.() -> Unit = { + Timber.d("Table 'personencounters': Add column 'durationClassification'") + execSQL("ALTER TABLE `personencounters` ADD COLUMN `durationClassification` TEXT") + + Timber.d("Table 'personencounters': Add column 'circumstances'") + execSQL("ALTER TABLE `personencounters` ADD COLUMN `circumstances` TEXT") + + Timber.d("Table 'personencounters': Add column 'withMask'") + execSQL("ALTER TABLE `personencounters` ADD COLUMN `withMask` INTEGER") + + Timber.d("Table 'personencounters': Add column 'wasOutside'") + execSQL("ALTER TABLE `personencounters` ADD COLUMN `wasOutside` INTEGER") + } + + private val migrateLocationVisitTable: SupportSQLiteDatabase.() -> Unit = { + Timber.d("Table 'locationvisits': Add column 'duration'") + execSQL("ALTER TABLE `locationvisits` ADD COLUMN `duration` INTEGER") + + Timber.d("Table 'locationvisits': Add column 'circumstances'") + execSQL("ALTER TABLE `locationvisits` ADD COLUMN `circumstances` TEXT") + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/repo/ContactDiaryRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/repo/ContactDiaryRepository.kt index 286e5d9ed7842d6dfcfd6f01962d328ba10d9224..d2685422c75f1161e171c2fc4a5316c7ffd85542 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/repo/ContactDiaryRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/repo/ContactDiaryRepository.kt @@ -22,6 +22,7 @@ interface ContactDiaryRepository { val locationVisits: Flow<List<ContactDiaryLocationVisit>> fun locationVisitsForDate(date: LocalDate): Flow<List<ContactDiaryLocationVisit>> suspend fun addLocationVisit(contactDiaryLocationVisit: ContactDiaryLocationVisit) + suspend fun updateLocationVisit(contactDiaryLocationVisit: ContactDiaryLocationVisit) suspend fun deleteLocationVisit(contactDiaryLocationVisit: ContactDiaryLocationVisit) suspend fun deleteLocationVisits(contactDiaryLocationVisits: List<ContactDiaryLocationVisit>) suspend fun deleteAllLocationVisits() @@ -38,6 +39,7 @@ interface ContactDiaryRepository { val personEncounters: Flow<List<ContactDiaryPersonEncounter>> fun personEncountersForDate(date: LocalDate): Flow<List<ContactDiaryPersonEncounter>> suspend fun addPersonEncounter(contactDiaryPersonEncounter: ContactDiaryPersonEncounter) + suspend fun updatePersonEncounter(contactDiaryPersonEncounter: ContactDiaryPersonEncounter) suspend fun deletePersonEncounter(contactDiaryPersonEncounter: ContactDiaryPersonEncounter) suspend fun deletePersonEncounters(contactDiaryPersonEncounters: List<ContactDiaryPersonEncounter>) suspend fun deleteAllPersonEncounters() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/repo/DefaultContactDiaryRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/repo/DefaultContactDiaryRepository.kt index 6cdec5daf2882792cdb9449f6bc5d250d99c0634..b4a1e1efa058075f393ef3fd6c18b11110ea7bbf 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/repo/DefaultContactDiaryRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/storage/repo/DefaultContactDiaryRepository.kt @@ -108,6 +108,13 @@ class DefaultContactDiaryRepository @Inject constructor( contactDiaryLocationVisitDao.insert(contactDiaryLocationVisitEntity) } + override suspend fun updateLocationVisit(contactDiaryLocationVisit: ContactDiaryLocationVisit) { + executeWhenIdNotDefault(contactDiaryLocationVisit.id) { + val contactDiaryLocationVisitEntity = contactDiaryLocationVisit.toContactDiaryLocationVisitEntity() + contactDiaryLocationVisitDao.update(contactDiaryLocationVisitEntity) + } + } + override suspend fun deleteLocationVisit(contactDiaryLocationVisit: ContactDiaryLocationVisit) { Timber.d("Deleting location visit $contactDiaryLocationVisit") executeWhenIdNotDefault(contactDiaryLocationVisit.id) { @@ -195,6 +202,13 @@ class DefaultContactDiaryRepository @Inject constructor( contactDiaryPersonEncounterDao.insert(contactDiaryPersonEncounterEntity) } + override suspend fun updatePersonEncounter(contactDiaryPersonEncounter: ContactDiaryPersonEncounter) { + executeWhenIdNotDefault(contactDiaryPersonEncounter.id) { + val contactDiaryPersonEncounterEntity = contactDiaryPersonEncounter.toContactDiaryPersonEncounterEntity() + contactDiaryPersonEncounterDao.update(contactDiaryPersonEncounterEntity) + } + } + override suspend fun deletePersonEncounter(contactDiaryPersonEncounter: ContactDiaryPersonEncounter) { Timber.d("Deleting person encounter $contactDiaryPersonEncounter") executeWhenIdNotDefault(contactDiaryPersonEncounter.id) { diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/storage/internal/converters/ContactDiaryRoomConvertersTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/storage/internal/converters/ContactDiaryRoomConvertersTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..18b89cc2e5267c46da57f423372b620876fc89ee --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/storage/internal/converters/ContactDiaryRoomConvertersTest.kt @@ -0,0 +1,24 @@ +package de.rki.coronawarnapp.contactdiary.storage.internal.converters + +import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter.DurationClassification +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class ContactDiaryRoomConvertersTest : BaseTest() { + + @Test + fun `test person encounter duration classification`() { + ContactDiaryRoomConverters().apply { + toContactDurationClassification(null) shouldBe null + toContactDurationClassification( + DurationClassification.MORE_THAN_15_MINUTES.key + ) shouldBe DurationClassification.MORE_THAN_15_MINUTES + + fromContactDurationClassification(null) shouldBe null + fromContactDurationClassification( + DurationClassification.LESS_THAN_15_MINUTES + ) shouldBe DurationClassification.LESS_THAN_15_MINUTES.key + } + } +}