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