From 8d866ed77c25e64e90d5915281296b52b5c24763 Mon Sep 17 00:00:00 2001 From: Chilja Gossow <49635654+chiljamgossow@users.noreply.github.com> Date: Wed, 24 Feb 2021 17:41:13 +0100 Subject: [PATCH] PPA Collect exposure windows (EXPOSUREAPP-4819) #2387 * comments * persistence * database * revert unrelated changes * revert unrelated changes * detekt * extract collector * unused * unit test donor * klint * unit tests * unit tests and db test * naming error * unmock object * clear and unmock object * make static * make static * comments and additional tests * inject probability * check for new before insert * Merge branch 'release/1.14.x' into feature/4819-exposure-window # Conflicts: # Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt * fix test * review comments * review comments * measure time for db insertion * adjust test * klint --- .../1.json | 140 ++++++++++++++++ .../1.json | 140 ++++++++++++++++ .../AnalyticsExposureWindowsDatabaseTest.kt | 75 +++++++++ .../datadonation/analytics/AnalyticsModule.kt | 13 +- .../AnalyticsExposureWindowCollector.kt | 51 ++++++ .../AnalyticsExposureWindowDatabase.kt | 154 ++++++++++++++++++ .../AnalyticsExposureWindowDonor.kt | 97 +++++++++++ .../AnalyticsExposureWindowModel.kt | 17 ++ .../AnalyticsExposureWindowRepository.kt | 85 ++++++++++ .../NewExposureWindowsDonor.kt | 36 ---- .../coronawarnapp/risk/DefaultRiskLevels.kt | 25 +-- .../rki/coronawarnapp/risk/RiskLevelTask.kt | 24 ++- .../de/rki/coronawarnapp/risk/RiskLevels.kt | 10 +- .../src/main/res/values-bg/strings.xml | 2 +- .../AnalyticsExposureWindowCollectorTest.kt | 70 ++++++++ .../AnalyticsExposureWindowDonorTest.kt | 129 +++++++++++++++ .../AnalyticsExposureWindowsRepositoryTest.kt | 120 ++++++++++++++ .../coronawarnapp/risk/RiskLevelTaskTest.kt | 9 +- 18 files changed, 1126 insertions(+), 71 deletions(-) create mode 100644 Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDatabase/1.json create mode 100644 Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.ExposureWindowContributionDao/1.json create mode 100644 Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsDatabaseTest.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollector.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDatabase.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonor.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowModel.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowRepository.kt delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/NewExposureWindowsDonor.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollectorTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonorTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsRepositoryTest.kt diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDatabase/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDatabase/1.json new file mode 100644 index 000000000..8b7610046 --- /dev/null +++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDatabase/1.json @@ -0,0 +1,140 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "7353e2a0b1a9b8a18e316fccdf3ee651", + "entities": [ + { + "tableName": "AnalyticsExposureWindowEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha256Hash` TEXT NOT NULL, `calibrationConfidence` INTEGER NOT NULL, `dateMillis` INTEGER NOT NULL, `infectiousness` INTEGER NOT NULL, `reportType` INTEGER NOT NULL, `normalizedTime` REAL NOT NULL, `transmissionRiskLevel` INTEGER NOT NULL, PRIMARY KEY(`sha256Hash`))", + "fields": [ + { + "fieldPath": "sha256Hash", + "columnName": "sha256Hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "calibrationConfidence", + "columnName": "calibrationConfidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "infectiousness", + "columnName": "infectiousness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reportType", + "columnName": "reportType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "normalizedTime", + "columnName": "normalizedTime", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "transmissionRiskLevel", + "columnName": "transmissionRiskLevel", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sha256Hash" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AnalyticsScanInstanceEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `fkSha256Hash` TEXT NOT NULL, `minAttenuation` INTEGER NOT NULL, `typicalAttenuation` INTEGER NOT NULL, `secondsSinceLastScan` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "fkSha256Hash", + "columnName": "fkSha256Hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minAttenuation", + "columnName": "minAttenuation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "typicalAttenuation", + "columnName": "typicalAttenuation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondsSinceLastScan", + "columnName": "secondsSinceLastScan", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "AnalyticsReportedExposureWindowEntity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha256Hash` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`sha256Hash`))", + "fields": [ + { + "fieldPath": "sha256Hash", + "columnName": "sha256Hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sha256Hash" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '7353e2a0b1a9b8a18e316fccdf3ee651')" + ] + } +} \ No newline at end of file diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.ExposureWindowContributionDao/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.ExposureWindowContributionDao/1.json new file mode 100644 index 000000000..8e46af150 --- /dev/null +++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.ExposureWindowContributionDao/1.json @@ -0,0 +1,140 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "837a6417171913af42aac6007ed1e2b1", + "entities": [ + { + "tableName": "exposureWindows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha256Hash` TEXT NOT NULL, `calibrationConfidence` INTEGER NOT NULL, `dateMillis` INTEGER NOT NULL, `infectiousness` INTEGER NOT NULL, `reportType` INTEGER NOT NULL, `normalizedTime` REAL NOT NULL, `transmissionRiskLevel` INTEGER NOT NULL, PRIMARY KEY(`sha256Hash`))", + "fields": [ + { + "fieldPath": "sha256Hash", + "columnName": "sha256Hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "calibrationConfidence", + "columnName": "calibrationConfidence", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dateMillis", + "columnName": "dateMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "infectiousness", + "columnName": "infectiousness", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reportType", + "columnName": "reportType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "normalizedTime", + "columnName": "normalizedTime", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "transmissionRiskLevel", + "columnName": "transmissionRiskLevel", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sha256Hash" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "scanInstances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `fkSha256Hash` TEXT NOT NULL, `minAttenuation` INTEGER NOT NULL, `typicalAttenuation` INTEGER NOT NULL, `secondsSinceLastScan` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fkSha256Hash", + "columnName": "fkSha256Hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minAttenuation", + "columnName": "minAttenuation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "typicalAttenuation", + "columnName": "typicalAttenuation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "secondsSinceLastScan", + "columnName": "secondsSinceLastScan", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "reportedExposureWindows", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha256Hash` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`sha256Hash`))", + "fields": [ + { + "fieldPath": "sha256Hash", + "columnName": "sha256Hash", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sha256Hash" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '837a6417171913af42aac6007ed1e2b1')" + ] + } +} \ No newline at end of file diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsDatabaseTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsDatabaseTest.kt new file mode 100644 index 000000000..37eca21f8 --- /dev/null +++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsDatabaseTest.kt @@ -0,0 +1,75 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import testhelpers.BaseTestInstrumentation + +@RunWith(AndroidJUnit4::class) +class AnalyticsExposureWindowsDatabaseTest : BaseTestInstrumentation() { + + private val database: AnalyticsExposureWindowDatabase = + Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AnalyticsExposureWindowDatabase::class.java + ) + .build() + + private val dao = database.analyticsExposureWindowDao() + + @After + fun teardown() { + database.clearAllTables() + } + + @Test + fun testMoveToReportedAndRollback() = runBlocking { + //insert new + val exposureWindowEntity = AnalyticsExposureWindowEntity("hash", 1, 1, 1, 1, 1.0, 1) + val scanInstance = AnalyticsScanInstanceEntity(null, "hash", 1, 1, 1) + val wrapper = AnalyticsExposureWindowEntityWrapper(exposureWindowEntity, listOf(scanInstance)) + dao.insert(listOf(wrapper)) + val allNew = dao.getAllNew() + allNew.size shouldBe 1 + allNew[0].exposureWindowEntity.sha256Hash shouldBe "hash" + + // move to reported + dao.moveToReported(listOf(wrapper), 999999) + dao.getAllNew() shouldBe listOf() + val reported = dao.getReported("hash") + reported!!.sha256Hash shouldBe "hash" + + //rollback + dao.rollback(listOf(wrapper), listOf(reported)) + val allNew2 = dao.getAllNew() + allNew2.size shouldBe 1 + allNew2[0].exposureWindowEntity.sha256Hash shouldBe "hash" + dao.getReported("hash") shouldBe null + } + + @Test + fun testDeleteStaleReported() = runBlocking { + //insert new + val exposureWindowEntity = AnalyticsExposureWindowEntity("hash", 1, 1, 1, 1, 1.0, 1) + val scanInstance = AnalyticsScanInstanceEntity(null, "hash", 1, 1, 1) + val wrapper = AnalyticsExposureWindowEntityWrapper(exposureWindowEntity, listOf(scanInstance)) + val exposureWindowEntity2 = AnalyticsExposureWindowEntity("hash2", 1, 1, 1, 1, 1.0, 1) + val scanInstance2 = AnalyticsScanInstanceEntity(null, "hash2", 1, 1, 1) + val wrapper2 = AnalyticsExposureWindowEntityWrapper(exposureWindowEntity2, listOf(scanInstance2)) + dao.insert(listOf(wrapper, wrapper2)) + + // move to reported + dao.moveToReported(listOf(wrapper), 999990) + dao.moveToReported(listOf(wrapper2), 999999) + + //delete stale + dao.deleteReportedOlderThan(999999) + dao.getReported("hash") shouldBe null + dao.getReported("hash2")!!.sha256Hash shouldBe "hash2" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt index 254d9b3d9..52c57ad78 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt @@ -8,6 +8,7 @@ import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule import de.rki.coronawarnapp.datadonation.analytics.modules.clientmetadata.ClientMetadataDonor import de.rki.coronawarnapp.datadonation.analytics.modules.exposureriskmetadata.ExposureRiskMetadataDonor import de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest.TestResultDonor +import de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDonor import de.rki.coronawarnapp.datadonation.analytics.modules.usermetadata.UserMetadataDonor import de.rki.coronawarnapp.datadonation.analytics.server.DataDonationAnalyticsApiV1 import de.rki.coronawarnapp.datadonation.analytics.storage.DefaultLastAnalyticsSubmissionLogger @@ -40,16 +41,14 @@ class AnalyticsModule { .create(DataDonationAnalyticsApiV1::class.java) } -// Add these back later when they actually collect data -// -// @IntoSet -// @Provides -// fun newExposureWindows(module: NewExposureWindowsDonor): DonorModule = module -// + @IntoSet + @Provides + fun newExposureWindows(module: AnalyticsExposureWindowDonor): DonorModule = module + // @IntoSet // @Provides // fun keySubmission(module: KeySubmissionStateDonor): DonorModule = module -// + @IntoSet @Provides fun registeredTest(module: TestResultDonor): DonorModule = module diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollector.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollector.kt new file mode 100644 index 000000000..ff958e997 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollector.kt @@ -0,0 +1,51 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +import com.google.android.gms.nearby.exposurenotification.ExposureWindow +import com.google.android.gms.nearby.exposurenotification.ScanInstance +import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings +import de.rki.coronawarnapp.risk.result.RiskResult +import de.rki.coronawarnapp.util.debug.measureTime +import timber.log.Timber +import javax.inject.Inject + +class AnalyticsExposureWindowCollector @Inject constructor( + private val analyticsExposureWindowRepository: AnalyticsExposureWindowRepository, + private val analyticsSettings: AnalyticsSettings +) { + suspend fun reportRiskResultsPerWindow(riskResultsPerWindow: Map<ExposureWindow, RiskResult>) { + if (analyticsSettings.analyticsEnabled.value) { + collectAnalyticsData(riskResultsPerWindow) + } + } + + private suspend fun collectAnalyticsData(riskResultsPerWindow: Map<ExposureWindow, RiskResult>) { + measureTime(onMeasured = { Timber.d("Time per db insert of exposure window is $it") }) { + riskResultsPerWindow.forEach { + val analyticsExposureWindow = createAnalyticsExposureWindow( + it.key, + it.value + ) + analyticsExposureWindowRepository.addNew(analyticsExposureWindow) + } + } + } +} + +private fun createAnalyticsExposureWindow( + window: ExposureWindow, + result: RiskResult +) = AnalyticsExposureWindow( + calibrationConfidence = window.calibrationConfidence, + dateMillis = window.dateMillisSinceEpoch, + infectiousness = window.infectiousness, + reportType = window.reportType, + analyticsScanInstances = window.scanInstances.map { it.toAnalyticsScanInstance() }, + normalizedTime = result.normalizedTime, + transmissionRiskLevel = result.transmissionRiskLevel +) + +private fun ScanInstance.toAnalyticsScanInstance() = AnalyticsScanInstance( + minAttenuation = minAttenuationDb, + typicalAttenuation = typicalAttenuationDb, + secondsSinceLastScan = secondsSinceLastScan +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDatabase.kt new file mode 100644 index 000000000..e9ed85312 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDatabase.kt @@ -0,0 +1,154 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +import android.content.Context +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Delete +import androidx.room.Embedded +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Relation +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Transaction +import de.rki.coronawarnapp.util.di.AppContext +import javax.inject.Inject + +@Database( + entities = [ + AnalyticsExposureWindowEntity::class, + AnalyticsScanInstanceEntity::class, + AnalyticsReportedExposureWindowEntity::class + ], + version = 1 +) +abstract class AnalyticsExposureWindowDatabase : RoomDatabase() { + abstract fun analyticsExposureWindowDao(): AnalyticsExposureWindowDao + + class Factory @Inject constructor(@AppContext private val context: Context) { + fun create(): AnalyticsExposureWindowDatabase = Room + .databaseBuilder( + context, + AnalyticsExposureWindowDatabase::class.java, + "AnalyticsExposureWindow-db" + ) + .fallbackToDestructiveMigration() + .build() + } +} + +@Dao +interface AnalyticsExposureWindowDao { + @Transaction + @Query("SELECT * FROM AnalyticsExposureWindowEntity") + suspend fun getAllNew(): List<AnalyticsExposureWindowEntityWrapper> + + @Query("SELECT * FROM AnalyticsExposureWindowEntity WHERE sha256Hash LIKE :sha256Hash") + suspend fun getNew(sha256Hash: String): AnalyticsExposureWindowEntity? + + @Query("SELECT * FROM AnalyticsReportedExposureWindowEntity WHERE sha256Hash LIKE :sha256Hash") + suspend fun getReported(sha256Hash: String): AnalyticsReportedExposureWindowEntity? + + @Query("SELECT * FROM AnalyticsReportedExposureWindowEntity") + suspend fun getAllReported(): List<AnalyticsReportedExposureWindowEntity> + + @Delete + suspend fun deleteExposureWindows(entities: List<AnalyticsExposureWindowEntity>) + + @Delete + suspend fun deleteScanInstances(entities: List<AnalyticsScanInstanceEntity>) + + @Delete + suspend fun deleteReported(entities: List<AnalyticsReportedExposureWindowEntity>) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertExposureWindows(entities: List<AnalyticsExposureWindowEntity>): List<Long> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertScanInstances(entity: List<AnalyticsScanInstanceEntity>): List<Long> + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertReported(entities: List<AnalyticsReportedExposureWindowEntity>): List<Long> + + @Transaction + suspend fun insert(wrappers: List<AnalyticsExposureWindowEntityWrapper>) { + insertExposureWindows(wrappers.map { it.exposureWindowEntity }) + insertScanInstances(wrappers.flatMap { it.scanInstanceEntities }) + } + + @Transaction + suspend fun moveToReported( + entities: List<AnalyticsExposureWindowEntityWrapper>, + timestamp: Long + ): List<AnalyticsReportedExposureWindowEntity> { + val reported = entities.map { + AnalyticsReportedExposureWindowEntity( + it.exposureWindowEntity.sha256Hash, + timestamp + ) + } + deleteExposureWindows(entities.map { it.exposureWindowEntity }) + deleteScanInstances(entities.flatMap { it.scanInstanceEntities }) + insertReported(reported) + return reported + } + + @Transaction + suspend fun rollback( + wrappers: List<AnalyticsExposureWindowEntityWrapper>, + reported: List<AnalyticsReportedExposureWindowEntity> + ) { + deleteReported(reported) + insertExposureWindows(wrappers.map { it.exposureWindowEntity }) + insertScanInstances(wrappers.flatMap { it.scanInstanceEntities }) + } + + @Query("DELETE FROM AnalyticsReportedExposureWindowEntity WHERE timestamp < :timestamp") + suspend fun deleteReportedOlderThan(timestamp: Long) +} + +class AnalyticsExposureWindowEntityWrapper( + @Embedded val exposureWindowEntity: AnalyticsExposureWindowEntity, + @Relation(parentColumn = PARENT_COLUMN, entityColumn = CHILD_COLUMN) + val scanInstanceEntities: List<AnalyticsScanInstanceEntity> +) + +@Entity +data class AnalyticsExposureWindowEntity( + @PrimaryKey(autoGenerate = false) val sha256Hash: String, + val calibrationConfidence: Int, + val dateMillis: Long, + val infectiousness: Int, + val reportType: Int, + val normalizedTime: Double, + val transmissionRiskLevel: Int +) + +@Entity +data class AnalyticsScanInstanceEntity( + @PrimaryKey(autoGenerate = true) val id: Long? = null, + @ForeignKey( + entity = AnalyticsExposureWindowEntity::class, + parentColumns = [PARENT_COLUMN], + childColumns = [CHILD_COLUMN], + onDelete = ForeignKey.CASCADE, + deferred = true + ) + val fkSha256Hash: String, + val minAttenuation: Int, + val typicalAttenuation: Int, + val secondsSinceLastScan: Int +) + +@Entity +data class AnalyticsReportedExposureWindowEntity( + @PrimaryKey(autoGenerate = false) val sha256Hash: String, + val timestamp: Long +) + +private const val PARENT_COLUMN = "sha256Hash" +private const val CHILD_COLUMN = "fkSha256Hash" diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonor.kt new file mode 100644 index 000000000..20483943d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonor.kt @@ -0,0 +1,97 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +import androidx.annotation.VisibleForTesting +import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule +import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.random.Random + +@Singleton +class AnalyticsExposureWindowDonor @Inject constructor( + private val analyticsExposureWindowRepository: AnalyticsExposureWindowRepository +) : DonorModule { + + override suspend fun beginDonation(request: DonorModule.Request): DonorModule.Contribution { + // clean up + analyticsExposureWindowRepository.deleteStaleData() + + val probability = request.currentConfig.analytics.probabilityToSubmitNewExposureWindows + if (skipSubmission(probability)) { + Timber.w("Submission skipped.") + return AnalyticsExposureWindowNoContribution + } + + val newWrappers = analyticsExposureWindowRepository.getAllNew() + val reported = analyticsExposureWindowRepository.moveToReported(newWrappers) + return Contribution( + data = newWrappers.asPpaData(), + onDonationFailed = { onDonationFailed(newWrappers, reported) } + ) + } + + override suspend fun deleteData() { + analyticsExposureWindowRepository.deleteAllData() + } + + @VisibleForTesting + internal fun skipSubmission(probability: Double): Boolean { + // load balancing + val random = Random.nextDouble() + Timber.w("Random number is $random. probabilityToSubmitNewExposureWindows is $probability.") + return random > probability + } + + @VisibleForTesting + internal suspend fun onDonationFailed( + newWrappers: List<AnalyticsExposureWindowEntityWrapper>, + reported: List<AnalyticsReportedExposureWindowEntity> + ) { + analyticsExposureWindowRepository.rollback(newWrappers, reported) + } + + data class Contribution( + val data: List<PpaData.PPANewExposureWindow>, + val onDonationFailed: suspend () -> Unit + ) : DonorModule.Contribution { + override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) { + protobufContainer.addAllNewExposureWindows(data) + } + + override suspend fun finishDonation(successful: Boolean) { + if (!successful) onDonationFailed() + } + } +} + +@VisibleForTesting +internal fun List<AnalyticsExposureWindowEntityWrapper>.asPpaData() = map { + val scanInstances = it.scanInstanceEntities.map { scanInstance -> + PpaData.PPAExposureWindowScanInstance.newBuilder() + .setMinAttenuation(scanInstance.minAttenuation) + .setTypicalAttenuation(scanInstance.typicalAttenuation) + .setSecondsSinceLastScan(scanInstance.secondsSinceLastScan) + .build() + } + + val exposureWindow = PpaData.PPAExposureWindow.newBuilder() + .setDate(it.exposureWindowEntity.dateMillis) + .setCalibrationConfidence(it.exposureWindowEntity.calibrationConfidence) + .setInfectiousnessValue(it.exposureWindowEntity.infectiousness) + .setReportTypeValue(it.exposureWindowEntity.reportType) + .addAllScanInstances(scanInstances) + .build() + + PpaData.PPANewExposureWindow.newBuilder() + .setExposureWindow(exposureWindow) + .setNormalizedTime(it.exposureWindowEntity.normalizedTime) + .setTransmissionRiskLevel(it.exposureWindowEntity.transmissionRiskLevel) + .build() +} + +@VisibleForTesting +object AnalyticsExposureWindowNoContribution : DonorModule.Contribution { + override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) = Unit + override suspend fun finishDonation(successful: Boolean) = Unit +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowModel.kt new file mode 100644 index 000000000..bf518144d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowModel.kt @@ -0,0 +1,17 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +data class AnalyticsExposureWindow( + val calibrationConfidence: Int, + val dateMillis: Long, + val infectiousness: Int, + val reportType: Int, + val analyticsScanInstances: List<AnalyticsScanInstance>, + val normalizedTime: Double, + val transmissionRiskLevel: Int +) + +data class AnalyticsScanInstance( + val minAttenuation: Int, + val typicalAttenuation: Int, + val secondsSinceLastScan: Int +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowRepository.kt new file mode 100644 index 000000000..7b763695a --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowRepository.kt @@ -0,0 +1,85 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +import androidx.annotation.VisibleForTesting +import de.rki.coronawarnapp.util.HashExtensions.toSHA256 +import de.rki.coronawarnapp.util.TimeStamper +import org.joda.time.Days +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AnalyticsExposureWindowRepository @Inject constructor( + private val databaseFactory: AnalyticsExposureWindowDatabase.Factory, + private val timeStamper: TimeStamper +) { + private val database by lazy { + databaseFactory.create() + } + + private val dao by lazy { + database.analyticsExposureWindowDao() + } + + suspend fun getAllNew(): List<AnalyticsExposureWindowEntityWrapper> { + return dao.getAllNew() + } + + suspend fun addNew(analyticsExposureWindow: AnalyticsExposureWindow) { + val hash = analyticsExposureWindow.sha256Hash() + if (dao.getReported(hash) == null && dao.getNew(hash) == null) { + val wrapper = analyticsExposureWindow.toWrapper(hash) + dao.insert(listOf(wrapper)) + } + } + + suspend fun moveToReported( + wrapperEntities: List<AnalyticsExposureWindowEntityWrapper> + ): List<AnalyticsReportedExposureWindowEntity> { + return dao.moveToReported(wrapperEntities, timeStamper.nowUTC.millis) + } + + suspend fun rollback( + wrappers: List<AnalyticsExposureWindowEntityWrapper>, + reported: List<AnalyticsReportedExposureWindowEntity> + ) { + dao.rollback(wrappers, reported) + } + + suspend fun deleteStaleData() { + val timestamp = timeStamper.nowUTC.minus(Days.days(15).toStandardDuration()).millis + dao.deleteReportedOlderThan(timestamp) + } + + suspend fun deleteAllData() { + val new = dao.getAllNew() + dao.deleteExposureWindows(new.map { it.exposureWindowEntity }) + dao.deleteScanInstances(new.flatMap { it.scanInstanceEntities }) + dao.deleteReported(dao.getAllReported()) + } +} + +@VisibleForTesting +internal fun AnalyticsExposureWindow.sha256Hash() = toString().toSHA256() + +@VisibleForTesting +internal fun AnalyticsExposureWindow.toWrapper(key: String) = + AnalyticsExposureWindowEntityWrapper( + exposureWindowEntity = AnalyticsExposureWindowEntity( + sha256Hash = key, + calibrationConfidence = calibrationConfidence, + dateMillis = dateMillis, + infectiousness = infectiousness, + reportType = reportType, + normalizedTime = normalizedTime, + transmissionRiskLevel = transmissionRiskLevel + ), + scanInstanceEntities = analyticsScanInstances.map { it.toEntity(key) } + ) + +private fun AnalyticsScanInstance.toEntity(foreignKey: String) = + AnalyticsScanInstanceEntity( + fkSha256Hash = foreignKey, + minAttenuation = minAttenuation, + typicalAttenuation = typicalAttenuation, + secondsSinceLastScan = secondsSinceLastScan + ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/NewExposureWindowsDonor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/NewExposureWindowsDonor.kt deleted file mode 100644 index 993e333c1..000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/NewExposureWindowsDonor.kt +++ /dev/null @@ -1,36 +0,0 @@ -package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows - -import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule -import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class NewExposureWindowsDonor @Inject constructor() : DonorModule { - - override suspend fun beginDonation(request: DonorModule.Request): DonorModule.Contribution { - return CollectedData( - protobuf = Any(), - onContributionFinished = { success -> - // TODO - } - ) - } - - override suspend fun deleteData() { - // TODO - } - - data class CollectedData( - val protobuf: Any, - val onContributionFinished: suspend (Boolean) -> Unit - ) : DonorModule.Contribution { - override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) { - // TODO "Add this specific protobuf to the top level protobuf container" - } - - override suspend fun finishDonation(successful: Boolean) { - onContributionFinished(successful) - } - } -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt index 99ce95839..734d7ff3d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt @@ -1,7 +1,6 @@ package de.rki.coronawarnapp.risk import android.text.TextUtils -import androidx.annotation.VisibleForTesting import com.google.android.gms.nearby.exposurenotification.ExposureWindow import com.google.android.gms.nearby.exposurenotification.Infectiousness import com.google.android.gms.nearby.exposurenotification.ReportType @@ -19,18 +18,6 @@ import kotlin.math.max @Singleton class DefaultRiskLevels @Inject constructor() : RiskLevels { - override fun determineRisk( - appConfig: ExposureWindowRiskCalculationConfig, - exposureWindows: List<ExposureWindow> - ): AggregatedRiskResult { - val riskResultsPerWindow = - exposureWindows.mapNotNull { window -> - calculateRisk(appConfig, window)?.let { window to it } - }.toMap() - - return aggregateResults(appConfig, riskResultsPerWindow) - } - private fun ExposureWindow.dropDueToMinutesAtAttenuation( attenuationFilters: List<RiskCalculationParametersOuterClass.MinutesAtAttenuationFilter> ) = @@ -105,8 +92,7 @@ class DefaultRiskLevels @Inject constructor() : RiskLevels { .map { it.riskLevel } .firstOrNull() - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun calculateRisk( + override fun calculateRisk( appConfig: ExposureWindowRiskCalculationConfig, exposureWindow: ExposureWindow ): RiskResult? { @@ -162,12 +148,11 @@ class DefaultRiskLevels @Inject constructor() : RiskLevels { ) } - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal fun aggregateResults( + override fun aggregateResults( appConfig: ExposureWindowRiskCalculationConfig, - exposureWindowsAndResult: Map<ExposureWindow, RiskResult> + exposureWindowResultMap: Map<ExposureWindow, RiskResult> ): AggregatedRiskResult { - val uniqueDatesMillisSinceEpoch = exposureWindowsAndResult.keys + val uniqueDatesMillisSinceEpoch = exposureWindowResultMap.keys .map { it.dateMillisSinceEpoch } .toSet() @@ -176,7 +161,7 @@ class DefaultRiskLevels @Inject constructor() : RiskLevels { { TextUtils.join(System.lineSeparator(), uniqueDatesMillisSinceEpoch) } ) val exposureHistory = uniqueDatesMillisSinceEpoch.map { - aggregateRiskPerDate(appConfig, it, exposureWindowsAndResult) + aggregateRiskPerDate(appConfig, it, exposureWindowResultMap) } Timber.d("exposureHistory size: ${exposureHistory.size}") diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt index 7b411e3a8..058d658c7 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt @@ -1,9 +1,11 @@ package de.rki.coronawarnapp.risk import android.content.Context +import com.google.android.gms.nearby.exposurenotification.ExposureWindow import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.appconfig.ConfigData import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig +import de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowCollector import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.reporting.report @@ -11,6 +13,7 @@ import de.rki.coronawarnapp.nearby.ENFClient import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection import de.rki.coronawarnapp.risk.RiskLevelResult.FailureReason +import de.rki.coronawarnapp.risk.result.AggregatedRiskResult import de.rki.coronawarnapp.risk.storage.RiskLevelStorage import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.task.Task @@ -31,7 +34,7 @@ import timber.log.Timber import javax.inject.Inject import javax.inject.Provider -@Suppress("ReturnCount") +@Suppress("ReturnCount", "LongParameterList") class RiskLevelTask @Inject constructor( private val riskLevels: RiskLevels, @AppContext private val context: Context, @@ -41,7 +44,8 @@ class RiskLevelTask @Inject constructor( private val riskLevelSettings: RiskLevelSettings, private val appConfigProvider: AppConfigProvider, private val riskLevelStorage: RiskLevelStorage, - private val keyCacheRepository: KeyCacheRepository + private val keyCacheRepository: KeyCacheRepository, + private val analyticsExposureWindowCollector: AnalyticsExposureWindowCollector ) : Task<DefaultProgress, RiskLevelTaskResult> { private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>() @@ -153,7 +157,7 @@ class RiskLevelTask @Inject constructor( Timber.tag(TAG).d("Calculating risklevel") val exposureWindows = enfClient.exposureWindows() - return riskLevels.determineRisk(configData, exposureWindows).let { + return determineRisk(configData, exposureWindows).let { Timber.tag(TAG).d("Risklevel calculated: %s", it) if (it.isIncreasedRisk()) { Timber.tag(TAG).i("Risk is increased!") @@ -169,6 +173,20 @@ class RiskLevelTask @Inject constructor( } } + private suspend fun determineRisk( + appConfig: ExposureWindowRiskCalculationConfig, + exposureWindows: List<ExposureWindow> + ): AggregatedRiskResult { + val riskResultsPerWindow = + exposureWindows.mapNotNull { window -> + riskLevels.calculateRisk(appConfig, window)?.let { window to it } + }.toMap() + + analyticsExposureWindowCollector.reportRiskResultsPerWindow(riskResultsPerWindow) + + return riskLevels.aggregateResults(appConfig, riskResultsPerWindow) + } + private suspend fun backgroundJobsEnabled() = backgroundModeStatus.isAutoModeEnabled.first().also { if (it) { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt index a3ee1addc..30089d352 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt @@ -3,11 +3,17 @@ package de.rki.coronawarnapp.risk import com.google.android.gms.nearby.exposurenotification.ExposureWindow import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig import de.rki.coronawarnapp.risk.result.AggregatedRiskResult +import de.rki.coronawarnapp.risk.result.RiskResult interface RiskLevels { - fun determineRisk( + fun calculateRisk( appConfig: ExposureWindowRiskCalculationConfig, - exposureWindows: List<ExposureWindow> + exposureWindow: ExposureWindow + ): RiskResult? + + fun aggregateResults( + appConfig: ExposureWindowRiskCalculationConfig, + exposureWindowResultMap: Map<ExposureWindow, RiskResult> ): AggregatedRiskResult } diff --git a/Corona-Warn-App/src/main/res/values-bg/strings.xml b/Corona-Warn-App/src/main/res/values-bg/strings.xml index f05ceb616..fbdda0b02 100644 --- a/Corona-Warn-App/src/main/res/values-bg/strings.xml +++ b/Corona-Warn-App/src/main/res/values-bg/strings.xml @@ -1877,4 +1877,4 @@ <string name="analytics_userinput_district_title">"Ð’ кой окръг Ñе намирате"</string> <!-- XTXT: Analytics voluntary user input, district: UNSPECIFIED --> <string name="analytics_userinput_district_unspecified">"Без отговор"</string> -</resources> \ No newline at end of file +</resources> diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollectorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollectorTest.kt new file mode 100644 index 000000000..6c4eb073f --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollectorTest.kt @@ -0,0 +1,70 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +import com.google.android.gms.nearby.exposurenotification.ExposureWindow +import com.google.android.gms.nearby.exposurenotification.ScanInstance +import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings +import de.rki.coronawarnapp.risk.result.RiskResult +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class AnalyticsExposureWindowCollectorTest : BaseTest() { + + @MockK lateinit var analyticsExposureWindowRepository: AnalyticsExposureWindowRepository + @MockK lateinit var analyticsSettings: AnalyticsSettings + @MockK lateinit var exposureWindow: ExposureWindow + @MockK lateinit var riskResult: RiskResult + @MockK lateinit var scanInstance: ScanInstance + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { scanInstance.minAttenuationDb } returns 1 + every { scanInstance.secondsSinceLastScan } returns 1 + every { scanInstance.typicalAttenuationDb } returns 1 + every { exposureWindow.calibrationConfidence } returns 1 + every { exposureWindow.dateMillisSinceEpoch } returns 1 + every { exposureWindow.infectiousness } returns 1 + every { exposureWindow.reportType } returns 1 + every { exposureWindow.scanInstances } returns listOf(scanInstance) + every { riskResult.normalizedTime } returns 1.0 + every { riskResult.transmissionRiskLevel } returns 1 + coEvery { analyticsExposureWindowRepository.addNew(any()) } just Runs + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + @Test + fun `data is stored when analytics enabled`() { + every { analyticsSettings.analyticsEnabled.value } returns true + runBlockingTest { + newInstance().reportRiskResultsPerWindow(mapOf(exposureWindow to riskResult)) + coVerify(exactly = 1) { analyticsExposureWindowRepository.addNew(any()) } + } + } + + @Test + fun `data is not stored when analytics disabled`() { + every { analyticsSettings.analyticsEnabled.value } returns false + runBlockingTest { + newInstance().reportRiskResultsPerWindow(mapOf(exposureWindow to riskResult)) + coVerify(exactly = 0) { analyticsExposureWindowRepository.addNew(any()) } + } + } + + private fun newInstance() = + AnalyticsExposureWindowCollector(analyticsExposureWindowRepository, analyticsSettings) +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonorTest.kt new file mode 100644 index 000000000..307fade38 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonorTest.kt @@ -0,0 +1,129 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +import de.rki.coronawarnapp.appconfig.ConfigData +import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockkObject +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import kotlin.random.Random + +class AnalyticsExposureWindowDonorTest : BaseTest() { + + @MockK lateinit var analyticsExposureWindowRepository: AnalyticsExposureWindowRepository + @MockK lateinit var configData: ConfigData + private val request = object : DonorModule.Request { + override val currentConfig: ConfigData + get() = configData + } + private val window = AnalyticsExposureWindowEntity( + "hash", + 1, + 1L, + 1, + 1, + 1.0, + 1 + ) + private val scanInstance = AnalyticsScanInstanceEntity(1, "hash", 1, 1, 1) + private val wrapper = AnalyticsExposureWindowEntityWrapper( + window, + listOf(scanInstance) + ) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + mockkObject(Random) + coEvery { analyticsExposureWindowRepository.deleteStaleData() } just Runs + coEvery { analyticsExposureWindowRepository.rollback(any(), any()) } just Runs + every { Random.nextDouble() } returns .5 + } + + @Test + fun `skip submission when random number greater than probability`() { + val donor = newInstance() + runBlockingTest { + donor.skipSubmission(.3) shouldBe true + } + } + + @Test + fun `execute submission when random number less or equal than probability`() { + val donor = newInstance() + runBlockingTest { + donor.skipSubmission(.5) shouldBe false + } + } + + @Test + fun `skipped submission returns empty contribution`() { + val donor = newInstance() + coEvery { configData.analytics.probabilityToSubmitNewExposureWindows } returns .4 + runBlockingTest { + donor.beginDonation(request) shouldBe AnalyticsExposureWindowNoContribution + } + } + + @Test + fun `regular submission returns stored data`() { + val donor = newInstance() + val wrappers = listOf(wrapper) + val reported = listOf( + AnalyticsReportedExposureWindowEntity( + "hash", + 1L + ) + ) + coEvery { configData.analytics.probabilityToSubmitNewExposureWindows } returns .8 + coEvery { analyticsExposureWindowRepository.getAllNew() } returns wrappers + coEvery { analyticsExposureWindowRepository.moveToReported(wrappers) } returns reported + runBlockingTest { + (donor.beginDonation(request) as AnalyticsExposureWindowDonor.Contribution).data shouldBe wrappers.asPpaData() + } + } + + @Test + fun `failure triggers rollback`() { + val donor = newInstance() + val wrappers = listOf(wrapper) + val reported = listOf( + AnalyticsReportedExposureWindowEntity( + "hash", + 1L + ) + ) + coEvery { configData.analytics.probabilityToSubmitNewExposureWindows } returns .8 + coEvery { analyticsExposureWindowRepository.getAllNew() } returns wrappers + coEvery { analyticsExposureWindowRepository.moveToReported(wrappers) } returns reported + runBlockingTest { + val contribution = donor.beginDonation(request) + contribution.finishDonation(false) + coVerify { analyticsExposureWindowRepository.rollback(wrappers, reported) } + } + } + + @Test + fun `stale data clean up`() { + val donor = newInstance() + coEvery { configData.analytics.probabilityToSubmitNewExposureWindows } returns .4 + runBlockingTest { + donor.beginDonation(request) + coVerify { analyticsExposureWindowRepository.deleteStaleData() } + } + } + + private fun newInstance() = + AnalyticsExposureWindowDonor( + analyticsExposureWindowRepository + ) +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsRepositoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsRepositoryTest.kt new file mode 100644 index 000000000..6b7da3ba1 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsRepositoryTest.kt @@ -0,0 +1,120 @@ +package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows + +import de.rki.coronawarnapp.util.TimeStamper +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.Days +import org.joda.time.Instant +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class AnalyticsExposureWindowsRepositoryTest : BaseTest() { + + @MockK lateinit var databaseFactory: AnalyticsExposureWindowDatabase.Factory + @MockK lateinit var timeStamper: TimeStamper + @MockK lateinit var analyticsExposureWindowDao: AnalyticsExposureWindowDao + @MockK lateinit var analyticsExposureWindowDatabase: AnalyticsExposureWindowDatabase + @MockK lateinit var analyticsReportedExposureWindowEntity: AnalyticsReportedExposureWindowEntity + @MockK lateinit var analyticsExposureWindowEntity: AnalyticsExposureWindowEntity + + private val analyticsScanInstance = AnalyticsScanInstance( + 1, + 1, + 1, + ) + private val analyticsExposureWindow = AnalyticsExposureWindow( + 1, + 1L, + 1, + 1, + listOf(analyticsScanInstance), + 1.0, + 1 + ) + private val now = Instant.now() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { timeStamper.nowUTC } returns now + coEvery { analyticsExposureWindowDao.deleteReportedOlderThan(any()) } just Runs + } + + @Test + fun `stale data clean up`() { + addDatabase() + runBlockingTest { + newInstance().deleteStaleData() + coVerify { + analyticsExposureWindowDao.deleteReportedOlderThan( + now.minus(Days.days(15).toStandardDuration()).millis + ) + } + } + } + + @Test + fun `insert if hash not reported or new`() { + coEvery { analyticsExposureWindowDao.getReported(any()) } returns null + coEvery { analyticsExposureWindowDao.getNew(any()) } returns null + coEvery { analyticsExposureWindowDao.insert(any()) } just Runs + addDatabase() + runBlockingTest { + newInstance().addNew(analyticsExposureWindow) + coVerify { analyticsExposureWindowDao.insert(any()) } + } + } + + @Test + fun `no insert if hash reported`() { + coEvery { analyticsExposureWindowDao.getReported(any()) } returns analyticsReportedExposureWindowEntity + coEvery { analyticsExposureWindowDao.getNew(any()) } returns analyticsExposureWindowEntity + addDatabase() + runBlockingTest { + newInstance().addNew(analyticsExposureWindow) + coVerify(exactly = 0) { analyticsExposureWindowDao.insert(any()) } + } + } + + @Test + fun `no insert if hash in new`() { + coEvery { analyticsExposureWindowDao.getReported(any()) } returns analyticsReportedExposureWindowEntity + coEvery { analyticsExposureWindowDao.getNew(any()) } returns analyticsExposureWindowEntity + addDatabase() + runBlockingTest { + newInstance().addNew(analyticsExposureWindow) + coVerify(exactly = 0) { analyticsExposureWindowDao.insert(any()) } + } + } + + @Test + fun `hash value equal for two instances with same data`() { + val copy = analyticsExposureWindow.copy() + copy.sha256Hash() shouldBe analyticsExposureWindow.sha256Hash() + } + + @Test + fun `hash value not equal for two instances with different data`() { + val copy = analyticsExposureWindow.copy(dateMillis = 9999) + copy.sha256Hash() shouldNotBe analyticsExposureWindow.sha256Hash() + } + + private fun addDatabase() { + every { analyticsExposureWindowDatabase.analyticsExposureWindowDao() } returns analyticsExposureWindowDao + every { databaseFactory.create() } returns analyticsExposureWindowDatabase + } + + private fun newInstance() = + AnalyticsExposureWindowRepository( + databaseFactory, timeStamper + ) +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTaskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTaskTest.kt index 90681c6f3..62a830527 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTaskTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTaskTest.kt @@ -6,6 +6,7 @@ import android.net.Network import android.net.NetworkCapabilities import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.appconfig.ConfigData +import de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowCollector import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository @@ -49,6 +50,7 @@ class RiskLevelTaskTest : BaseTest() { @MockK lateinit var appConfigProvider: AppConfigProvider @MockK lateinit var riskLevelStorage: RiskLevelStorage @MockK lateinit var keyCacheRepository: KeyCacheRepository + @MockK lateinit var analyticsExposureWindowCollector: AnalyticsExposureWindowCollector private val arguments: Task.Arguments = object : Task.Arguments {} @@ -92,7 +94,8 @@ class RiskLevelTaskTest : BaseTest() { riskLevelSettings = riskLevelSettings, appConfigProvider = appConfigProvider, riskLevelStorage = riskLevelStorage, - keyCacheRepository = keyCacheRepository + keyCacheRepository = keyCacheRepository, + analyticsExposureWindowCollector = analyticsExposureWindowCollector ) @Test @@ -234,8 +237,10 @@ class RiskLevelTaskTest : BaseTest() { coEvery { keyCacheRepository.getAllCachedKeys() } returns listOf(cachedKey) coEvery { enfClient.exposureWindows() } returns listOf() - every { riskLevels.determineRisk(any(), listOf()) } returns aggregatedRiskResult + every { riskLevels.calculateRisk(any(), any()) } returns null + every { riskLevels.aggregateResults(any(), any()) } returns aggregatedRiskResult every { timeStamper.nowUTC } returns now + coEvery { analyticsExposureWindowCollector.reportRiskResultsPerWindow(any()) } just Runs createTask().run(arguments) shouldBe RiskLevelTaskResult( calculatedAt = now, -- GitLab