From 2547e10aea48821f4d373f99470d171d15257f54 Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <matthias.urhahn@sap.com>
Date: Wed, 25 Nov 2020 10:55:40 +0100
Subject: [PATCH] Persist ExposureWindow based risk level calculation results
 and update the UI (EXPOSUREAPP-3910,EXPOSUREAPP-3855) (#1705)

* First draft to refactor `RiskRepository` away and persist risk level results produced by ExposureWindow calculations.

TODO: Tests, Legacy data migration, Persist ExposureWindow's in tester builds.

* Unit tests for refactored classes.

* LINTs

* First draft for legacy risk data migration.

TODO: Tests.

* Store exposure windows on device for tester builds and perform clean on them too after risk result deletions.
Split RiskResultStorage such that the exposure window storage code is only available in deviceForTesters flavor builds.

* simplification

* no preference should return no value

* Remove TODOs, singletons are refactored away ;)

* Simplify RiskLevelTask interface

* Split risk level storage implementations to let production (device flavor) have a NOOP for storing/deleting exposure windows.

* Remove unused storage function.
Add test skeletons.

* unit test

* unit test

* Update risk card to show new window mode based information.

* Fix test regressions.

* Address PR comments.

* LINTs

* Everybody get's LINT for XMAS.

* Finish unit tests and remove unused classes.

Co-authored-by: chris-cwa <chris.cwa.sap@gmail.com>
---
 .../risk/storage/DefaultRiskLevelStorage.kt   |  29 +++
 .../risk/storage/DefaultRiskLevelStorage.kt   |  57 +++++
 .../test/api/ui/TestForAPIFragment.kt         |   6 +-
 ...iskLevelCalculationFragmentCWAViewModel.kt |  66 +++--
 .../coronawarnapp/CoronaWarnApplication.kt    |   3 +
 .../appconfig/ConfigChangeDetector.kt         |  24 +-
 .../nearby/ExposureStateUpdateWorker.kt       |   8 -
 .../notification/NotificationHelper.kt        |  15 --
 .../coronawarnapp/risk/ExposureResultStore.kt |  30 ---
 .../de/rki/coronawarnapp/risk/RiskLevel.kt    |  33 ---
 .../risk/RiskLevelChangeDetector.kt           |  92 +++++++
 .../rki/coronawarnapp/risk/RiskLevelResult.kt |  53 ++++
 ...{RiskLevelData.kt => RiskLevelSettings.kt} |   2 +-
 .../rki/coronawarnapp/risk/RiskLevelTask.kt   | 212 +++++++---------
 .../coronawarnapp/risk/RiskLevelTaskResult.kt |  13 +
 .../de/rki/coronawarnapp/risk/RiskModule.kt   |  24 +-
 .../risk/result/AggregatedRiskResult.kt       |   2 +
 .../risk/storage/BaseRiskLevelStorage.kt      | 100 ++++++++
 .../risk/storage/RiskLevelStorage.kt          |  18 ++
 .../storage/internal/RiskResultDatabase.kt    |  87 +++++++
 .../PersistedRiskLevelResultDao.kt            |  73 ++++++
 .../PersistedRiskResultDaoExtensions.kt       |  24 ++
 .../windows/PersistedExposureWindowDao.kt     |  39 +++
 .../PersistedExposureWindowDaoExtensions.kt   |  29 +++
 .../PersistedExposureWindowDaoWrapper.kt      |  32 +++
 .../storage/legacy/RiskLevelResultMigrator.kt |  75 ++++++
 .../storage/EncryptedPreferences.kt           |   8 +
 .../de/rki/coronawarnapp/storage/LocalData.kt | 104 --------
 .../storage/RiskLevelRepository.kt            | 106 --------
 .../storage/TracingRepository.kt              |   4 -
 .../ui/main/home/HomeFragmentViewModel.kt     |   1 -
 .../ui/tracing/card/TracingCardState.kt       |  71 +++---
 .../tracing/card/TracingCardStateProvider.kt  |  35 +--
 .../ui/tracing/common/BaseTracingState.kt     |   7 -
 .../ui/tracing/common/RiskLevelExtensions.kt  |  43 ++++
 .../ui/tracing/details/TracingDetailsState.kt |  13 +-
 .../details/TracingDetailsStateProvider.kt    |  39 ++-
 .../de/rki/coronawarnapp/util/DataReset.kt    |   5 +-
 .../coronawarnapp/util/di/AndroidModule.kt    |   8 +
 .../coronawarnapp/util/flow/FlowExtensions.kt |  80 ++++++
 .../src/main/res/values-bg/strings.xml        |  35 ---
 .../src/main/res/values-de/strings.xml        |  35 ---
 .../src/main/res/values-en/strings.xml        |  35 ---
 .../src/main/res/values-pl/strings.xml        |  35 ---
 .../src/main/res/values-ro/strings.xml        |  35 ---
 .../src/main/res/values-tr/strings.xml        |  35 ---
 .../src/main/res/values/strings.xml           |  37 +--
 .../appconfig/ConfigChangeDetectorTest.kt     |  45 ++--
 .../DefaultDiagnosisKeyProviderTest.kt        |   1 -
 .../risk/RiskLevelChangeDetectorTest.kt       | 174 +++++++++++++
 .../coronawarnapp/risk/RiskLevelResultTest.kt |  31 +++
 ...elDataTest.kt => RiskLevelSettingsTest.kt} |   4 +-
 .../coronawarnapp/risk/RiskLevelTaskTest.kt   |  11 +-
 .../rki/coronawarnapp/risk/RiskLevelTest.kt   |  85 -------
 .../risk/storage/BaseRiskLevelStorageTest.kt  | 229 ++++++++++++++++++
 .../PersistedExposureWindowDaoTest.kt         |  41 ++++
 .../internal/PersistedRiskResultDaoTest.kt    |  46 ++++
 .../legacy/RiskLevelResultMigratorTest.kt     | 124 ++++++++++
 .../ui/tracing/card/TracingCardStateTest.kt   | 105 ++------
 .../ui/tracing/common/BaseTracingStateTest.kt |   6 -
 .../tracing/common/RiskLevelExtensionsTest.kt |  93 +++++++
 .../details/TracingDetailsStateTest.kt        |   5 -
 .../util/worker/WorkerBinderTest.kt           |   4 +-
 .../storage/DefaultRiskLevelStorageTest.kt    | 122 ++++++++++
 .../storage/DefaultRiskLevelStorageTest.kt    | 120 +++++++++
 65 files changed, 2107 insertions(+), 1056 deletions(-)
 create mode 100644 Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
 create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/ExposureResultStore.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetector.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelResult.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/{RiskLevelData.kt => RiskLevelSettings.kt} (95%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTaskResult.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorage.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/RiskLevelStorage.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/RiskResultDatabase.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskLevelResultDao.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskResultDaoExtensions.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDao.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDaoExtensions.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDaoWrapper.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/legacy/RiskLevelResultMigrator.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/EncryptedPreferences.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/RiskLevelRepository.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/RiskLevelExtensions.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetectorTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelResultTest.kt
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/{RiskLevelDataTest.kt => RiskLevelSettingsTest.kt} (91%)
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorageTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/internal/PersistedExposureWindowDaoTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/internal/PersistedRiskResultDaoTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/legacy/RiskLevelResultMigratorTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/RiskLevelExtensionsTest.kt
 create mode 100644 Corona-Warn-App/src/testDevice/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
 create mode 100644 Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt

diff --git a/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
new file mode 100644
index 000000000..e1131726c
--- /dev/null
+++ b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
@@ -0,0 +1,29 @@
+package de.rki.coronawarnapp.risk.storage
+
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
+import de.rki.coronawarnapp.risk.storage.legacy.RiskLevelResultMigrator
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DefaultRiskLevelStorage @Inject constructor(
+    riskResultDatabaseFactory: RiskResultDatabase.Factory,
+    riskLevelResultMigrator: RiskLevelResultMigrator
+) : BaseRiskLevelStorage(riskResultDatabaseFactory, riskLevelResultMigrator) {
+
+    // 2 days, 6 times per day, data is considered stale after 48 hours with risk calculation
+    // Taken from TimeVariables.MAX_STALE_EXPOSURE_RISK_RANGE
+    override val storedResultLimit: Int = 2 * 6
+
+    override suspend fun storeExposureWindows(storedResultId: String, result: RiskLevelResult) {
+        Timber.d("storeExposureWindows(): NOOP")
+        // NOOP
+    }
+
+    override suspend fun deletedOrphanedExposureWindows() {
+        Timber.d("deletedOrphanedExposureWindows(): NOOP")
+        // NOOP
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
new file mode 100644
index 000000000..0ce4d0e7a
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
@@ -0,0 +1,57 @@
+package de.rki.coronawarnapp.risk.storage
+
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
+import de.rki.coronawarnapp.risk.storage.internal.windows.PersistedExposureWindowDao.PersistedScanInstance
+import de.rki.coronawarnapp.risk.storage.internal.windows.toPersistedExposureWindow
+import de.rki.coronawarnapp.risk.storage.internal.windows.toPersistedScanInstances
+import de.rki.coronawarnapp.risk.storage.legacy.RiskLevelResultMigrator
+import kotlinx.coroutines.flow.firstOrNull
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DefaultRiskLevelStorage @Inject constructor(
+    riskResultDatabaseFactory: RiskResultDatabase.Factory,
+    riskLevelResultMigrator: RiskLevelResultMigrator
+) : BaseRiskLevelStorage(riskResultDatabaseFactory, riskLevelResultMigrator) {
+
+    // 14 days, 6 times per day
+    // For testers keep all the results!
+    override val storedResultLimit: Int = 14 * 6
+
+    override suspend fun storeExposureWindows(storedResultId: String, result: RiskLevelResult) {
+        Timber.d("Storing exposure windows for storedResultId=%s", storedResultId)
+        try {
+            val startTime = System.currentTimeMillis()
+            val exposureWindows = result.exposureWindows ?: emptyList()
+            val windowIds = exposureWindows
+                .map { it.toPersistedExposureWindow(riskLevelResultId = storedResultId) }
+                .let { exposureWindowsTables.insertWindows(it) }
+
+            require(windowIds.size == exposureWindows.size) {
+                Timber.e("Inserted ${windowIds.size}, but wanted ${exposureWindows.size}")
+            }
+
+            val persistedScanInstances: List<PersistedScanInstance> = windowIds.flatMapIndexed { index, id ->
+                val scanInstances = exposureWindows[index].scanInstances
+                scanInstances.toPersistedScanInstances(exposureWindowId = id)
+            }
+            exposureWindowsTables.insertScanInstances(persistedScanInstances)
+
+            Timber.d("Storing ExposureWindows took %dms.", (System.currentTimeMillis() - startTime))
+        } catch (e: Exception) {
+            Timber.e(e, "Failed to save exposure windows")
+        }
+    }
+
+    override suspend fun deletedOrphanedExposureWindows() {
+        Timber.d("deletedOrphanedExposureWindows() running...")
+        val currentRiskResultIds = riskResultsTables.allEntries().firstOrNull()?.map { it.id } ?: emptyList()
+
+        exposureWindowsTables.deleteByRiskResultId(currentRiskResultIds).also {
+            Timber.d("$it orphaned exposure windows were deleted.")
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt
index c03b84f39..15968158f 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt
@@ -34,8 +34,8 @@ import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationPermissionHelper
 import de.rki.coronawarnapp.receiver.ExposureStateUpdateReceiver
-import de.rki.coronawarnapp.risk.ExposureResultStore
 import de.rki.coronawarnapp.risk.TimeVariables
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange
 import de.rki.coronawarnapp.sharing.ExposureSharingService
 import de.rki.coronawarnapp.storage.AppDatabase
@@ -65,7 +65,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
 
     @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
     @Inject lateinit var enfClient: ENFClient
-    @Inject lateinit var exposureResultStore: ExposureResultStore
+    @Inject lateinit var riskLevelStorage: RiskLevelStorage
     private val vm: TestForApiFragmentViewModel by cwaViewModels { viewModelFactory }
 
     companion object {
@@ -159,7 +159,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
 
             buttonRetrieveExposureSummary.setOnClickListener {
                 vm.launch {
-                    val summary = exposureResultStore.entities.first().exposureWindows.toString()
+                    val summary = riskLevelStorage.exposureWindows.first().toString()
 
                     withContext(Dispatchers.Main) {
                         showToast(summary)
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
index dd93a0e15..8cbf5d39e 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
@@ -11,21 +11,20 @@ import de.rki.coronawarnapp.diagnosiskeys.download.DownloadDiagnosisKeysTask
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
-import de.rki.coronawarnapp.risk.ExposureResult
-import de.rki.coronawarnapp.risk.ExposureResultStore
 import de.rki.coronawarnapp.risk.RiskLevel
 import de.rki.coronawarnapp.risk.RiskLevelTask
 import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.storage.AppDatabase
 import de.rki.coronawarnapp.storage.LocalData
-import de.rki.coronawarnapp.storage.RiskLevelRepository
 import de.rki.coronawarnapp.storage.SubmissionRepository
 import de.rki.coronawarnapp.storage.TestSettings
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.task.common.DefaultTaskRequest
 import de.rki.coronawarnapp.task.submitBlocking
 import de.rki.coronawarnapp.ui.tracing.card.TracingCardStateProvider
+import de.rki.coronawarnapp.ui.tracing.common.tryLatestResultsWithDefaults
 import de.rki.coronawarnapp.util.NetworkRequestWrapper.Companion.withSuccess
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.di.AppContext
@@ -52,7 +51,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
     private val keyCacheRepository: KeyCacheRepository,
     private val appConfigProvider: AppConfigProvider,
     tracingCardStateProvider: TracingCardStateProvider,
-    private val exposureResultStore: ExposureResultStore,
+    private val riskLevelStorage: RiskLevelStorage,
     private val testSettings: TestSettings
 ) : CWAViewModel(
     dispatcherProvider = dispatcherProvider
@@ -76,19 +75,26 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
         .sample(150L)
         .asLiveData(dispatcherProvider.Default)
 
-    val exposureWindowCountString = exposureResultStore
-        .entities
-        .map { "Retrieved ${it.exposureWindows.size} Exposure Windows" }
+    val exposureWindowCountString = riskLevelStorage
+        .exposureWindows
+        .map { "Retrieved ${it.size} Exposure Windows" }
         .asLiveData()
 
-    val exposureWindows = exposureResultStore
-        .entities
-        .map { if (it.exposureWindows.isEmpty()) "Exposure windows list is empty" else it.exposureWindows.toString() }
+    val exposureWindows = riskLevelStorage
+        .exposureWindows
+        .map { if (it.isEmpty()) "Exposure windows list is empty" else it.toString() }
         .asLiveData()
 
-    val aggregatedRiskResult = exposureResultStore
-        .entities
-        .map { if (it.aggregatedRiskResult != null) it.aggregatedRiskResult.toReadableString() else "Aggregated risk result is not available" }
+    val aggregatedRiskResult = riskLevelStorage
+        .riskLevelResults
+        .map {
+            val latest = it.maxByOrNull { it.calculatedAt }
+            if (latest?.aggregatedRiskResult != null) {
+                latest.aggregatedRiskResult?.toReadableString()
+            } else {
+                "Aggregated risk result is not available"
+            }
+        }
         .asLiveData()
 
     private fun AggregatedRiskResult.toReadableString(): String = StringBuilder()
@@ -129,26 +135,24 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
         .toString()
 
     val additionalRiskCalcInfo = combine(
-        RiskLevelRepository.riskLevelScore,
-        RiskLevelRepository.riskLevelScoreLastSuccessfulCalculated,
-        exposureResultStore.matchedKeyCount,
-        exposureResultStore.daysSinceLastExposure,
+        riskLevelStorage.riskLevelResults,
         LocalData.lastTimeDiagnosisKeysFromServerFetchFlow()
-    ) { riskLevelScore,
-        riskLevelScoreLastSuccessfulCalculated,
-        matchedKeyCount,
-        daysSinceLastExposure,
-        lastTimeDiagnosisKeysFromServerFetch ->
+    ) { riskLevelResults, lastTimeDiagnosisKeysFromServerFetch ->
+
+        val (latestCalc, latestSuccessfulCalc) = riskLevelResults.tryLatestResultsWithDefaults()
+
         createAdditionalRiskCalcInfo(
-            riskLevelScore = riskLevelScore,
-            riskLevelScoreLastSuccessfulCalculated = riskLevelScoreLastSuccessfulCalculated,
-            matchedKeyCount = matchedKeyCount,
-            daysSinceLastExposure = daysSinceLastExposure,
+            latestCalc.calculatedAt,
+            riskLevelScore = latestCalc.riskLevel.raw,
+            riskLevelScoreLastSuccessfulCalculated = latestSuccessfulCalc.riskLevel.raw,
+            matchedKeyCount = latestCalc.matchedKeyCount,
+            daysSinceLastExposure = latestCalc.daysWithEncounters,
             lastTimeDiagnosisKeysFromServerFetch = lastTimeDiagnosisKeysFromServerFetch
         )
     }.asLiveData()
 
     private suspend fun createAdditionalRiskCalcInfo(
+        lastTimeRiskLevelCalculation: Instant,
         riskLevelScore: Int,
         riskLevelScoreLastSuccessfulCalculated: Int,
         matchedKeyCount: Int,
@@ -162,11 +166,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
         .appendLine("Last Time Server Fetch: ${lastTimeDiagnosisKeysFromServerFetch?.time?.let { Instant.ofEpochMilli(it) }}")
         .appendLine("Tracing Duration: ${TimeUnit.MILLISECONDS.toDays(TimeVariables.getTimeActiveTracingDuration())} days")
         .appendLine("Tracing Duration in last 14 days: ${TimeVariables.getActiveTracingDaysInRetentionPeriod()} days")
-        .appendLine(
-            "Last time risk level calculation ${
-                LocalData.lastTimeRiskLevelCalculation()?.let { Instant.ofEpochMilli(it) }
-            }"
-        )
+        .appendLine("Last time risk level calculation $lastTimeRiskLevelCalculation")
         .toString()
 
     fun retrieveDiagnosisKeys() {
@@ -204,10 +204,8 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                     // Export File Reset
                     keyCacheRepository.clear()
 
-                    exposureResultStore.entities.value = ExposureResult(emptyList(), null)
+                    riskLevelStorage.clear()
 
-                    LocalData.lastCalculatedRiskLevel(RiskLevel.UNDETERMINED.raw)
-                    LocalData.lastSuccessfullyCalculatedRiskLevel(RiskLevel.UNDETERMINED.raw)
                     LocalData.lastTimeDiagnosisKeysFromServerFetch(null)
                 } catch (e: Exception) {
                     e.report(ExceptionCategory.INTERNAL)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
index 941a2b4e1..7bdbcd80d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
@@ -17,6 +17,7 @@ import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler
 import de.rki.coronawarnapp.exception.reporting.ErrorReportReceiver
 import de.rki.coronawarnapp.exception.reporting.ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL
 import de.rki.coronawarnapp.notification.NotificationHelper
+import de.rki.coronawarnapp.risk.RiskLevelChangeDetector
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.util.CWADebug
@@ -45,6 +46,7 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
     @Inject lateinit var foregroundState: ForegroundState
     @Inject lateinit var workManager: WorkManager
     @Inject lateinit var configChangeDetector: ConfigChangeDetector
+    @Inject lateinit var riskLevelChangeDetector: RiskLevelChangeDetector
     @Inject lateinit var deadmanNotificationScheduler: DeadmanNotificationScheduler
     @LogHistoryTree @Inject lateinit var rollingLogHistory: Timber.Tree
 
@@ -78,6 +80,7 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
         }
 
         configChangeDetector.launch()
+        riskLevelChangeDetector.launch()
     }
 
     private val activityLifecycleCallback = object : ActivityLifecycleCallbacks {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigChangeDetector.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigChangeDetector.kt
index 588451c9d..da3440abb 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigChangeDetector.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigChangeDetector.kt
@@ -1,10 +1,9 @@
 package de.rki.coronawarnapp.appconfig
 
 import androidx.annotation.VisibleForTesting
-import de.rki.coronawarnapp.risk.RiskLevel
-import de.rki.coronawarnapp.risk.RiskLevelData
+import de.rki.coronawarnapp.risk.RiskLevelSettings
 import de.rki.coronawarnapp.risk.RiskLevelTask
-import de.rki.coronawarnapp.storage.RiskLevelRepository
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.task.common.DefaultTaskRequest
 import de.rki.coronawarnapp.util.coroutine.AppScope
@@ -20,7 +19,8 @@ class ConfigChangeDetector @Inject constructor(
     private val appConfigProvider: AppConfigProvider,
     private val taskController: TaskController,
     @AppScope private val appScope: CoroutineScope,
-    private val riskLevelData: RiskLevelData
+    private val riskLevelSettings: RiskLevelSettings,
+    private val riskLevelStorage: RiskLevelStorage
 ) {
 
     fun launch() {
@@ -36,28 +36,20 @@ class ConfigChangeDetector @Inject constructor(
     }
 
     @VisibleForTesting
-    internal fun check(newIdentifier: String) {
-        if (riskLevelData.lastUsedConfigIdentifier == null) {
+    internal suspend fun check(newIdentifier: String) {
+        if (riskLevelSettings.lastUsedConfigIdentifier == null) {
             // No need to reset anything if we didn't calculate a risklevel yet.
             Timber.d("Config changed, but no previous identifier is available.")
             return
         }
 
-        val oldConfigId = riskLevelData.lastUsedConfigIdentifier
+        val oldConfigId = riskLevelSettings.lastUsedConfigIdentifier
         if (newIdentifier != oldConfigId) {
             Timber.i("New config id ($newIdentifier) differs from last one ($oldConfigId), resetting.")
-            RiskLevelRepositoryDeferrer.resetRiskLevel()
+            riskLevelStorage.clear()
             taskController.submit(DefaultTaskRequest(RiskLevelTask::class, originTag = "ConfigChangeDetector"))
         } else {
             Timber.v("Config identifier ($oldConfigId) didn't change, NOOP.")
         }
     }
-
-    @VisibleForTesting
-    internal object RiskLevelRepositoryDeferrer {
-
-        fun resetRiskLevel() {
-            RiskLevelRepository.setRiskLevelScore(RiskLevel.UNDETERMINED)
-        }
-    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ExposureStateUpdateWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ExposureStateUpdateWorker.kt
index f0f0ed5c5..e383844c3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ExposureStateUpdateWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ExposureStateUpdateWorker.kt
@@ -8,8 +8,6 @@ import com.squareup.inject.assisted.Assisted
 import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
-import de.rki.coronawarnapp.risk.ExposureResult
-import de.rki.coronawarnapp.risk.ExposureResultStore
 import de.rki.coronawarnapp.risk.RiskLevelTask
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.task.common.DefaultTaskRequest
@@ -19,18 +17,12 @@ import timber.log.Timber
 class ExposureStateUpdateWorker @AssistedInject constructor(
     @Assisted val context: Context,
     @Assisted workerParams: WorkerParameters,
-    private val exposureResultStore: ExposureResultStore,
-    private val enfClient: ENFClient,
     private val taskController: TaskController
 ) : CoroutineWorker(context, workerParams) {
 
     override suspend fun doWork(): Result {
         try {
             Timber.v("worker to persist exposure summary started")
-            enfClient.exposureWindows().let {
-                exposureResultStore.entities.value = ExposureResult(it, null)
-                Timber.v("exposure summary state updated: $it")
-            }
 
             taskController.submit(
                 DefaultTaskRequest(RiskLevelTask::class, originTag = "ExposureStateUpdateWorker")
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationHelper.kt
index c642a8a4b..2da051ce6 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationHelper.kt
@@ -189,12 +189,10 @@ object NotificationHelper {
      *
      * @param title: String
      * @param content: String
-     * @param visibility: Int
      * @param expandableLongText: Boolean
      * @param notificationId: NotificationId
      * @param pendingIntent: PendingIntent
      */
-
     fun sendNotification(
         title: String,
         content: String,
@@ -209,19 +207,6 @@ object NotificationHelper {
         }
     }
 
-    /**
-     * Send notification
-     * Build and send notification with content and visibility.
-     * Notification is only sent if app is not in foreground.
-     *
-     * @param content: String
-     */
-    fun sendNotification(content: String) {
-        if (!CoronaWarnApplication.isAppInForeground) {
-            sendNotification("", content, true)
-        }
-    }
-
     /**
      * Log notification build
      * Log success or failure of creating new notification
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/ExposureResultStore.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/ExposureResultStore.kt
deleted file mode 100644
index 3b26fc497..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/ExposureResultStore.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package de.rki.coronawarnapp.risk
-
-import com.google.android.gms.nearby.exposurenotification.ExposureWindow
-import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class ExposureResultStore @Inject constructor() {
-
-    val entities = MutableStateFlow(
-        ExposureResult(
-            exposureWindows = emptyList(),
-            aggregatedRiskResult = null
-        )
-    )
-
-    internal val internalMatchedKeyCount = MutableStateFlow(0)
-    val matchedKeyCount: Flow<Int> = internalMatchedKeyCount
-
-    internal val internalDaysSinceLastExposure = MutableStateFlow(0)
-    val daysSinceLastExposure: Flow<Int> = internalDaysSinceLastExposure
-}
-
-data class ExposureResult(
-    val exposureWindows: List<ExposureWindow>,
-    val aggregatedRiskResult: AggregatedRiskResult?
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevel.kt
index 05267394d..89e6d8a21 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevel.kt
@@ -47,38 +47,5 @@ enum class RiskLevel(val raw: Int) {
                 else -> UNDETERMINED
             }
         }
-
-        // risk level categories
-        val UNSUCCESSFUL_RISK_LEVELS =
-            arrayOf(
-                UNDETERMINED,
-                NO_CALCULATION_POSSIBLE_TRACING_OFF,
-                UNKNOWN_RISK_OUTDATED_RESULTS,
-                UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
-            )
-        private val HIGH_RISK_LEVELS = arrayOf(INCREASED_RISK)
-        private val LOW_RISK_LEVELS = arrayOf(
-            UNKNOWN_RISK_INITIAL,
-            NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            LOW_LEVEL_RISK,
-            UNKNOWN_RISK_OUTDATED_RESULTS,
-            UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL,
-            UNDETERMINED
-        )
-
-        /**
-         * Checks if the RiskLevel has change from a high to low or from low to high
-         *
-         * @param previousRiskLevel previously persisted RiskLevel
-         * @param currentRiskLevel newly calculated RiskLevel
-         * @return
-         */
-        fun riskLevelChangedBetweenLowAndHigh(
-            previousRiskLevel: RiskLevel,
-            currentRiskLevel: RiskLevel
-        ): Boolean {
-            return HIGH_RISK_LEVELS.contains(previousRiskLevel) && LOW_RISK_LEVELS.contains(currentRiskLevel) ||
-                    LOW_RISK_LEVELS.contains(previousRiskLevel) && HIGH_RISK_LEVELS.contains(currentRiskLevel)
-        }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetector.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetector.kt
new file mode 100644
index 000000000..78c75891b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetector.kt
@@ -0,0 +1,92 @@
+package de.rki.coronawarnapp.risk
+
+import android.content.Context
+import androidx.annotation.VisibleForTesting
+import androidx.core.app.NotificationManagerCompat
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.notification.NotificationHelper
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.util.ForegroundState
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import timber.log.Timber
+import javax.inject.Inject
+
+class RiskLevelChangeDetector @Inject constructor(
+    @AppContext private val context: Context,
+    @AppScope private val appScope: CoroutineScope,
+    private val riskLevelStorage: RiskLevelStorage,
+    private val notificationManagerCompat: NotificationManagerCompat,
+    private val foregroundState: ForegroundState
+) {
+
+    fun launch() {
+        Timber.v("Monitoring risk level changes.")
+        riskLevelStorage.riskLevelResults
+            .map { results ->
+                results.sortedBy { it.calculatedAt }.takeLast(2)
+            }
+            .filter { it.size == 2 }
+            .onEach {
+                Timber.v("Checking for risklevel change.")
+                check(it)
+            }
+            .catch { Timber.e(it, "App config change checks failed.") }
+            .launchIn(appScope)
+    }
+
+    private suspend fun check(changedLevels: List<RiskLevelResult>) {
+        val oldResult = changedLevels.first()
+        val newResult = changedLevels.last()
+
+        val oldRiskLevel = oldResult.riskLevel
+        val newRiskLevel = newResult.riskLevel
+
+        Timber.d("last CalculatedS core is ${oldRiskLevel.raw} and Current Risk Level is ${newRiskLevel.raw}")
+
+        if (hasHighLowLevelChanged(oldRiskLevel, newRiskLevel) && !LocalData.submissionWasSuccessful()) {
+            Timber.d("Notification Permission = ${notificationManagerCompat.areNotificationsEnabled()}")
+
+            if (!foregroundState.isInForeground.first()) {
+                NotificationHelper.sendNotification("", context.getString(R.string.notification_body), true)
+            } else {
+                Timber.d("App is in foreground, not sending notifications")
+            }
+
+            Timber.d("Risk level changed and notification sent. Current Risk level is $newRiskLevel")
+        }
+
+        if (
+            oldRiskLevel.raw == RiskLevelConstants.INCREASED_RISK &&
+            newRiskLevel.raw == RiskLevelConstants.LOW_LEVEL_RISK
+        ) {
+            LocalData.isUserToBeNotifiedOfLoweredRiskLevel = true
+
+            Timber.d("Risk level changed LocalData is updated. Current Risk level is $newRiskLevel")
+        }
+    }
+
+    companion object {
+        /**
+         * Checks if the RiskLevel has change from a high to low or from low to high
+         *
+         * @param previous previously persisted RiskLevel
+         * @param current newly calculated RiskLevel
+         * @return
+         */
+        @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+        internal fun hasHighLowLevelChanged(previous: RiskLevel, current: RiskLevel) =
+            previous.isIncreasedRisk != current.isIncreasedRisk
+
+        private val RiskLevel.isIncreasedRisk: Boolean
+            get() = this == RiskLevel.INCREASED_RISK
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelResult.kt
new file mode 100644
index 000000000..b8baae9d8
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelResult.kt
@@ -0,0 +1,53 @@
+package de.rki.coronawarnapp.risk
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import org.joda.time.Instant
+
+interface RiskLevelResult {
+    val riskLevel: RiskLevel
+    val calculatedAt: Instant
+
+    val aggregatedRiskResult: AggregatedRiskResult?
+
+    /**
+     * This will only be filled in deviceForTester builds
+     */
+    val exposureWindows: List<ExposureWindow>?
+
+    val wasSuccessfullyCalculated: Boolean
+        get() = !UNSUCCESSFUL_RISK_LEVELS.contains(riskLevel)
+
+    val isIncreasedRisk: Boolean
+        get() = aggregatedRiskResult?.isIncreasedRisk() ?: false
+
+    val matchedKeyCount: Int
+        get() = if (isIncreasedRisk) {
+            aggregatedRiskResult?.totalMinimumDistinctEncountersWithHighRisk ?: 0
+        } else {
+            aggregatedRiskResult?.totalMinimumDistinctEncountersWithLowRisk ?: 0
+        }
+
+    val daysWithEncounters: Int
+        get() = if (isIncreasedRisk) {
+            aggregatedRiskResult?.numberOfDaysWithHighRisk ?: 0
+        } else {
+            aggregatedRiskResult?.numberOfDaysWithLowRisk ?: 0
+        }
+
+    val lastRiskEncounterAt: Instant?
+        get() = if (isIncreasedRisk) {
+            aggregatedRiskResult?.mostRecentDateWithHighRisk
+        } else {
+            aggregatedRiskResult?.mostRecentDateWithLowRisk
+        }
+
+    companion object {
+        private val UNSUCCESSFUL_RISK_LEVELS = arrayOf(
+            RiskLevel.UNDETERMINED,
+            RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF,
+            RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS,
+            RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelSettings.kt
similarity index 95%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelData.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelSettings.kt
index 83372c3f5..4af4d0c05 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelData.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelSettings.kt
@@ -7,7 +7,7 @@ import javax.inject.Inject
 import javax.inject.Singleton
 
 @Singleton
-class RiskLevelData @Inject constructor(
+class RiskLevelSettings @Inject constructor(
     @AppContext private val context: Context
 ) {
 
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 8069454c2..8f2cba566 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,10 +1,6 @@
 package de.rki.coronawarnapp.risk
 
 import android.content.Context
-import androidx.annotation.VisibleForTesting
-import androidx.core.app.NotificationManagerCompat
-import de.rki.coronawarnapp.CoronaWarnApplication
-import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.appconfig.ConfigData
 import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig
@@ -12,7 +8,6 @@ import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.RiskLevelCalculationException
 import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.nearby.ENFClient
-import de.rki.coronawarnapp.notification.NotificationHelper
 import de.rki.coronawarnapp.risk.RiskLevel.INCREASED_RISK
 import de.rki.coronawarnapp.risk.RiskLevel.LOW_LEVEL_RISK
 import de.rki.coronawarnapp.risk.RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF
@@ -20,8 +15,7 @@ import de.rki.coronawarnapp.risk.RiskLevel.UNDETERMINED
 import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_INITIAL
 import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS
 import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
-import de.rki.coronawarnapp.storage.LocalData
-import de.rki.coronawarnapp.storage.RiskLevelRepository
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.task.Task
 import de.rki.coronawarnapp.task.TaskCancellationException
 import de.rki.coronawarnapp.task.TaskFactory
@@ -40,66 +34,86 @@ import timber.log.Timber
 import javax.inject.Inject
 import javax.inject.Provider
 
+@Suppress("ReturnCount")
 class RiskLevelTask @Inject constructor(
     private val riskLevels: RiskLevels,
     @AppContext private val context: Context,
     private val enfClient: ENFClient,
     private val timeStamper: TimeStamper,
     private val backgroundModeStatus: BackgroundModeStatus,
-    private val riskLevelData: RiskLevelData,
+    private val riskLevelSettings: RiskLevelSettings,
     private val appConfigProvider: AppConfigProvider,
-    private val exposureResultStore: ExposureResultStore
-) : Task<DefaultProgress, RiskLevelTask.Result> {
+    private val riskLevelStorage: RiskLevelStorage
+) : Task<DefaultProgress, RiskLevelTaskResult> {
 
     private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>()
     override val progress: Flow<DefaultProgress> = internalProgress.asFlow()
 
     private var isCanceled = false
 
-    override suspend fun run(arguments: Task.Arguments): Result {
+    @Suppress("LongMethod")
+    override suspend fun run(arguments: Task.Arguments): RiskLevelTaskResult {
         try {
             Timber.d("Running with arguments=%s", arguments)
-            // If there is no connectivity the transaction will set the last calculated risk level
             if (!isNetworkEnabled(context)) {
-                RiskLevelRepository.setLastCalculatedRiskLevelAsCurrent()
-                return Result(UNDETERMINED)
+                return RiskLevelTaskResult(
+                    riskLevel = UNDETERMINED,
+                    calculatedAt = timeStamper.nowUTC
+                )
             }
 
             if (!enfClient.isTracingEnabled.first()) {
-                return Result(NO_CALCULATION_POSSIBLE_TRACING_OFF)
+                return RiskLevelTaskResult(
+                    riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF,
+                    calculatedAt = timeStamper.nowUTC
+                )
             }
 
             val configData: ConfigData = appConfigProvider.getAppConfig()
 
-            return Result(
-                when {
-                    calculationNotPossibleBecauseOfNoKeys().also {
-                        checkCancel()
-                    } -> UNKNOWN_RISK_INITIAL
-
-                    calculationNotPossibleBecauseOfOutdatedResults().also {
-                        checkCancel()
-                    } -> if (backgroundJobsEnabled()) {
-                        UNKNOWN_RISK_OUTDATED_RESULTS
-                    } else {
-                        UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
-                    }
+            return kotlin.run evaluation@{
+                if (calculationNotPossibleBecauseOfNoKeys()) {
+                    return@evaluation RiskLevelTaskResult(
+                        riskLevel = UNKNOWN_RISK_INITIAL,
+                        calculatedAt = timeStamper.nowUTC
+                    )
+                }
 
-                    isIncreasedRisk(configData).also {
-                        checkCancel()
-                    } -> INCREASED_RISK
+                if (calculationNotPossibleBecauseOfOutdatedResults()) {
+                    return@evaluation RiskLevelTaskResult(
+                        riskLevel = when (backgroundJobsEnabled()) {
+                            true -> UNKNOWN_RISK_OUTDATED_RESULTS
+                            false -> UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
+                        },
+                        calculatedAt = timeStamper.nowUTC
+                    )
+                }
+                checkCancel()
 
-                    !isActiveTracingTimeAboveThreshold().also {
-                        checkCancel()
-                    } -> UNKNOWN_RISK_INITIAL
+                val risklevelResult = calculateRiskLevel(configData)
+                if (risklevelResult.isIncreasedRisk) {
+                    return@evaluation risklevelResult
+                }
+                checkCancel()
 
-                    else -> LOW_LEVEL_RISK
-                }.also {
-                    checkCancel()
-                    updateRepository(it, timeStamper.nowUTC.millis)
-                    riskLevelData.lastUsedConfigIdentifier = configData.identifier
+                if (!isActiveTracingTimeAboveThreshold()) {
+                    return@evaluation RiskLevelTaskResult(
+                        riskLevel = UNKNOWN_RISK_INITIAL,
+                        calculatedAt = timeStamper.nowUTC
+                    )
                 }
-            )
+                checkCancel()
+
+                return@evaluation risklevelResult
+            }.also {
+                checkCancel()
+                Timber.i("Evaluation finished with %s", it)
+
+                Timber.tag(TAG).d("storeTaskResult(...)")
+                riskLevelStorage.storeResult(it)
+
+                riskLevelSettings.lastUsedConfigIdentifier = configData.identifier
+            }
         } catch (error: Exception) {
             Timber.tag(TAG).e(error)
             error.report(ExceptionCategory.EXPOSURENOTIFICATION)
@@ -111,6 +125,7 @@ class RiskLevelTask @Inject constructor(
     }
 
     private fun calculationNotPossibleBecauseOfOutdatedResults(): Boolean {
+        Timber.tag(TAG).d("Evaluating calculationNotPossibleBecauseOfOutdatedResults()")
         // if the last calculation is longer in the past as the defined threshold we return the stale state
         val timeSinceLastDiagnosisKeyFetchFromServer =
             TimeVariables.getTimeSinceLastDiagnosisKeyFetchFromServer()
@@ -119,19 +134,30 @@ class RiskLevelTask @Inject constructor(
                 )
         /** we only return outdated risk level if the threshold is reached AND the active tracing time is above the
         defined threshold because [UNKNOWN_RISK_INITIAL] overrules [UNKNOWN_RISK_OUTDATED_RESULTS] */
-        return timeSinceLastDiagnosisKeyFetchFromServer.millisecondsToHours() >
-            TimeVariables.getMaxStaleExposureRiskRange() && isActiveTracingTimeAboveThreshold()
+        return (timeSinceLastDiagnosisKeyFetchFromServer.millisecondsToHours() >
+            TimeVariables.getMaxStaleExposureRiskRange() && isActiveTracingTimeAboveThreshold()).also {
+            if (it) {
+                Timber.tag(TAG).i("Calculation was not possible because reults are outdated.")
+            } else {
+                Timber.tag(TAG).d("Results are not out dated, continuing evaluation.")
+            }
+        }
     }
 
-    private fun calculationNotPossibleBecauseOfNoKeys() =
-        (TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() == null).also {
+    private fun calculationNotPossibleBecauseOfNoKeys(): Boolean {
+        Timber.tag(TAG).d("Evaluating calculationNotPossibleBecauseOfNoKeys()")
+        return (TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() == null).also {
             if (it) {
-                Timber.tag(TAG)
-                    .v("No last time diagnosis keys from server fetch timestamp was found")
+                Timber.tag(TAG).v("No last time diagnosis keys from server fetch timestamp was found")
+            } else {
+                Timber.tag(TAG).d("Diagnosis keys from server are available, continuing evaluation.")
             }
         }
+    }
 
     private fun isActiveTracingTimeAboveThreshold(): Boolean {
+        Timber.tag(TAG).d("Evaluating isActiveTracingTimeAboveThreshold()")
+
         val durationTracingIsActive = TimeVariables.getTimeActiveTracingDuration()
         val activeTracingDurationInHours = durationTracingIsActive.millisecondsToHours()
         val durationTracingIsActiveThreshold = TimeVariables.getMinActivatedTracingTime().toLong()
@@ -144,85 +170,25 @@ class RiskLevelTask @Inject constructor(
         }
     }
 
-    private suspend fun isIncreasedRisk(configData: ExposureWindowRiskCalculationConfig): Boolean {
+    private suspend fun calculateRiskLevel(configData: ExposureWindowRiskCalculationConfig): RiskLevelTaskResult {
+        Timber.tag(TAG).d("Evaluating isIncreasedRisk(...)")
         val exposureWindows = enfClient.exposureWindows()
 
-        return riskLevels.determineRisk(configData, exposureWindows).apply {
-            // TODO This should be solved differently, by saving a more specialised result object
-            if (isIncreasedRisk()) {
-                exposureResultStore.internalMatchedKeyCount.value = totalMinimumDistinctEncountersWithHighRisk
-                exposureResultStore.internalDaysSinceLastExposure.value = numberOfDaysWithHighRisk
+        return riskLevels.determineRisk(configData, exposureWindows).let {
+            Timber.tag(TAG).d("Evaluated increased risk: %s", it)
+            if (it.isIncreasedRisk()) {
+                Timber.tag(TAG).i("Risk is increased!")
             } else {
-                exposureResultStore.internalMatchedKeyCount.value = totalMinimumDistinctEncountersWithLowRisk
-                exposureResultStore.internalDaysSinceLastExposure.value = numberOfDaysWithLowRisk
-            }
-            exposureResultStore.entities.value = ExposureResult(exposureWindows, this)
-        }.isIncreasedRisk()
-    }
-
-    private fun updateRepository(riskLevel: RiskLevel, time: Long) {
-        val rollbackItems = mutableListOf<RollbackItem>()
-        try {
-            Timber.tag(TAG).v("Update the risk level with $riskLevel")
-            val lastCalculatedRiskLevelScoreForRollback = RiskLevelRepository.getLastCalculatedScore()
-            updateRiskLevelScore(riskLevel)
-            rollbackItems.add {
-                updateRiskLevelScore(lastCalculatedRiskLevelScoreForRollback)
-            }
-
-            // risk level calculation date update
-            val lastCalculatedRiskLevelDate = LocalData.lastTimeRiskLevelCalculation()
-            LocalData.lastTimeRiskLevelCalculation(time)
-            rollbackItems.add {
-                LocalData.lastTimeRiskLevelCalculation(lastCalculatedRiskLevelDate)
-            }
-        } catch (error: Exception) {
-            Timber.tag(TAG).e(error, "Updating the RiskLevelRepository failed.")
-
-            try {
-                Timber.tag(TAG).d("Initiate Rollback")
-                for (rollbackItem: RollbackItem in rollbackItems) rollbackItem.invoke()
-            } catch (rollbackException: Exception) {
-                Timber.tag(TAG).e(rollbackException, "RiskLevelRepository rollback failed.")
+                Timber.tag(TAG).d("Risk is not increased, continuing evaluating.")
             }
 
-            throw error
-        }
-    }
-
-    /**
-     * Updates the Risk Level Score in the repository with the calculated Risk Level
-     *
-     * @param riskLevel
-     */
-    @VisibleForTesting
-    internal fun updateRiskLevelScore(riskLevel: RiskLevel) {
-        val lastCalculatedScore = RiskLevelRepository.getLastCalculatedScore()
-        Timber.d("last CalculatedS core is ${lastCalculatedScore.raw} and Current Risk Level is ${riskLevel.raw}")
-
-        if (RiskLevel.riskLevelChangedBetweenLowAndHigh(lastCalculatedScore, riskLevel) &&
-            !LocalData.submissionWasSuccessful()
-        ) {
-            Timber.d(
-                "Notification Permission = ${
-                    NotificationManagerCompat.from(CoronaWarnApplication.getAppContext()).areNotificationsEnabled()
-                }"
+            RiskLevelTaskResult(
+                riskLevel = if (it.isIncreasedRisk()) INCREASED_RISK else LOW_LEVEL_RISK,
+                aggregatedRiskResult = it,
+                exposureWindows = exposureWindows,
+                calculatedAt = timeStamper.nowUTC
             )
-
-            NotificationHelper.sendNotification(
-                CoronaWarnApplication.getAppContext().getString(R.string.notification_body)
-            )
-
-            Timber.d("Risk level changed and notification sent. Current Risk level is ${riskLevel.raw}")
         }
-        if (lastCalculatedScore.raw == RiskLevelConstants.INCREASED_RISK &&
-            riskLevel.raw == RiskLevelConstants.LOW_LEVEL_RISK
-        ) {
-            LocalData.isUserToBeNotifiedOfLoweredRiskLevel = true
-
-            Timber.d("Risk level changed LocalData is updated. Current Risk level is ${riskLevel.raw}")
-        }
-        RiskLevelRepository.setRiskLevelScore(riskLevel)
     }
 
     private fun checkCancel() {
@@ -245,12 +211,6 @@ class RiskLevelTask @Inject constructor(
         isCanceled = true
     }
 
-    class Result(val riskLevel: RiskLevel) : Task.Result {
-        override fun toString(): String {
-            return "Result(riskLevel=${riskLevel.name})"
-        }
-    }
-
     data class Config(
         // TODO unit-test that not > 9 min
         override val executionTimeout: Duration = Duration.standardMinutes(8),
@@ -262,10 +222,10 @@ class RiskLevelTask @Inject constructor(
 
     class Factory @Inject constructor(
         private val taskByDagger: Provider<RiskLevelTask>
-    ) : TaskFactory<DefaultProgress, Result> {
+    ) : TaskFactory<DefaultProgress, RiskLevelTaskResult> {
 
         override suspend fun createConfig(): TaskFactory.Config = Config()
-        override val taskProvider: () -> Task<DefaultProgress, Result> = {
+        override val taskProvider: () -> Task<DefaultProgress, RiskLevelTaskResult> = {
             taskByDagger.get()
         }
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTaskResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTaskResult.kt
new file mode 100644
index 000000000..f930cf134
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTaskResult.kt
@@ -0,0 +1,13 @@
+package de.rki.coronawarnapp.risk
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.task.Task
+import org.joda.time.Instant
+
+data class RiskLevelTaskResult(
+    override val riskLevel: RiskLevel,
+    override val calculatedAt: Instant,
+    override val aggregatedRiskResult: AggregatedRiskResult? = null,
+    override val exposureWindows: List<ExposureWindow>? = null
+) : Task.Result, RiskLevelResult
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskModule.kt
index ca97d2dc3..ecf7818a7 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskModule.kt
@@ -1,26 +1,34 @@
 package de.rki.coronawarnapp.risk
 
-import dagger.Binds
 import dagger.Module
+import dagger.Provides
 import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.risk.storage.DefaultRiskLevelStorage
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.task.Task
 import de.rki.coronawarnapp.task.TaskFactory
 import de.rki.coronawarnapp.task.TaskTypeKey
 import javax.inject.Singleton
 
 @Module
-abstract class RiskModule {
+class RiskModule {
 
-    @Binds
+    @Provides
     @IntoMap
     @TaskTypeKey(RiskLevelTask::class)
-    abstract fun riskLevelTaskFactory(
+    fun riskLevelTaskFactory(
         factory: RiskLevelTask.Factory
-    ): TaskFactory<out Task.Progress, out Task.Result>
+    ): TaskFactory<out Task.Progress, out Task.Result> = factory
 
-    @Binds
+    @Provides
     @Singleton
-    abstract fun bindRiskLevelCalculation(
+    fun bindRiskLevelCalculation(
         riskLevelCalculation: DefaultRiskLevels
-    ): RiskLevels
+    ): RiskLevels = riskLevelCalculation
+
+    @Provides
+    @Singleton
+    fun riskLevelStorage(
+        storage: DefaultRiskLevelStorage
+    ): RiskLevelStorage = storage
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskResult.kt
index 07595cd56..1a6d0c6cc 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskResult.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskResult.kt
@@ -15,4 +15,6 @@ data class AggregatedRiskResult(
 ) {
 
     fun isIncreasedRisk(): Boolean = totalRiskLevel == ProtoRiskLevel.HIGH
+
+    fun isLowRisk(): Boolean = totalRiskLevel == ProtoRiskLevel.LOW
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorage.kt
new file mode 100644
index 000000000..4c5c45727
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorage.kt
@@ -0,0 +1,100 @@
+package de.rki.coronawarnapp.risk.storage
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
+import de.rki.coronawarnapp.risk.storage.internal.riskresults.toPersistedRiskResult
+import de.rki.coronawarnapp.risk.storage.legacy.RiskLevelResultMigrator
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.joda.time.Instant
+import timber.log.Timber
+
+abstract class BaseRiskLevelStorage constructor(
+    private val riskResultDatabaseFactory: RiskResultDatabase.Factory,
+    private val riskLevelResultMigrator: RiskLevelResultMigrator
+) : RiskLevelStorage {
+
+    private val database by lazy { riskResultDatabaseFactory.create() }
+    internal val riskResultsTables by lazy { database.riskResults() }
+    internal val exposureWindowsTables by lazy { database.exposureWindows() }
+
+    abstract val storedResultLimit: Int
+
+    override val exposureWindows: Flow<List<ExposureWindow>> = exposureWindowsTables.allEntries().map { windows ->
+        windows.map { it.toExposureWindow() }
+    }
+
+    final override val riskLevelResults: Flow<List<RiskLevelResult>> = riskResultsTables.allEntries()
+        .map { latestResults ->
+            latestResults.map { it.toRiskResult() }
+        }
+        .map { results ->
+            if (results.isEmpty()) {
+                riskLevelResultMigrator.getLegacyResults()
+            } else {
+                results
+            }
+        }
+
+    override val lastRiskLevelResult: Flow<RiskLevelResult> = riskLevelResults.map { results ->
+        results.maxByOrNull { it.calculatedAt } ?: INITIAL_RESULT
+    }
+
+    override suspend fun storeResult(result: RiskLevelResult) {
+        Timber.d("Storing result (exposureWindows.size=%s)", result.exposureWindows?.size)
+
+        val storedResultId = try {
+            val startTime = System.currentTimeMillis()
+
+            val resultToPersist = result.toPersistedRiskResult()
+            riskResultsTables.insertEntry(resultToPersist).also {
+                Timber.d("Storing RiskLevelResult took %dms.", (System.currentTimeMillis() - startTime))
+            }
+
+            resultToPersist.id
+        } catch (e: Exception) {
+            Timber.e(e, "Failed to store latest result: %s", result)
+            throw e
+        }
+
+        try {
+            Timber.d("Cleaning up old results.")
+
+            riskResultsTables.deleteOldest(storedResultLimit).also {
+                Timber.d("$it old results were deleted.")
+            }
+        } catch (e: Exception) {
+            Timber.e(e, "Failed to clean up old results.")
+            throw e
+        }
+
+        Timber.d("Storing exposure windows.")
+        storeExposureWindows(storedResultId = storedResultId, result)
+
+        Timber.d("Deleting orphaned exposure windows.")
+        deletedOrphanedExposureWindows()
+    }
+
+    internal abstract suspend fun storeExposureWindows(storedResultId: String, result: RiskLevelResult)
+
+    internal abstract suspend fun deletedOrphanedExposureWindows()
+
+    override suspend fun clear() {
+        Timber.w("clear() - Clearing stored riskleve/exposure-detection results.")
+        database.clearAllTables()
+    }
+
+    companion object {
+        private val INITIAL_RESULT = object : RiskLevelResult {
+            override val riskLevel: RiskLevel = RiskLevel.LOW_LEVEL_RISK
+            override val calculatedAt: Instant = Instant.EPOCH
+            override val aggregatedRiskResult: AggregatedRiskResult? = null
+            override val exposureWindows: List<ExposureWindow>? = null
+            override val matchedKeyCount: Int = 0
+            override val daysWithEncounters: Int = 0
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/RiskLevelStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/RiskLevelStorage.kt
new file mode 100644
index 000000000..6a261f448
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/RiskLevelStorage.kt
@@ -0,0 +1,18 @@
+package de.rki.coronawarnapp.risk.storage
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import kotlinx.coroutines.flow.Flow
+
+interface RiskLevelStorage {
+
+    val exposureWindows: Flow<List<ExposureWindow>>
+
+    val riskLevelResults: Flow<List<RiskLevelResult>>
+
+    val lastRiskLevelResult: Flow<RiskLevelResult>
+
+    suspend fun storeResult(result: RiskLevelResult)
+
+    suspend fun clear()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/RiskResultDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/RiskResultDatabase.kt
new file mode 100644
index 000000000..f9f1252fa
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/RiskResultDatabase.kt
@@ -0,0 +1,87 @@
+package de.rki.coronawarnapp.risk.storage.internal
+
+import android.content.Context
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao
+import de.rki.coronawarnapp.risk.storage.internal.windows.PersistedExposureWindowDao
+import de.rki.coronawarnapp.risk.storage.internal.windows.PersistedExposureWindowDaoWrapper
+import de.rki.coronawarnapp.util.database.CommonConverters
+import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.flow.Flow
+import timber.log.Timber
+import javax.inject.Inject
+
+@Suppress("MaxLineLength")
+@Database(
+    entities = [
+        PersistedRiskLevelResultDao::class,
+        PersistedExposureWindowDao::class,
+        PersistedExposureWindowDao.PersistedScanInstance::class
+    ],
+    version = 1,
+    exportSchema = true
+)
+@TypeConverters(
+    CommonConverters::class,
+    PersistedRiskLevelResultDao.Converter::class,
+    PersistedRiskLevelResultDao.PersistedAggregatedRiskResult.Converter::class
+)
+abstract class RiskResultDatabase : RoomDatabase() {
+
+    abstract fun riskResults(): RiskResultsDao
+
+    abstract fun exposureWindows(): ExposureWindowsDao
+
+    @Dao
+    interface RiskResultsDao {
+        @Query("SELECT * FROM riskresults")
+        fun allEntries(): Flow<List<PersistedRiskLevelResultDao>>
+
+        @Insert(onConflict = OnConflictStrategy.ABORT)
+        suspend fun insertEntry(riskResultDao: PersistedRiskLevelResultDao)
+
+        @Query(
+            "DELETE FROM riskresults where id NOT IN (SELECT id from riskresults ORDER BY calculatedAt DESC LIMIT :keep)"
+        )
+        suspend fun deleteOldest(keep: Int): Int
+    }
+
+    @Dao
+    interface ExposureWindowsDao {
+        @Query("SELECT * FROM exposurewindows")
+        fun allEntries(): Flow<List<PersistedExposureWindowDaoWrapper>>
+
+        @Insert(onConflict = OnConflictStrategy.REPLACE)
+        suspend fun insertWindows(exposureWindows: List<PersistedExposureWindowDao>): List<Long>
+
+        @Insert(onConflict = OnConflictStrategy.REPLACE)
+        suspend fun insertScanInstances(scanInstances: List<PersistedExposureWindowDao.PersistedScanInstance>)
+
+        @Query(
+            "DELETE FROM exposurewindows where riskLevelResultId NOT IN (:riskResultIds)"
+        )
+        suspend fun deleteByRiskResultId(riskResultIds: List<String>): Int
+    }
+
+    class Factory @Inject constructor(@AppContext private val context: Context) {
+
+        fun create(): RiskResultDatabase {
+            Timber.d("Instantiating risk result database.")
+            return Room
+                .databaseBuilder(context, RiskResultDatabase::class.java, DATABASE_NAME)
+                .fallbackToDestructiveMigrationFrom()
+                .build()
+        }
+    }
+
+    companion object {
+        private const val DATABASE_NAME = "riskresults.db"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskLevelResultDao.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskLevelResultDao.kt
new file mode 100644
index 000000000..e9f15fcb5
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskLevelResultDao.kt
@@ -0,0 +1,73 @@
+package de.rki.coronawarnapp.risk.storage.internal.riskresults
+
+import androidx.room.ColumnInfo
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import androidx.room.TypeConverter
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelTaskResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping
+import org.joda.time.Instant
+
+@Entity(tableName = "riskresults")
+data class PersistedRiskLevelResultDao(
+    @PrimaryKey @ColumnInfo(name = "id") val id: String,
+    @ColumnInfo(name = "riskLevel") val riskLevel: RiskLevel,
+    @ColumnInfo(name = "calculatedAt") val calculatedAt: Instant,
+    @Embedded val aggregatedRiskResult: PersistedAggregatedRiskResult?
+) {
+
+    fun toRiskResult() = RiskLevelTaskResult(
+        riskLevel = riskLevel,
+        calculatedAt = calculatedAt,
+        aggregatedRiskResult = aggregatedRiskResult?.toAggregatedRiskResult()
+    )
+
+    data class PersistedAggregatedRiskResult(
+        @ColumnInfo(name = "totalRiskLevel")
+        val totalRiskLevel: NormalizedTimeToRiskLevelMapping.RiskLevel,
+        @ColumnInfo(name = "totalMinimumDistinctEncountersWithLowRisk")
+        val totalMinimumDistinctEncountersWithLowRisk: Int,
+        @ColumnInfo(name = "totalMinimumDistinctEncountersWithHighRisk")
+        val totalMinimumDistinctEncountersWithHighRisk: Int,
+        @ColumnInfo(name = "mostRecentDateWithLowRisk")
+        val mostRecentDateWithLowRisk: Instant?,
+        @ColumnInfo(name = "mostRecentDateWithHighRisk")
+        val mostRecentDateWithHighRisk: Instant?,
+        @ColumnInfo(name = "numberOfDaysWithLowRisk")
+        val numberOfDaysWithLowRisk: Int,
+        @ColumnInfo(name = "numberOfDaysWithHighRisk")
+        val numberOfDaysWithHighRisk: Int
+    ) {
+
+        fun toAggregatedRiskResult() = AggregatedRiskResult(
+            totalRiskLevel = totalRiskLevel,
+            totalMinimumDistinctEncountersWithLowRisk = totalMinimumDistinctEncountersWithLowRisk,
+            totalMinimumDistinctEncountersWithHighRisk = totalMinimumDistinctEncountersWithHighRisk,
+            mostRecentDateWithLowRisk = mostRecentDateWithLowRisk,
+            mostRecentDateWithHighRisk = mostRecentDateWithHighRisk,
+            numberOfDaysWithLowRisk = numberOfDaysWithLowRisk,
+            numberOfDaysWithHighRisk = numberOfDaysWithHighRisk
+        )
+
+        class Converter {
+            @TypeConverter
+            fun toType(value: Int?): NormalizedTimeToRiskLevelMapping.RiskLevel? =
+                value?.let { NormalizedTimeToRiskLevelMapping.RiskLevel.forNumber(value) }
+
+            @TypeConverter
+            fun fromType(type: NormalizedTimeToRiskLevelMapping.RiskLevel?): Int? = type?.number
+        }
+    }
+
+    class Converter {
+        @TypeConverter
+        fun toType(value: Int?): RiskLevel? =
+            value?.let { RiskLevel.values().single { it.raw == value } }
+
+        @TypeConverter
+        fun fromType(type: RiskLevel?): Int? = type?.raw
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskResultDaoExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskResultDaoExtensions.kt
new file mode 100644
index 000000000..4fbf56eca
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/riskresults/PersistedRiskResultDaoExtensions.kt
@@ -0,0 +1,24 @@
+package de.rki.coronawarnapp.risk.storage.internal.riskresults
+
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import java.util.UUID
+
+fun RiskLevelResult.toPersistedRiskResult(
+    id: String = UUID.randomUUID().toString()
+) = PersistedRiskLevelResultDao(
+    id = id,
+    riskLevel = riskLevel,
+    calculatedAt = calculatedAt,
+    aggregatedRiskResult = aggregatedRiskResult?.toPersistedAggregatedRiskResult()
+)
+
+fun AggregatedRiskResult.toPersistedAggregatedRiskResult() = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+    totalRiskLevel = totalRiskLevel,
+    totalMinimumDistinctEncountersWithLowRisk = totalMinimumDistinctEncountersWithLowRisk,
+    totalMinimumDistinctEncountersWithHighRisk = totalMinimumDistinctEncountersWithHighRisk,
+    mostRecentDateWithLowRisk = mostRecentDateWithLowRisk,
+    mostRecentDateWithHighRisk = mostRecentDateWithHighRisk,
+    numberOfDaysWithLowRisk = numberOfDaysWithLowRisk,
+    numberOfDaysWithHighRisk = numberOfDaysWithHighRisk
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDao.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDao.kt
new file mode 100644
index 000000000..c82caf8dd
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDao.kt
@@ -0,0 +1,39 @@
+package de.rki.coronawarnapp.risk.storage.internal.windows
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.ForeignKey.CASCADE
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "exposurewindows")
+data class PersistedExposureWindowDao(
+    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
+    @ColumnInfo(name = "riskLevelResultId") val riskLevelResultId: String,
+    @ColumnInfo(name = "dateMillisSinceEpoch") val dateMillisSinceEpoch: Long,
+    @ColumnInfo(name = "calibrationConfidence") val calibrationConfidence: Int,
+    @ColumnInfo(name = "infectiousness") val infectiousness: Int,
+    @ColumnInfo(name = "reportType") val reportType: Int
+) {
+
+    @Entity(
+        tableName = "scaninstances",
+        foreignKeys = [
+            ForeignKey(
+                onDelete = CASCADE,
+                entity = PersistedExposureWindowDao::class,
+                parentColumns = ["id"],
+                childColumns = ["exposureWindowId"]
+            )
+        ],
+        indices = [Index("exposureWindowId")]
+    )
+    data class PersistedScanInstance(
+        @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0,
+        @ColumnInfo(name = "exposureWindowId") val exposureWindowId: Long,
+        @ColumnInfo(name = "minAttenuationDb") val minAttenuationDb: Int,
+        @ColumnInfo(name = "secondsSinceLastScan") val secondsSinceLastScan: Int,
+        @ColumnInfo(name = "typicalAttenuationDb") val typicalAttenuationDb: Int
+    )
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDaoExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDaoExtensions.kt
new file mode 100644
index 000000000..2d43fe68d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDaoExtensions.kt
@@ -0,0 +1,29 @@
+package de.rki.coronawarnapp.risk.storage.internal.windows
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import com.google.android.gms.nearby.exposurenotification.ScanInstance
+
+fun ExposureWindow.toPersistedExposureWindow(
+    riskLevelResultId: String
+) = PersistedExposureWindowDao(
+    riskLevelResultId = riskLevelResultId,
+    dateMillisSinceEpoch = this.dateMillisSinceEpoch,
+    calibrationConfidence = this.calibrationConfidence,
+    infectiousness = this.infectiousness,
+    reportType = this.reportType
+)
+
+fun List<ExposureWindow>.toPersistedExposureWindows(
+    riskLevelResultId: String
+) = this.map { it.toPersistedExposureWindow(riskLevelResultId) }
+
+fun ScanInstance.toPersistedScanInstance(exposureWindowId: Long) = PersistedExposureWindowDao.PersistedScanInstance(
+    exposureWindowId = exposureWindowId,
+    minAttenuationDb = minAttenuationDb,
+    secondsSinceLastScan = secondsSinceLastScan,
+    typicalAttenuationDb = typicalAttenuationDb
+)
+
+fun List<ScanInstance>.toPersistedScanInstances(
+    exposureWindowId: Long
+) = this.map { it.toPersistedScanInstance(exposureWindowId) }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDaoWrapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDaoWrapper.kt
new file mode 100644
index 000000000..b3b8fb13d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/internal/windows/PersistedExposureWindowDaoWrapper.kt
@@ -0,0 +1,32 @@
+package de.rki.coronawarnapp.risk.storage.internal.windows
+
+import androidx.room.Embedded
+import androidx.room.Relation
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import com.google.android.gms.nearby.exposurenotification.ScanInstance
+
+/**
+ * Helper class for Room @Relation
+ */
+data class PersistedExposureWindowDaoWrapper(
+    @Embedded
+    val exposureWindowDao: PersistedExposureWindowDao,
+    @Relation(parentColumn = "id", entityColumn = "exposureWindowId")
+    val scanInstances: List<PersistedExposureWindowDao.PersistedScanInstance>
+) {
+    fun toExposureWindow(): ExposureWindow =
+        ExposureWindow.Builder().apply {
+            setDateMillisSinceEpoch(exposureWindowDao.dateMillisSinceEpoch)
+            setCalibrationConfidence(exposureWindowDao.calibrationConfidence)
+            setInfectiousness(exposureWindowDao.infectiousness)
+            setReportType(exposureWindowDao.reportType)
+            setScanInstances(scanInstances.map { it.toScanInstance() })
+        }.build()
+
+    private fun PersistedExposureWindowDao.PersistedScanInstance.toScanInstance(): ScanInstance = ScanInstance.Builder()
+        .apply {
+            setMinAttenuationDb(minAttenuationDb)
+            setSecondsSinceLastScan(secondsSinceLastScan)
+            setTypicalAttenuationDb(typicalAttenuationDb)
+        }.build()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/legacy/RiskLevelResultMigrator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/legacy/RiskLevelResultMigrator.kt
new file mode 100644
index 000000000..0e39b13cb
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/legacy/RiskLevelResultMigrator.kt
@@ -0,0 +1,75 @@
+package de.rki.coronawarnapp.risk.storage.legacy
+
+import android.content.SharedPreferences
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import dagger.Lazy
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.storage.EncryptedPreferences
+import de.rki.coronawarnapp.util.TimeStamper
+import org.joda.time.Instant
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+/**
+ * TODO Remove this in the future
+ * Once a significant portion of the user base has already been running 1.8.x,
+ * this class can be removed to reduce access to the EncryptedPreferences.
+ */
+@Singleton
+class RiskLevelResultMigrator @Inject constructor(
+    @EncryptedPreferences encryptedPreferences: Lazy<SharedPreferences>,
+    private val timeStamper: TimeStamper
+) {
+
+    private val prefs by lazy { encryptedPreferences.get() }
+
+    private fun lastTimeRiskLevelCalculation(): Instant? {
+        prefs.getLong("preference_timestamp_risk_level_calculation", -1L).also {
+            return if (it < 0) null else Instant.ofEpochMilli(it)
+        }
+    }
+
+    private fun lastCalculatedRiskLevel(): RiskLevel? {
+        val rawRiskLevel = prefs.getInt("preference_risk_level_score", -1)
+        return if (rawRiskLevel != -1) RiskLevel.forValue(rawRiskLevel) else null
+    }
+
+    private fun lastSuccessfullyCalculatedRiskLevel(): RiskLevel? {
+        val rawRiskLevel = prefs.getInt("preference_risk_level_score_successful", -1)
+        return if (rawRiskLevel != -1) RiskLevel.forValue(rawRiskLevel) else null
+    }
+
+    fun getLegacyResults(): List<RiskLevelResult> = try {
+        val legacyResults = mutableListOf<RiskLevelResult>()
+        lastCalculatedRiskLevel()?.let {
+            legacyResults.add(
+                LegacyResult(
+                    riskLevel = it,
+                    calculatedAt = lastTimeRiskLevelCalculation() ?: timeStamper.nowUTC
+                )
+            )
+        }
+
+        lastSuccessfullyCalculatedRiskLevel()?.let {
+            legacyResults.add(LegacyResult(riskLevel = it, calculatedAt = timeStamper.nowUTC))
+        }
+
+        legacyResults
+    } catch (e: Exception) {
+        Timber.e(e, "Failed to parse legacy risklevel data.")
+        emptyList()
+    }
+
+    data class LegacyResult(
+        override val riskLevel: RiskLevel,
+        override val calculatedAt: Instant
+    ) : RiskLevelResult {
+        override val aggregatedRiskResult: AggregatedRiskResult? = null
+        override val exposureWindows: List<ExposureWindow>? = null
+        override val matchedKeyCount: Int = 0
+        override val daysWithEncounters: Int = 0
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/EncryptedPreferences.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/EncryptedPreferences.kt
new file mode 100644
index 000000000..63733ba49
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/EncryptedPreferences.kt
@@ -0,0 +1,8 @@
+package de.rki.coronawarnapp.storage
+
+import javax.inject.Qualifier
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class EncryptedPreferences
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
index fd533993e..326f05790 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
@@ -4,7 +4,6 @@ import android.content.SharedPreferences
 import androidx.core.content.edit
 import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.risk.RiskLevel
 import de.rki.coronawarnapp.util.preferences.createFlowPreference
 import de.rki.coronawarnapp.util.security.SecurityHelper.globalEncryptedSharedPreferencesInstance
 import kotlinx.coroutines.flow.Flow
@@ -261,78 +260,6 @@ object LocalData {
             )
         }
 
-    /****************************************************
-     * RISK LEVEL
-     ****************************************************/
-
-    /**
-     * Gets the last calculated risk level
-     * from the EncryptedSharedPrefs
-     *
-     * @see RiskLevelRepository
-     *
-     * @return
-     */
-    fun lastCalculatedRiskLevel(): RiskLevel {
-        val rawRiskLevel = getSharedPreferenceInstance().getInt(
-            CoronaWarnApplication.getAppContext()
-                .getString(R.string.preference_risk_level_score),
-            RiskLevel.UNDETERMINED.raw
-        )
-        return RiskLevel.forValue(rawRiskLevel)
-    }
-
-    /**
-     * Sets the last calculated risk level
-     * from the EncryptedSharedPrefs
-     *
-     * @see RiskLevelRepository
-     *
-     * @param rawRiskLevel
-     */
-    fun lastCalculatedRiskLevel(rawRiskLevel: Int) =
-        getSharedPreferenceInstance().edit(true) {
-            putInt(
-                CoronaWarnApplication.getAppContext()
-                    .getString(R.string.preference_risk_level_score),
-                rawRiskLevel
-            )
-        }
-
-    /**
-     * Gets the last successfully calculated risk level
-     * from the EncryptedSharedPrefs
-     *
-     * @see RiskLevelRepository
-     *
-     * @return
-     */
-    fun lastSuccessfullyCalculatedRiskLevel(): RiskLevel {
-        val rawRiskLevel = getSharedPreferenceInstance().getInt(
-            CoronaWarnApplication.getAppContext()
-                .getString(R.string.preference_risk_level_score_successful),
-            RiskLevel.UNDETERMINED.raw
-        )
-        return RiskLevel.forValue(rawRiskLevel)
-    }
-
-    /**
-     * Sets the last calculated risk level
-     * from the EncryptedSharedPrefs
-     *
-     * @see RiskLevelRepository
-     *
-     * @param rawRiskLevel
-     */
-    fun lastSuccessfullyCalculatedRiskLevel(rawRiskLevel: Int) =
-        getSharedPreferenceInstance().edit(true) {
-            putInt(
-                CoronaWarnApplication.getAppContext()
-                    .getString(R.string.preference_risk_level_score_successful),
-                rawRiskLevel
-            )
-        }
-
     /**
      * Gets the boolean if the user has seen the explanation dialog for the
      * risk level tracing days
@@ -401,37 +328,6 @@ object LocalData {
     fun lastTimeDiagnosisKeysFromServerFetch(value: Date?) =
         lastTimeDiagnosisKeysFetchedFlowPref.update { value?.time ?: 0L }
 
-    /**
-     * Gets the last time of successful risk level calculation as long
-     * from the EncryptedSharedPrefs
-     *
-     * @return Long
-     */
-    fun lastTimeRiskLevelCalculation(): Long? {
-        val time = getSharedPreferenceInstance().getLong(
-            CoronaWarnApplication.getAppContext()
-                .getString(R.string.preference_timestamp_risk_level_calculation),
-            0L
-        )
-        return Date(time).time
-    }
-
-    /**
-     * Sets the last time of successful risk level calculation as long
-     * from the EncryptedSharedPrefs
-     *
-     * @param value timestamp as Long
-     */
-    fun lastTimeRiskLevelCalculation(value: Long?) {
-        getSharedPreferenceInstance().edit(true) {
-            putLong(
-                CoronaWarnApplication.getAppContext()
-                    .getString(R.string.preference_timestamp_risk_level_calculation),
-                value ?: 0L
-            )
-        }
-    }
-
     /****************************************************
      * SETTINGS DATA
      ****************************************************/
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/RiskLevelRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/RiskLevelRepository.kt
deleted file mode 100644
index 62db1f8e7..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/RiskLevelRepository.kt
+++ /dev/null
@@ -1,106 +0,0 @@
-package de.rki.coronawarnapp.storage
-
-import de.rki.coronawarnapp.risk.RiskLevel
-import de.rki.coronawarnapp.risk.RiskLevelConstants
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-
-object RiskLevelRepository {
-
-    private val internalRisklevelScore = MutableStateFlow(getLastSuccessfullyCalculatedScore().raw)
-    val riskLevelScore: Flow<Int> = internalRisklevelScore
-
-    private val internalRiskLevelScoreLastSuccessfulCalculated =
-        MutableStateFlow(LocalData.lastSuccessfullyCalculatedRiskLevel().raw)
-    val riskLevelScoreLastSuccessfulCalculated: Flow<Int> =
-        internalRiskLevelScoreLastSuccessfulCalculated
-
-    /**
-     * Set the new calculated [RiskLevel]
-     * Calculation happens in the [de.rki.coronawarnapp.transaction.RiskLevelTransaction]
-     *
-     * @see de.rki.coronawarnapp.transaction.RiskLevelTransaction
-     * @see de.rki.coronawarnapp.risk.RiskLevels
-     *
-     * @param riskLevel
-     */
-    fun setRiskLevelScore(riskLevel: RiskLevel) {
-        val rawRiskLevel = riskLevel.raw
-        internalRisklevelScore.value = rawRiskLevel
-
-        setLastCalculatedScore(rawRiskLevel)
-        setLastSuccessfullyCalculatedScore(riskLevel)
-    }
-
-    /**
-     * Resets the data in the [RiskLevelRepository]
-     *
-     * @see de.rki.coronawarnapp.util.DataReset
-     *
-     */
-    fun reset() {
-        internalRisklevelScore.value = RiskLevelConstants.UNKNOWN_RISK_INITIAL
-    }
-
-    /**
-     * Set the current risk level from the last calculated risk level.
-     * This is necessary if the app has no connectivity and the risk level transaction
-     * fails.
-     *
-     * @see de.rki.coronawarnapp.transaction.RiskLevelTransaction
-     *
-     */
-    fun setLastCalculatedRiskLevelAsCurrent() {
-        var lastRiskLevelScore = getLastCalculatedScore()
-        if (lastRiskLevelScore == RiskLevel.UNDETERMINED) {
-            lastRiskLevelScore = RiskLevel.UNKNOWN_RISK_INITIAL
-        }
-        internalRisklevelScore.value = lastRiskLevelScore.raw
-    }
-
-    /**
-     * Get the last calculated RiskLevel
-     *
-     * @return
-     */
-    fun getLastCalculatedScore(): RiskLevel = LocalData.lastCalculatedRiskLevel()
-
-    /**
-     * Set the last calculated RiskLevel
-     *
-     * @param rawRiskLevel
-     */
-    private fun setLastCalculatedScore(rawRiskLevel: Int) =
-        LocalData.lastCalculatedRiskLevel(rawRiskLevel)
-
-    /**
-     * Get the last successfully calculated [RiskLevel]
-     *
-     * @see RiskLevel
-     *
-     * @return
-     */
-    fun getLastSuccessfullyCalculatedScore(): RiskLevel =
-        LocalData.lastSuccessfullyCalculatedRiskLevel()
-
-    /**
-     * Refreshes repository variable with local data
-     *
-     */
-    fun refreshLastSuccessfullyCalculatedScore() {
-        internalRiskLevelScoreLastSuccessfulCalculated.value =
-            getLastSuccessfullyCalculatedScore().raw
-    }
-
-    /**
-     * Set the last successfully calculated [RiskLevel]
-     *
-     * @param riskLevel
-     */
-    private fun setLastSuccessfullyCalculatedScore(riskLevel: RiskLevel) {
-        if (!RiskLevel.UNSUCCESSFUL_RISK_LEVELS.contains(riskLevel)) {
-            LocalData.lastSuccessfullyCalculatedRiskLevel(riskLevel.raw)
-            internalRiskLevelScoreLastSuccessfulCalculated.value = riskLevel.raw
-        }
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
index 75be4b8cf..34c4f5fc3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
@@ -172,10 +172,6 @@ class TracingRepository @Inject constructor(
         }
     }
 
-    fun refreshLastSuccessfullyCalculatedScore() {
-        RiskLevelRepository.refreshLastSuccessfullyCalculatedScore()
-    }
-
     companion object {
         private val TAG: String? = TracingRepository::class.simpleName
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
index 99d389d7d..60fe10547 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
@@ -106,7 +106,6 @@ class HomeFragmentViewModel @AssistedInject constructor(
         tracingRepository.refreshRiskLevel()
         tracingRepository.refreshActiveTracingDaysInRetentionPeriod()
         TimerHelper.checkManualKeyRetrievalTimer()
-        tracingRepository.refreshLastSuccessfullyCalculatedScore()
     }
 
     fun tracingExplanationWasShown() {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt
index ceb286194..69e50181f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt
@@ -10,18 +10,21 @@ import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.tracing.TracingProgress
 import de.rki.coronawarnapp.ui.tracing.common.BaseTracingState
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDate
+import org.joda.time.Instant
+import org.joda.time.format.DateTimeFormat
 import java.util.Date
 
 data class TracingCardState(
     override val tracingStatus: GeneralTracingStatus.Status,
     override val riskLevelScore: Int,
     override val tracingProgress: TracingProgress,
-    override val lastRiskLevelScoreCalculated: Int,
-    override val matchedKeyCount: Int,
-    override val daysSinceLastExposure: Int,
-    override val activeTracingDaysInRetentionPeriod: Long,
-    override val lastTimeDiagnosisKeysFetched: Date?,
-    override val isBackgroundJobEnabled: Boolean,
+    val lastRiskLevelScoreCalculated: Int,
+    val daysWithEncounters: Int,
+    val lastEncounterAt: Instant?,
+    val activeTracingDaysInRetentionPeriod: Long,
+    val lastTimeDiagnosisKeysFetched: Date?,
+    val isBackgroundJobEnabled: Boolean,
     override val isManualKeyRetrievalEnabled: Boolean,
     override val manualKeyRetrievalTime: Long,
     override val showDetails: Boolean = false
@@ -92,27 +95,26 @@ data class TracingCardState(
      */
     fun getRiskContactBody(c: Context): String {
         val resources = c.resources
-        val contacts = matchedKeyCount
         return when (riskLevelScore) {
             RiskLevelConstants.INCREASED_RISK -> {
-                if (matchedKeyCount == 0) {
-                    c.getString(R.string.risk_card_body_contact)
+                if (daysWithEncounters == 0) {
+                    c.getString(R.string.risk_card_high_risk_no_encounters_body)
                 } else {
                     resources.getQuantityString(
-                        R.plurals.risk_card_body_contact_value_high_risk,
-                        contacts,
-                        contacts
+                        R.plurals.risk_card_high_risk_encounter_days_body,
+                        daysWithEncounters,
+                        daysWithEncounters
                     )
                 }
             }
             RiskLevelConstants.LOW_LEVEL_RISK -> {
-                if (matchedKeyCount == 0) {
-                    c.getString(R.string.risk_card_body_contact)
+                if (daysWithEncounters == 0) {
+                    c.getString(R.string.risk_card_low_risk_no_encounters_body)
                 } else {
                     resources.getQuantityString(
-                        R.plurals.risk_card_body_contact_value,
-                        contacts,
-                        contacts
+                        R.plurals.risk_card_low_risk_encounter_days_body,
+                        daysWithEncounters,
+                        daysWithEncounters
                     )
                 }
             }
@@ -136,18 +138,11 @@ data class TracingCardState(
      * only in the special case of increased risk as a positive contact is a
      * prerequisite for increased risk
      */
-    fun getRiskContactLast(c: Context): String {
-        val resources = c.resources
-        val days = daysSinceLastExposure
-        return if (riskLevelScore == RiskLevelConstants.INCREASED_RISK) {
-            resources.getQuantityString(
-                R.plurals.risk_card_increased_risk_body_contact_last,
-                days,
-                days
-            )
-        } else {
-            ""
-        }
+    fun getRiskContactLast(c: Context): String = if (riskLevelScore == RiskLevelConstants.INCREASED_RISK) {
+        val formattedDate = lastEncounterAt?.toLocalDate()?.toString(DateTimeFormat.mediumDate())
+        c.getString(R.string.risk_card_high_risk_most_recent_body, formattedDate)
+    } else {
+        ""
     }
 
     /**
@@ -211,7 +206,7 @@ data class TracingCardState(
                 c.getString(R.string.risk_card_body_not_yet_fetched)
             }
         }
-            return when (riskLevelScore) {
+        return when (riskLevelScore) {
             RiskLevelConstants.LOW_LEVEL_RISK,
             RiskLevelConstants.INCREASED_RISK -> {
                 if (lastTimeDiagnosisKeysFetched != null) {
@@ -302,14 +297,14 @@ data class TracingCardState(
 
     fun getRiskInfoContainerBackgroundTint(c: Context): ColorStateList {
         return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) {
-        when (riskLevelScore) {
-            RiskLevelConstants.INCREASED_RISK -> R.color.card_increased
-            RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.color.card_outdated
-            RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.color.card_no_calculation
-            RiskLevelConstants.LOW_LEVEL_RISK -> R.color.card_low
-            else -> R.color.card_unknown
-        }.let { c.getColorStateList(it) }
-    } else {
+            when (riskLevelScore) {
+                RiskLevelConstants.INCREASED_RISK -> R.color.card_increased
+                RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.color.card_outdated
+                RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.color.card_no_calculation
+                RiskLevelConstants.LOW_LEVEL_RISK -> R.color.card_low
+                else -> R.color.card_unknown
+            }.let { c.getColorStateList(it) }
+        } else {
             return c.getColorStateList(R.color.card_no_calculation)
         }
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt
index 80db0f28a..93c77e228 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt
@@ -1,11 +1,11 @@
 package de.rki.coronawarnapp.ui.tracing.card
 
 import dagger.Reusable
-import de.rki.coronawarnapp.risk.ExposureResultStore
-import de.rki.coronawarnapp.storage.RiskLevelRepository
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.storage.SettingsRepository
 import de.rki.coronawarnapp.storage.TracingRepository
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
+import de.rki.coronawarnapp.ui.tracing.common.tryLatestResultsWithDefaults
 import de.rki.coronawarnapp.util.BackgroundModeStatus
 import de.rki.coronawarnapp.util.flow.combine
 import kotlinx.coroutines.flow.Flow
@@ -21,28 +21,18 @@ class TracingCardStateProvider @Inject constructor(
     backgroundModeStatus: BackgroundModeStatus,
     settingsRepository: SettingsRepository,
     tracingRepository: TracingRepository,
-    exposureResultStore: ExposureResultStore
+    riskLevelStorage: RiskLevelStorage
 ) {
 
-    // TODO Refactor these singletons away
     val state: Flow<TracingCardState> = combine(
         tracingStatus.generalStatus.onEach {
             Timber.v("tracingStatus: $it")
         },
-        RiskLevelRepository.riskLevelScore.onEach {
-            Timber.v("riskLevelScore: $it")
-        },
-        RiskLevelRepository.riskLevelScoreLastSuccessfulCalculated.onEach {
-            Timber.v("riskLevelScoreLastSuccessfulCalculated: $it")
-        },
         tracingRepository.tracingProgress.onEach {
             Timber.v("tracingProgress: $it")
         },
-        exposureResultStore.matchedKeyCount.onEach {
-            Timber.v("matchedKeyCount: $it")
-        },
-        exposureResultStore.daysSinceLastExposure.onEach {
-            Timber.v("daysSinceLastExposure: $it")
+        riskLevelStorage.riskLevelResults.onEach {
+            Timber.v("riskLevelResults: $it")
         },
         tracingRepository.activeTracingDaysInRetentionPeriod.onEach {
             Timber.v("activeTracingDaysInRetentionPeriod: $it")
@@ -60,25 +50,24 @@ class TracingCardStateProvider @Inject constructor(
             Timber.v("manualKeyRetrievalTimeFlow: $it")
         }
     ) { status,
-        riskLevelScore,
-        riskLevelScoreLastSuccessfulCalculated,
         tracingProgress,
-        matchedKeyCount,
-        daysSinceLastExposure,
+        riskLevelResults,
         activeTracingDaysInRetentionPeriod,
         lastTimeDiagnosisKeysFetched,
         isBackgroundJobEnabled,
         isManualKeyRetrievalEnabled,
         manualKeyRetrievalTime ->
 
+        val (latestCalc, latestSuccessfulCalc) = riskLevelResults.tryLatestResultsWithDefaults()
+
         TracingCardState(
             tracingStatus = status,
-            riskLevelScore = riskLevelScore,
+            riskLevelScore = latestCalc.riskLevel.raw,
             tracingProgress = tracingProgress,
-            lastRiskLevelScoreCalculated = riskLevelScoreLastSuccessfulCalculated,
+            lastRiskLevelScoreCalculated = latestSuccessfulCalc.riskLevel.raw,
             lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched,
-            matchedKeyCount = matchedKeyCount,
-            daysSinceLastExposure = daysSinceLastExposure,
+            daysWithEncounters = latestCalc.daysWithEncounters,
+            lastEncounterAt = latestCalc.lastRiskEncounterAt,
             activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod,
             isBackgroundJobEnabled = isBackgroundJobEnabled,
             isManualKeyRetrievalEnabled = isManualKeyRetrievalEnabled,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt
index a132f0cd0..f562b2a25 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt
@@ -6,18 +6,11 @@ import de.rki.coronawarnapp.risk.RiskLevelConstants
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.tracing.TracingProgress
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.millisecondsToHMS
-import java.util.Date
 
 abstract class BaseTracingState {
     abstract val tracingStatus: GeneralTracingStatus.Status
     abstract val riskLevelScore: Int
     abstract val tracingProgress: TracingProgress
-    abstract val lastRiskLevelScoreCalculated: Int
-    abstract val matchedKeyCount: Int
-    abstract val daysSinceLastExposure: Int
-    abstract val activeTracingDaysInRetentionPeriod: Long
-    abstract val lastTimeDiagnosisKeysFetched: Date?
-    abstract val isBackgroundJobEnabled: Boolean
     abstract val showDetails: Boolean // Only true for riskdetailsfragment
     abstract val isManualKeyRetrievalEnabled: Boolean
     abstract val manualKeyRetrievalTime: Long
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/RiskLevelExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/RiskLevelExtensions.kt
new file mode 100644
index 000000000..d190ff869
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/RiskLevelExtensions.kt
@@ -0,0 +1,43 @@
+package de.rki.coronawarnapp.ui.tracing.common
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import org.joda.time.Instant
+
+fun List<RiskLevelResult>.tryLatestResultsWithDefaults(): DisplayableRiskResults {
+    val latestCalculation = this.maxByOrNull { it.calculatedAt }
+        ?: InitialLowLevelRiskLevelResult
+
+    val lastSuccessfullyCalculated = this.filter { it.wasSuccessfullyCalculated }
+        .maxByOrNull { it.calculatedAt } ?: UndeterminedRiskLevelResult
+
+    return DisplayableRiskResults(
+        lastCalculated = latestCalculation,
+        lastSuccessfullyCalculated = lastSuccessfullyCalculated
+    )
+}
+
+data class DisplayableRiskResults(
+    val lastCalculated: RiskLevelResult,
+    val lastSuccessfullyCalculated: RiskLevelResult
+)
+
+private object InitialLowLevelRiskLevelResult : RiskLevelResult {
+    override val riskLevel: RiskLevel = RiskLevel.LOW_LEVEL_RISK
+    override val calculatedAt: Instant = Instant.now()
+    override val aggregatedRiskResult: AggregatedRiskResult? = null
+    override val exposureWindows: List<ExposureWindow>? = null
+    override val matchedKeyCount: Int = 0
+    override val daysWithEncounters: Int = 0
+}
+
+private object UndeterminedRiskLevelResult : RiskLevelResult {
+    override val riskLevel: RiskLevel = RiskLevel.UNDETERMINED
+    override val calculatedAt: Instant = Instant.EPOCH
+    override val aggregatedRiskResult: AggregatedRiskResult? = null
+    override val exposureWindows: List<ExposureWindow>? = null
+    override val matchedKeyCount: Int = 0
+    override val daysWithEncounters: Int = 0
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt
index 7db5aa19c..c16773357 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt
@@ -6,22 +6,19 @@ import de.rki.coronawarnapp.risk.RiskLevelConstants
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.tracing.TracingProgress
 import de.rki.coronawarnapp.ui.tracing.common.BaseTracingState
-import java.util.Date
 
 data class TracingDetailsState(
     override val tracingStatus: GeneralTracingStatus.Status,
     override val riskLevelScore: Int,
     override val tracingProgress: TracingProgress,
-    override val lastRiskLevelScoreCalculated: Int,
-    override val matchedKeyCount: Int,
-    override val daysSinceLastExposure: Int,
-    override val activeTracingDaysInRetentionPeriod: Long,
-    override val lastTimeDiagnosisKeysFetched: Date?,
-    override val isBackgroundJobEnabled: Boolean,
+    val matchedKeyCount: Int,
+    val activeTracingDaysInRetentionPeriod: Long,
+    val isBackgroundJobEnabled: Boolean,
     override val isManualKeyRetrievalEnabled: Boolean,
     override val manualKeyRetrievalTime: Long,
     val isInformationBodyNoticeVisible: Boolean,
-    val isAdditionalInformationVisible: Boolean
+    val isAdditionalInformationVisible: Boolean,
+    val daysSinceLastExposure: Int
 ) : BaseTracingState() {
 
     override val showDetails: Boolean = true
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt
index 388bca23b..c24de3352 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt
@@ -1,11 +1,11 @@
 package de.rki.coronawarnapp.ui.tracing.details
 
 import dagger.Reusable
-import de.rki.coronawarnapp.risk.ExposureResultStore
-import de.rki.coronawarnapp.storage.RiskLevelRepository
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.storage.SettingsRepository
 import de.rki.coronawarnapp.storage.TracingRepository
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
+import de.rki.coronawarnapp.ui.tracing.common.tryLatestResultsWithDefaults
 import de.rki.coronawarnapp.util.BackgroundModeStatus
 import de.rki.coronawarnapp.util.flow.combine
 import kotlinx.coroutines.flow.Flow
@@ -22,50 +22,41 @@ class TracingDetailsStateProvider @Inject constructor(
     backgroundModeStatus: BackgroundModeStatus,
     settingsRepository: SettingsRepository,
     tracingRepository: TracingRepository,
-    exposureResultStore: ExposureResultStore
+    riskLevelStorage: RiskLevelStorage
 ) {
 
-    // TODO Refactore these singletons away
     val state: Flow<TracingDetailsState> = combine(
         tracingStatus.generalStatus,
-        RiskLevelRepository.riskLevelScore,
-        RiskLevelRepository.riskLevelScoreLastSuccessfulCalculated,
         tracingRepository.tracingProgress,
-        exposureResultStore.matchedKeyCount,
-        exposureResultStore.daysSinceLastExposure,
+        riskLevelStorage.riskLevelResults,
         tracingRepository.activeTracingDaysInRetentionPeriod,
-        tracingRepository.lastTimeDiagnosisKeysFetched,
         backgroundModeStatus.isAutoModeEnabled,
         settingsRepository.isManualKeyRetrievalEnabledFlow,
         settingsRepository.manualKeyRetrievalTimeFlow
     ) { status,
-        riskLevelScore,
-        riskLevelScoreLastSuccessfulCalculated,
         tracingProgress,
-        matchedKeyCount,
-        daysSinceLastExposure, activeTracingDaysInRetentionPeriod,
-        lastTimeDiagnosisKeysFetched,
+        riskLevelResults,
+        activeTracingDaysInRetentionPeriod,
         isBackgroundJobEnabled,
         isManualKeyRetrievalEnabled,
         manualKeyRetrievalTime ->
 
+        val (latestCalc, latestSuccessfulCalc) = riskLevelResults.tryLatestResultsWithDefaults()
+
         val isAdditionalInformationVisible = riskDetailPresenter.isAdditionalInfoVisible(
-            riskLevelScore, matchedKeyCount
+            latestCalc.riskLevel.raw, latestCalc.matchedKeyCount
+        )
+        val isInformationBodyNoticeVisible = riskDetailPresenter.isInformationBodyNoticeVisible(
+            latestCalc.riskLevel.raw
         )
-        val isInformationBodyNoticeVisible =
-            riskDetailPresenter.isInformationBodyNoticeVisible(
-                riskLevelScore
-            )
 
         TracingDetailsState(
             tracingStatus = status,
-            riskLevelScore = riskLevelScore,
+            riskLevelScore = latestCalc.riskLevel.raw,
             tracingProgress = tracingProgress,
-            lastRiskLevelScoreCalculated = riskLevelScoreLastSuccessfulCalculated,
-            matchedKeyCount = matchedKeyCount,
-            daysSinceLastExposure = daysSinceLastExposure,
+            matchedKeyCount = latestCalc.matchedKeyCount,
+            daysSinceLastExposure = latestCalc.daysWithEncounters,
             activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod,
-            lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched,
             isBackgroundJobEnabled = isBackgroundJobEnabled,
             isManualKeyRetrievalEnabled = isManualKeyRetrievalEnabled,
             manualKeyRetrievalTime = manualKeyRetrievalTime,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt
index fd1064bbf..cfd87b557 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt
@@ -27,7 +27,6 @@ import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
 import de.rki.coronawarnapp.storage.AppDatabase
 import de.rki.coronawarnapp.storage.LocalData
-import de.rki.coronawarnapp.storage.RiskLevelRepository
 import de.rki.coronawarnapp.storage.SubmissionRepository
 import de.rki.coronawarnapp.storage.interoperability.InteroperabilityRepository
 import de.rki.coronawarnapp.util.di.AppContext
@@ -65,8 +64,7 @@ class DataReset @Inject constructor(
         LocalData.clear()
         // Shared Preferences Reset
         SecurityHelper.resetSharedPrefs()
-        // Reset the current risk level stored in LiveData
-        RiskLevelRepository.reset()
+
         // Reset the current states stored in LiveData
         SubmissionRepository.reset()
         keyCacheRepository.clear()
@@ -74,6 +72,7 @@ class DataReset @Inject constructor(
         interoperabilityRepository.clear()
         exposureDetectionTracker.clear()
         keyPackageSyncSettings.clear()
+
         Timber.w("CWA LOCAL DATA DELETION COMPLETED.")
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt
index 6fdc8c4a0..c8a9be0e7 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt
@@ -3,11 +3,14 @@ package de.rki.coronawarnapp.util.di
 import android.app.Application
 import android.bluetooth.BluetoothAdapter
 import android.content.Context
+import android.content.SharedPreferences
 import androidx.core.app.NotificationManagerCompat
 import androidx.work.WorkManager
 import dagger.Module
 import dagger.Provides
 import de.rki.coronawarnapp.CoronaWarnApplication
+import de.rki.coronawarnapp.storage.EncryptedPreferences
+import de.rki.coronawarnapp.util.security.SecurityHelper
 import de.rki.coronawarnapp.util.worker.WorkManagerProvider
 import javax.inject.Singleton
 
@@ -38,4 +41,9 @@ class AndroidModule {
     fun workManager(
         workManagerProvider: WorkManagerProvider
     ): WorkManager = workManagerProvider.workManager
+
+    @EncryptedPreferences
+    @Provides
+    @Singleton
+    fun encryptedPreferences(): SharedPreferences = SecurityHelper.globalEncryptedSharedPreferencesInstance
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt
index 947b6d55c..d5dcfbc2a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt
@@ -42,6 +42,86 @@ fun <T : Any> Flow<T>.shareLatest(
     )
     .filterNotNull()
 
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun <T1, T2, T3, T4, T5, T6, T7, R> combine(
+    flow: Flow<T1>,
+    flow2: Flow<T2>,
+    flow3: Flow<T3>,
+    flow4: Flow<T4>,
+    flow5: Flow<T5>,
+    flow6: Flow<T6>,
+    flow7: Flow<T7>,
+    crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7) -> R
+): Flow<R> = combine(
+    flow, flow2, flow3, flow4, flow5, flow6, flow7
+) { args: Array<*> ->
+    transform(
+        args[0] as T1,
+        args[1] as T2,
+        args[2] as T3,
+        args[3] as T4,
+        args[4] as T5,
+        args[5] as T6,
+        args[6] as T7
+    )
+}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun <T1, T2, T3, T4, T5, T6, T7, T8, R> combine(
+    flow: Flow<T1>,
+    flow2: Flow<T2>,
+    flow3: Flow<T3>,
+    flow4: Flow<T4>,
+    flow5: Flow<T5>,
+    flow6: Flow<T6>,
+    flow7: Flow<T7>,
+    flow8: Flow<T8>,
+    crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8) -> R
+): Flow<R> = combine(
+    flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8
+) { args: Array<*> ->
+    transform(
+        args[0] as T1,
+        args[1] as T2,
+        args[2] as T3,
+        args[3] as T4,
+        args[4] as T5,
+        args[5] as T6,
+        args[6] as T7,
+        args[7] as T8
+    )
+}
+
+@Suppress("UNCHECKED_CAST", "LongParameterList")
+inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, R> combine(
+    flow: Flow<T1>,
+    flow2: Flow<T2>,
+    flow3: Flow<T3>,
+    flow4: Flow<T4>,
+    flow5: Flow<T5>,
+    flow6: Flow<T6>,
+    flow7: Flow<T7>,
+    flow8: Flow<T8>,
+    flow9: Flow<T9>,
+    flow10: Flow<T10>,
+    crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> R
+): Flow<R> = combine(
+    flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10
+) { args: Array<*> ->
+    transform(
+        args[0] as T1,
+        args[1] as T2,
+        args[2] as T3,
+        args[3] as T4,
+        args[4] as T5,
+        args[5] as T6,
+        args[6] as T7,
+        args[7] as T8,
+        args[8] as T9,
+        args[9] as T10
+    )
+}
+
 @Suppress("UNCHECKED_CAST", "LongParameterList")
 inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, R> combine(
     flow: Flow<T1>,
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 469ac6b4b..5d8c2cdd3 100644
--- a/Corona-Warn-App/src/main/res/values-bg/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-bg/strings.xml
@@ -34,12 +34,6 @@
     <!-- NOTR -->
     <string name="preference_initial_result_received_time"><xliff:g id="preference">"preference_initial_result_received_time"</xliff:g></string>
     <!-- NOTR -->
-    <string name="preference_risk_level_score"><xliff:g id="preference">"preference_risk_level_score"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_risk_level_score_successful"><xliff:g id="preference">"preference_risk_level_score_successful"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_timestamp_risk_level_calculation"><xliff:g id="preference">"preference_timestamp_risk_level_calculation"</xliff:g></string>
-    <!-- NOTR -->
     <string name="preference_test_guid"><xliff:g id="preference">"preference_test_guid"</xliff:g></string>
     <!-- NOTR -->
     <string name="preference_is_allowed_to_submit_diagnosis_keys"><xliff:g id="preference">"preference_is_allowed_to_submit_diagnosis_keys"</xliff:g></string>
@@ -125,26 +119,6 @@
                   Risk Card
     ###################################### -->
 
-    <!-- XTXT: risk card - no contact yet -->
-    <string name="risk_card_body_contact">"До момента няма излагане на риск"</string>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value">
-        <item quantity="one">"%1$s излагане на нисък риск"</item>
-        <item quantity="other">"%1$s излагания на нисък риск"</item>
-        <item quantity="zero">"До момента няма излагане на нисък риск"</item>
-        <item quantity="two">"%1$s излагания на нисък риск"</item>
-        <item quantity="few">"%1$s излагания на нисък риск"</item>
-        <item quantity="many">"%1$s излагания на нисък риск"</item>
-    </plurals>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value_high_risk">
-        <item quantity="one">"%1$s излагане на риск"</item>
-        <item quantity="other">"%1$s излагания на риск"</item>
-        <item quantity="zero">"До момента няма излагане на риск"</item>
-        <item quantity="two">"%1$s излагания на риск"</item>
-        <item quantity="few">"%1$s излагания на риск"</item>
-        <item quantity="many">"%1$s излагания на риск"</item>
-    </plurals>
     <!-- XTXT: risk card - tracing active for x out of 14 days -->
     <string name="risk_card_body_saved_days">"Регистрирането на излагания на риск беше активно през %1$s от изминалите 14 дни."</string>
     <!-- XTXT: risk card- tracing active for 14 out of 14 days -->
@@ -167,15 +141,6 @@
     <string name="risk_card_low_risk_headline">"Нисък риск"</string>
     <!-- XHED: risk card - increased risk headline -->
     <string name="risk_card_increased_risk_headline">"Повишен риск"</string>
-    <!-- XTXT: risk card - increased risk days since last contact -->
-    <plurals name="risk_card_increased_risk_body_contact_last">
-        <item quantity="one">"%1$s ден от последния контакт"</item>
-        <item quantity="other">"%1$s дни от последния контакт"</item>
-        <item quantity="zero">"%1$s дни от последния контакт"</item>
-        <item quantity="two">"%1$s дни от последния контакт"</item>
-        <item quantity="few">"%1$s дни от последния контакт"</item>
-        <item quantity="many">"%1$s дни от последния контакт"</item>
-    </plurals>
     <!-- XHED: risk card - unknown risk headline -->
     <string name="risk_card_unknown_risk_headline">"Неизвестен риск"</string>
     <!-- XTXT: risk card - tracing isn't active long enough, so a new risk level can't be calculated -->
diff --git a/Corona-Warn-App/src/main/res/values-de/strings.xml b/Corona-Warn-App/src/main/res/values-de/strings.xml
index d024cca93..266c4eea2 100644
--- a/Corona-Warn-App/src/main/res/values-de/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-de/strings.xml
@@ -35,12 +35,6 @@
     <!-- NOTR -->
     <string name="preference_initial_result_received_time"><xliff:g id="preference">"preference_initial_result_received_time"</xliff:g></string>
     <!-- NOTR -->
-    <string name="preference_risk_level_score"><xliff:g id="preference">"preference_risk_level_score"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_risk_level_score_successful"><xliff:g id="preference">"preference_risk_level_score_successful"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_timestamp_risk_level_calculation"><xliff:g id="preference">"preference_timestamp_risk_level_calculation"</xliff:g></string>
-    <!-- NOTR -->
     <string name="preference_test_guid"><xliff:g id="preference">"preference_test_guid"</xliff:g></string>
     <!-- NOTR -->
     <string name="preference_is_allowed_to_submit_diagnosis_keys"><xliff:g id="preference">"preference_is_allowed_to_submit_diagnosis_keys"</xliff:g></string>
@@ -126,26 +120,6 @@
                   Risk Card
     ###################################### -->
 
-    <!-- XTXT: risk card - no contact yet -->
-    <string name="risk_card_body_contact">"Bisher keine Risiko-Begegnungen"</string>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value">
-        <item quantity="one">"%1$s Begegnung mit niedrigem Risiko"</item>
-        <item quantity="other">"%1$s Begegnungen mit niedrigem Risiko"</item>
-        <item quantity="zero">"Bisher keine Begegnungen mit niedrigem Risiko"</item>
-        <item quantity="two">"%1$s Begegnungen mit niedrigem Risiko"</item>
-        <item quantity="few">"%1$s Begegnungen mit niedrigem Risiko"</item>
-        <item quantity="many">"%1$s Begegnungen mit niedrigem Risiko"</item>
-    </plurals>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value_high_risk">
-        <item quantity="one">"%1$s Risiko-Begegnung"</item>
-        <item quantity="other">"%1$s Risiko-Begegnungen"</item>
-        <item quantity="zero">"Bisher keine Risiko-Begegnungen"</item>
-        <item quantity="two">"%1$s Risiko-Begegnungen"</item>
-        <item quantity="few">"%1$s Risiko-Begegnungen"</item>
-        <item quantity="many">"%1$s Risiko-Begegnungen"</item>
-    </plurals>
     <!-- XTXT: risk card - tracing active for x out of 14 days -->
     <string name="risk_card_body_saved_days">"Risiko-Ermittlung war für %1$s der letzten 14 Tage aktiv"</string>
     <!-- XTXT: risk card- tracing active for 14 out of 14 days -->
@@ -168,15 +142,6 @@
     <string name="risk_card_low_risk_headline">"Niedriges Risiko"</string>
     <!-- XHED: risk card - increased risk headline -->
     <string name="risk_card_increased_risk_headline">"Erhöhtes Risiko"</string>
-    <!-- XTXT: risk card - increased risk days since last contact -->
-    <plurals name="risk_card_increased_risk_body_contact_last">
-        <item quantity="one">"%1$s Tag seit der letzten Begegnung"</item>
-        <item quantity="other">"%1$s Tage seit der letzten Begegnung"</item>
-        <item quantity="zero">"%1$s Tage seit der letzten Begegnung"</item>
-        <item quantity="two">"%1$s Tage seit der letzten Begegnung"</item>
-        <item quantity="few">"%1$s Tage seit der letzten Begegnung"</item>
-        <item quantity="many">"%1$s Tage seit der letzten Begegnung"</item>
-    </plurals>
     <!-- XHED: risk card - unknown risk headline -->
     <string name="risk_card_unknown_risk_headline">"Unbekanntes Risiko"</string>
     <!-- XTXT: risk card - tracing isn't active long enough, so a new risk level can't be calculated -->
diff --git a/Corona-Warn-App/src/main/res/values-en/strings.xml b/Corona-Warn-App/src/main/res/values-en/strings.xml
index a5e01cdef..334936e23 100644
--- a/Corona-Warn-App/src/main/res/values-en/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-en/strings.xml
@@ -34,12 +34,6 @@
     <!-- NOTR -->
     <string name="preference_initial_result_received_time"><xliff:g id="preference">"preference_initial_result_received_time"</xliff:g></string>
     <!-- NOTR -->
-    <string name="preference_risk_level_score"><xliff:g id="preference">"preference_risk_level_score"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_risk_level_score_successful"><xliff:g id="preference">"preference_risk_level_score_successful"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_timestamp_risk_level_calculation"><xliff:g id="preference">"preference_timestamp_risk_level_calculation"</xliff:g></string>
-    <!-- NOTR -->
     <string name="preference_test_guid"><xliff:g id="preference">"preference_test_guid"</xliff:g></string>
     <!-- NOTR -->
     <string name="preference_is_allowed_to_submit_diagnosis_keys"><xliff:g id="preference">"preference_is_allowed_to_submit_diagnosis_keys"</xliff:g></string>
@@ -125,26 +119,6 @@
                   Risk Card
     ###################################### -->
 
-    <!-- XTXT: risk card - no contact yet -->
-    <string name="risk_card_body_contact">"No exposure up to now"</string>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value">
-        <item quantity="one">"%1$s exposure with low risk"</item>
-        <item quantity="other">"%1$s exposures with low risk"</item>
-        <item quantity="zero">"No exposure with low risk so far"</item>
-        <item quantity="two">"%1$s exposures with low risk"</item>
-        <item quantity="few">"%1$s exposures with low risk"</item>
-        <item quantity="many">"%1$s exposures with low risk"</item>
-    </plurals>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value_high_risk">
-        <item quantity="one">"%1$s exposure"</item>
-        <item quantity="other">"%1$s exposures"</item>
-        <item quantity="zero">"No exposure up to now"</item>
-        <item quantity="two">"%1$s exposures"</item>
-        <item quantity="few">"%1$s exposures"</item>
-        <item quantity="many">"%1$s exposures"</item>
-    </plurals>
     <!-- XTXT: risk card - tracing active for x out of 14 days -->
     <string name="risk_card_body_saved_days">"Exposure logging was active for %1$s of the past 14 days."</string>
     <!-- XTXT: risk card- tracing active for 14 out of 14 days -->
@@ -167,15 +141,6 @@
     <string name="risk_card_low_risk_headline">"Low Risk"</string>
     <!-- XHED: risk card - increased risk headline -->
     <string name="risk_card_increased_risk_headline">"Increased Risk"</string>
-    <!-- XTXT: risk card - increased risk days since last contact -->
-    <plurals name="risk_card_increased_risk_body_contact_last">
-        <item quantity="one">"%1$s day since the last encounter"</item>
-        <item quantity="other">"%1$s days since the last encounter"</item>
-        <item quantity="zero">"%1$s days since the last encounter"</item>
-        <item quantity="two">"%1$s days since the last encounter"</item>
-        <item quantity="few">"%1$s days since the last encounter"</item>
-        <item quantity="many">"%1$s days since the last encounter"</item>
-    </plurals>
     <!-- XHED: risk card - unknown risk headline -->
     <string name="risk_card_unknown_risk_headline">"Unknown Risk"</string>
     <!-- XTXT: risk card - tracing isn't active long enough, so a new risk level can't be calculated -->
diff --git a/Corona-Warn-App/src/main/res/values-pl/strings.xml b/Corona-Warn-App/src/main/res/values-pl/strings.xml
index eded35c4b..045ea94fb 100644
--- a/Corona-Warn-App/src/main/res/values-pl/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-pl/strings.xml
@@ -34,12 +34,6 @@
     <!-- NOTR -->
     <string name="preference_initial_result_received_time"><xliff:g id="preference">"preference_initial_result_received_time"</xliff:g></string>
     <!-- NOTR -->
-    <string name="preference_risk_level_score"><xliff:g id="preference">"preference_risk_level_score"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_risk_level_score_successful"><xliff:g id="preference">"preference_risk_level_score_successful"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_timestamp_risk_level_calculation"><xliff:g id="preference">"preference_timestamp_risk_level_calculation"</xliff:g></string>
-    <!-- NOTR -->
     <string name="preference_test_guid"><xliff:g id="preference">"preference_test_guid"</xliff:g></string>
     <!-- NOTR -->
     <string name="preference_is_allowed_to_submit_diagnosis_keys"><xliff:g id="preference">"preference_is_allowed_to_submit_diagnosis_keys"</xliff:g></string>
@@ -125,26 +119,6 @@
                   Risk Card
     ###################################### -->
 
-    <!-- XTXT: risk card - no contact yet -->
-    <string name="risk_card_body_contact">"Brak narażenia do tej pory"</string>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value">
-        <item quantity="one">"%1$s narażenie z niskim ryzykiem"</item>
-        <item quantity="other">"%1$s narażenia z niskim ryzykiem"</item>
-        <item quantity="zero">"Brak narażenia z niskim ryzykiem do tej pory"</item>
-        <item quantity="two">"%1$s narażeń z niskim ryzykiem"</item>
-        <item quantity="few">"%1$s narażenia z niskim ryzykiem"</item>
-        <item quantity="many">"%1$s narażeń z niskim ryzykiem"</item>
-    </plurals>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value_high_risk">
-        <item quantity="one">"%1$s narażenie"</item>
-        <item quantity="other">"%1$s narażenia"</item>
-        <item quantity="zero">"Brak narażenia do tej pory"</item>
-        <item quantity="two">"%1$s narażenia"</item>
-        <item quantity="few">"%1$s narażenia"</item>
-        <item quantity="many">"%1$s narażeń"</item>
-    </plurals>
     <!-- XTXT: risk card - tracing active for x out of 14 days -->
     <string name="risk_card_body_saved_days">"Rejestrowanie narażenia było aktywne przez %1$s z ostatnich 14 dni."</string>
     <!-- XTXT: risk card- tracing active for 14 out of 14 days -->
@@ -167,15 +141,6 @@
     <string name="risk_card_low_risk_headline">"Niskie ryzyko"</string>
     <!-- XHED: risk card - increased risk headline -->
     <string name="risk_card_increased_risk_headline">"Podwyższone ryzyko"</string>
-    <!-- XTXT: risk card - increased risk days since last contact -->
-    <plurals name="risk_card_increased_risk_body_contact_last">
-        <item quantity="one">"%1$s dzień od ostatniego kontaktu"</item>
-        <item quantity="other">"%1$s dnia od ostatniego kontaktu"</item>
-        <item quantity="zero">"%1$s dni od ostatniego kontaktu"</item>
-        <item quantity="two">"%1$s dni od ostatniego kontaktu"</item>
-        <item quantity="few">"%1$s dni od ostatniego kontaktu"</item>
-        <item quantity="many">"%1$s dni od ostatniego kontaktu"</item>
-    </plurals>
     <!-- XHED: risk card - unknown risk headline -->
     <string name="risk_card_unknown_risk_headline">"Ryzyko nieznane"</string>
     <!-- XTXT: risk card - tracing isn't active long enough, so a new risk level can't be calculated -->
diff --git a/Corona-Warn-App/src/main/res/values-ro/strings.xml b/Corona-Warn-App/src/main/res/values-ro/strings.xml
index 0c5a9e4f4..c9195cae2 100644
--- a/Corona-Warn-App/src/main/res/values-ro/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-ro/strings.xml
@@ -34,12 +34,6 @@
     <!-- NOTR -->
     <string name="preference_initial_result_received_time"><xliff:g id="preference">"preference_initial_result_received_time"</xliff:g></string>
     <!-- NOTR -->
-    <string name="preference_risk_level_score"><xliff:g id="preference">"preference_risk_level_score"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_risk_level_score_successful"><xliff:g id="preference">"preference_risk_level_score_successful"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_timestamp_risk_level_calculation"><xliff:g id="preference">"preference_timestamp_risk_level_calculation"</xliff:g></string>
-    <!-- NOTR -->
     <string name="preference_test_guid"><xliff:g id="preference">"preference_test_guid"</xliff:g></string>
     <!-- NOTR -->
     <string name="preference_is_allowed_to_submit_diagnosis_keys"><xliff:g id="preference">"preference_is_allowed_to_submit_diagnosis_keys"</xliff:g></string>
@@ -125,26 +119,6 @@
                   Risk Card
     ###################################### -->
 
-    <!-- XTXT: risk card - no contact yet -->
-    <string name="risk_card_body_contact">"Nicio expunere până acum"</string>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value">
-        <item quantity="one">"%1$s expunere cu risc redus"</item>
-        <item quantity="other">"%1$s de expuneri cu risc redus"</item>
-        <item quantity="zero">"Nicio expunere cu risc redus până acum"</item>
-        <item quantity="two">"%1$s expuneri cu risc redus"</item>
-        <item quantity="few">"%1$s expuneri cu risc redus"</item>
-        <item quantity="many">"%1$s expuneri cu risc redus"</item>
-    </plurals>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value_high_risk">
-        <item quantity="one">"%1$s expunere"</item>
-        <item quantity="other">"%1$s de expuneri"</item>
-        <item quantity="zero">"Nicio expunere până acum"</item>
-        <item quantity="two">"%1$s expuneri"</item>
-        <item quantity="few">"%1$s expuneri"</item>
-        <item quantity="many">"%1$s expuneri"</item>
-    </plurals>
     <!-- XTXT: risk card - tracing active for x out of 14 days -->
     <string name="risk_card_body_saved_days">"În ultimele 14 zile, înregistrarea în jurnal a expunerilor a fost activă timp de %1$s zile."</string>
     <!-- XTXT: risk card- tracing active for 14 out of 14 days -->
@@ -167,15 +141,6 @@
     <string name="risk_card_low_risk_headline">"Risc redus"</string>
     <!-- XHED: risk card - increased risk headline -->
     <string name="risk_card_increased_risk_headline">"Risc crescut"</string>
-    <!-- XTXT: risk card - increased risk days since last contact -->
-    <plurals name="risk_card_increased_risk_body_contact_last">
-        <item quantity="one">"%1$s zi de la ultima întâlnire"</item>
-        <item quantity="other">"%1$s de zile de la ultima întâlnire"</item>
-        <item quantity="zero">"%1$s zile de la ultima întâlnire"</item>
-        <item quantity="two">"%1$s zile de la ultima întâlnire"</item>
-        <item quantity="few">"%1$s zile de la ultima întâlnire"</item>
-        <item quantity="many">"%1$s zile de la ultima întâlnire"</item>
-    </plurals>
     <!-- XHED: risk card - unknown risk headline -->
     <string name="risk_card_unknown_risk_headline">"Risc necunoscut"</string>
     <!-- XTXT: risk card - tracing isn't active long enough, so a new risk level can't be calculated -->
diff --git a/Corona-Warn-App/src/main/res/values-tr/strings.xml b/Corona-Warn-App/src/main/res/values-tr/strings.xml
index 78dc5a455..99da3bf37 100644
--- a/Corona-Warn-App/src/main/res/values-tr/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-tr/strings.xml
@@ -34,12 +34,6 @@
     <!-- NOTR -->
     <string name="preference_initial_result_received_time"><xliff:g id="preference">"preference_initial_result_received_time"</xliff:g></string>
     <!-- NOTR -->
-    <string name="preference_risk_level_score"><xliff:g id="preference">"preference_risk_level_score"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_risk_level_score_successful"><xliff:g id="preference">"preference_risk_level_score_successful"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_timestamp_risk_level_calculation"><xliff:g id="preference">"preference_timestamp_risk_level_calculation"</xliff:g></string>
-    <!-- NOTR -->
     <string name="preference_test_guid"><xliff:g id="preference">"preference_test_guid"</xliff:g></string>
     <!-- NOTR -->
     <string name="preference_is_allowed_to_submit_diagnosis_keys"><xliff:g id="preference">"preference_is_allowed_to_submit_diagnosis_keys"</xliff:g></string>
@@ -125,26 +119,6 @@
                   Risk Card
     ###################################### -->
 
-    <!-- XTXT: risk card - no contact yet -->
-    <string name="risk_card_body_contact">"Şu ana dek hiçbir maruz kalma yok"</string>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value">
-        <item quantity="one">"%1$s kez düşük riskli maruz kalma"</item>
-        <item quantity="other">"%1$s kez düşük riskli maruz kalma"</item>
-        <item quantity="zero">"Şu ana dek hiçbir düşük riskli maruz kalma yok"</item>
-        <item quantity="two">"%1$s kez düşük riskli maruz kalma"</item>
-        <item quantity="few">"%1$s kez düşük riskli maruz kalma"</item>
-        <item quantity="many">"%1$s kez düşük riskli maruz kalma"</item>
-    </plurals>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value_high_risk">
-        <item quantity="one">"%1$s maruz kalma"</item>
-        <item quantity="other">"%1$s maruz kalma"</item>
-        <item quantity="zero">"Şu ana dek hiçbir maruz kalma yok"</item>
-        <item quantity="two">"%1$s maruz kalma"</item>
-        <item quantity="few">"%1$s maruz kalma"</item>
-        <item quantity="many">"%1$s maruz kalma"</item>
-    </plurals>
     <!-- XTXT: risk card - tracing active for x out of 14 days -->
     <string name="risk_card_body_saved_days">"Maruz kalma günlüğü son 14 günde %1$s gün etkindi."</string>
     <!-- XTXT: risk card- tracing active for 14 out of 14 days -->
@@ -167,15 +141,6 @@
     <string name="risk_card_low_risk_headline">"Düşük Risk"</string>
     <!-- XHED: risk card - increased risk headline -->
     <string name="risk_card_increased_risk_headline">"Daha Yüksek Risk"</string>
-    <!-- XTXT: risk card - increased risk days since last contact -->
-    <plurals name="risk_card_increased_risk_body_contact_last">
-        <item quantity="one">"son karşılaşmanın üzerinden %1$s gün geçti"</item>
-        <item quantity="other">"son karşılaşmanın üzerinden %1$s gün geçti"</item>
-        <item quantity="zero">"son karşılaşmanın üzerinden %1$s gün geçti"</item>
-        <item quantity="two">"son karşılaşmanın üzerinden %1$s gün geçti"</item>
-        <item quantity="few">"son karşılaşmanın üzerinden %1$s gün geçti"</item>
-        <item quantity="many">"son karşılaşmanın üzerinden %1$s gün geçti"</item>
-    </plurals>
     <!-- XHED: risk card - unknown risk headline -->
     <string name="risk_card_unknown_risk_headline">"Bilinmeyen Risk"</string>
     <!-- XTXT: risk card - tracing isn't active long enough, so a new risk level can't be calculated -->
diff --git a/Corona-Warn-App/src/main/res/values/strings.xml b/Corona-Warn-App/src/main/res/values/strings.xml
index f40fe15af..24720e308 100644
--- a/Corona-Warn-App/src/main/res/values/strings.xml
+++ b/Corona-Warn-App/src/main/res/values/strings.xml
@@ -35,12 +35,6 @@
     <!-- NOTR -->
     <string name="preference_initial_result_received_time"><xliff:g id="preference">"preference_initial_result_received_time"</xliff:g></string>
     <!-- NOTR -->
-    <string name="preference_risk_level_score"><xliff:g id="preference">"preference_risk_level_score"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_risk_level_score_successful"><xliff:g id="preference">"preference_risk_level_score_successful"</xliff:g></string>
-    <!-- NOTR -->
-    <string name="preference_timestamp_risk_level_calculation"><xliff:g id="preference">"preference_timestamp_risk_level_calculation"</xliff:g></string>
-    <!-- NOTR -->
     <string name="preference_test_guid"><xliff:g id="preference">"preference_test_guid"</xliff:g></string>
     <!-- NOTR -->
     <string name="preference_is_allowed_to_submit_diagnosis_keys"><xliff:g id="preference">"preference_is_allowed_to_submit_diagnosis_keys"</xliff:g></string>
@@ -130,26 +124,6 @@
                   Risk Card
     ###################################### -->
 
-    <!-- XTXT: risk card - no contact yet -->
-    <string name="risk_card_body_contact">"No exposure up to now"</string>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value">
-        <item quantity="one">"%1$s exposure with low risk"</item>
-        <item quantity="other">"%1$s exposures with low risk"</item>
-        <item quantity="zero">"No exposure with low risk so far"</item>
-        <item quantity="two">"%1$s exposures with low risk"</item>
-        <item quantity="few">"%1$s exposures with low risk"</item>
-        <item quantity="many">"%1$s exposures with low risk"</item>
-    </plurals>
-    <!-- XTXT: risk card - number of contacts for one or more -->
-    <plurals name="risk_card_body_contact_value_high_risk">
-        <item quantity="one">"%1$s exposure"</item>
-        <item quantity="other">"%1$s exposures"</item>
-        <item quantity="zero">"No exposure up to now"</item>
-        <item quantity="two">"%1$s exposures"</item>
-        <item quantity="few">"%1$s exposures"</item>
-        <item quantity="many">"%1$s exposures"</item>
-    </plurals>
     <!-- XTXT: risk card - tracing active for x out of 14 days -->
     <string name="risk_card_body_saved_days">"Exposure logging was active for %1$s of the past 14 days."</string>
     <!-- XTXT: risk card- tracing active for 14 out of 14 days -->
@@ -172,15 +146,6 @@
     <string name="risk_card_low_risk_headline">"Low Risk"</string>
     <!-- XHED: risk card - increased risk headline -->
     <string name="risk_card_increased_risk_headline">"Increased Risk"</string>
-    <!-- XTXT: risk card - increased risk days since last contact -->
-    <plurals name="risk_card_increased_risk_body_contact_last">
-        <item quantity="one">"%1$s day since the last encounter"</item>
-        <item quantity="other">"%1$s days since the last encounter"</item>
-        <item quantity="zero">"%1$s days since the last encounter"</item>
-        <item quantity="two">"%1$s days since the last encounter"</item>
-        <item quantity="few">"%1$s days since the last encounter"</item>
-        <item quantity="many">"%1$s days since the last encounter"</item>
-    </plurals>
     <!-- XHED: risk card - unknown risk headline -->
     <string name="risk_card_unknown_risk_headline">"Unknown Risk"</string>
     <!-- XTXT: risk card - tracing isn't active long enough, so a new risk level can't be calculated -->
@@ -226,7 +191,7 @@
         <item quantity="many" />
     </plurals>
     <!-- XTXT: risk card - High risk state - Most recent date with high risk -->
-    <string name="risk_card_high_risk_most_recent_body" />
+    <string name="risk_card_high_risk_most_recent_body">"Last contact at %1$s"</string>
 
     <!-- ####################################
               Risk Card - Progress
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ConfigChangeDetectorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ConfigChangeDetectorTest.kt
index e06b40b3e..d8199e642 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ConfigChangeDetectorTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ConfigChangeDetectorTest.kt
@@ -1,16 +1,17 @@
 package de.rki.coronawarnapp.appconfig
 
-import de.rki.coronawarnapp.risk.RiskLevelData
+import de.rki.coronawarnapp.risk.RiskLevelSettings
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.task.TaskController
 import io.mockk.MockKAnnotations
 import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifySequence
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
 import io.mockk.just
 import io.mockk.mockk
-import io.mockk.mockkObject
-import io.mockk.verify
-import io.mockk.verifySequence
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.test.TestCoroutineScope
 import org.junit.jupiter.api.BeforeEach
@@ -21,7 +22,8 @@ class ConfigChangeDetectorTest : BaseTest() {
 
     @MockK lateinit var appConfigProvider: AppConfigProvider
     @MockK lateinit var taskController: TaskController
-    @MockK lateinit var riskLevelData: RiskLevelData
+    @MockK lateinit var riskLevelSettings: RiskLevelSettings
+    @MockK lateinit var riskLevelStorage: RiskLevelStorage
 
     private val currentConfigFake = MutableStateFlow(mockConfigId("initial"))
 
@@ -29,11 +31,9 @@ class ConfigChangeDetectorTest : BaseTest() {
     fun setup() {
         MockKAnnotations.init(this)
 
-        mockkObject(ConfigChangeDetector.RiskLevelRepositoryDeferrer)
-        every { ConfigChangeDetector.RiskLevelRepositoryDeferrer.resetRiskLevel() } just Runs
-
         every { taskController.submit(any()) } just Runs
         every { appConfigProvider.currentConfig } returns currentConfigFake
+        coEvery { riskLevelStorage.clear() } just Runs
     }
 
     private fun mockConfigId(id: String): ConfigData {
@@ -46,58 +46,59 @@ class ConfigChangeDetectorTest : BaseTest() {
         appConfigProvider = appConfigProvider,
         taskController = taskController,
         appScope = TestCoroutineScope(),
-        riskLevelData = riskLevelData
+        riskLevelSettings = riskLevelSettings,
+        riskLevelStorage = riskLevelStorage
     )
 
     @Test
     fun `new identifier without previous one is ignored`() {
 
-        every { riskLevelData.lastUsedConfigIdentifier } returns null
+        every { riskLevelSettings.lastUsedConfigIdentifier } returns null
 
         createInstance().launch()
 
-        verify(exactly = 0) {
+        coVerify(exactly = 0) {
             taskController.submit(any())
-            ConfigChangeDetector.RiskLevelRepositoryDeferrer.resetRiskLevel()
+            riskLevelStorage.clear()
         }
     }
 
     @Test
     fun `new identifier results in new risk level calculation`() {
-        every { riskLevelData.lastUsedConfigIdentifier } returns "I'm a new identifier"
+        every { riskLevelSettings.lastUsedConfigIdentifier } returns "I'm a new identifier"
 
         createInstance().launch()
 
-        verifySequence {
-            ConfigChangeDetector.RiskLevelRepositoryDeferrer.resetRiskLevel()
+        coVerifySequence {
+            riskLevelStorage.clear()
             taskController.submit(any())
         }
     }
 
     @Test
     fun `same idetifier results in no op`() {
-        every { riskLevelData.lastUsedConfigIdentifier } returns "initial"
+        every { riskLevelSettings.lastUsedConfigIdentifier } returns "initial"
 
         createInstance().launch()
 
-        verify(exactly = 0) {
+        coVerify(exactly = 0) {
             taskController.submit(any())
-            ConfigChangeDetector.RiskLevelRepositoryDeferrer.resetRiskLevel()
+            riskLevelStorage.clear()
         }
     }
 
     @Test
     fun `new emissions keep triggering the check`() {
-        every { riskLevelData.lastUsedConfigIdentifier } returns "initial"
+        every { riskLevelSettings.lastUsedConfigIdentifier } returns "initial"
 
         createInstance().launch()
         currentConfigFake.value = mockConfigId("Straw")
         currentConfigFake.value = mockConfigId("berry")
 
-        verifySequence {
-            ConfigChangeDetector.RiskLevelRepositoryDeferrer.resetRiskLevel()
+        coVerifySequence {
+            riskLevelStorage.clear()
             taskController.submit(any())
-            ConfigChangeDetector.RiskLevelRepositoryDeferrer.resetRiskLevel()
+            riskLevelStorage.clear()
             taskController.submit(any())
         }
     }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProviderTest.kt
index d484333fe..3092fb5d3 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProviderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProviderTest.kt
@@ -100,7 +100,6 @@ class DefaultDiagnosisKeyProviderTest : BaseTest() {
         }
     }
 
-
     @Test
     fun `quota is just monitored`() {
         coEvery { submissionQuota.consumeQuota(any()) } returns false
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetectorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetectorTest.kt
new file mode 100644
index 000000000..2f8b7d350
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetectorTest.kt
@@ -0,0 +1,174 @@
+package de.rki.coronawarnapp.risk
+
+import android.content.Context
+import androidx.core.app.NotificationManagerCompat
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.RiskLevel.INCREASED_RISK
+import de.rki.coronawarnapp.risk.RiskLevel.LOW_LEVEL_RISK
+import de.rki.coronawarnapp.risk.RiskLevel.UNDETERMINED
+import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_INITIAL
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.util.ForegroundState
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.matchers.shouldBe
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.coVerifySequence
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockkObject
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class RiskLevelChangeDetectorTest : BaseTest() {
+
+    @MockK lateinit var context: Context
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var riskLevelStorage: RiskLevelStorage
+    @MockK lateinit var notificationManagerCompat: NotificationManagerCompat
+    @MockK lateinit var foregroundState: ForegroundState
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        mockkObject(LocalData)
+
+        every { LocalData.isUserToBeNotifiedOfLoweredRiskLevel = any() } just Runs
+        every { LocalData.submissionWasSuccessful() } returns false
+        every { foregroundState.isInForeground } returns flowOf(true)
+        every { notificationManagerCompat.areNotificationsEnabled() } returns true
+    }
+
+    @AfterEach
+    fun tearDown() {
+        clearAllMocks()
+    }
+
+    private fun createRiskLevel(
+        riskLevel: RiskLevel,
+        calculatedAt: Instant = Instant.EPOCH
+    ): RiskLevelResult = object : RiskLevelResult {
+        override val riskLevel: RiskLevel = riskLevel
+        override val calculatedAt: Instant = calculatedAt
+        override val aggregatedRiskResult: AggregatedRiskResult? = null
+        override val exposureWindows: List<ExposureWindow>? = null
+        override val matchedKeyCount: Int = 0
+        override val daysWithEncounters: Int = 0
+    }
+
+    private fun createInstance(scope: CoroutineScope) = RiskLevelChangeDetector(
+        context = context,
+        appScope = scope,
+        riskLevelStorage = riskLevelStorage,
+        notificationManagerCompat = notificationManagerCompat,
+        foregroundState = foregroundState
+    )
+
+    @Test
+    fun `nothing happens if there is only one result yet`() {
+        every { riskLevelStorage.riskLevelResults } returns flowOf(listOf(createRiskLevel(LOW_LEVEL_RISK)))
+
+        runBlockingTest {
+            val instance = createInstance(scope = this)
+            instance.launch()
+
+            advanceUntilIdle()
+
+            coVerifySequence {
+                LocalData wasNot Called
+                notificationManagerCompat wasNot Called
+            }
+        }
+    }
+
+    @Test
+    fun `no risklevel change, nothing should happen`() {
+        every { riskLevelStorage.riskLevelResults } returns flowOf(
+            listOf(
+                createRiskLevel(LOW_LEVEL_RISK),
+                createRiskLevel(LOW_LEVEL_RISK)
+            )
+        )
+
+        runBlockingTest {
+            val instance = createInstance(scope = this)
+            instance.launch()
+
+            advanceUntilIdle()
+
+            coVerifySequence {
+                LocalData wasNot Called
+                notificationManagerCompat wasNot Called
+            }
+        }
+    }
+
+    @Test
+    fun `risklevel went from HIGH to LOW`() {
+        every { riskLevelStorage.riskLevelResults } returns flowOf(
+            listOf(
+                createRiskLevel(LOW_LEVEL_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+                createRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH)
+            )
+        )
+
+        runBlockingTest {
+            val instance = createInstance(scope = this)
+            instance.launch()
+
+            advanceUntilIdle()
+
+            coVerifySequence {
+                LocalData.submissionWasSuccessful()
+                foregroundState.isInForeground
+                LocalData.isUserToBeNotifiedOfLoweredRiskLevel = any()
+            }
+        }
+    }
+
+    @Test
+    fun `risklevel went from LOW to HIGH`() {
+        every { riskLevelStorage.riskLevelResults } returns flowOf(
+            listOf(
+                createRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+                createRiskLevel(LOW_LEVEL_RISK, calculatedAt = Instant.EPOCH)
+            )
+        )
+
+        runBlockingTest {
+            val instance = createInstance(scope = this)
+            instance.launch()
+
+            advanceUntilIdle()
+
+            coVerifySequence {
+                LocalData.submissionWasSuccessful()
+                foregroundState.isInForeground
+            }
+        }
+    }
+
+    @Test
+    fun `evaluate risk level change detection function`() {
+        RiskLevelChangeDetector.hasHighLowLevelChanged(INCREASED_RISK, INCREASED_RISK) shouldBe false
+        RiskLevelChangeDetector.hasHighLowLevelChanged(UNKNOWN_RISK_INITIAL, LOW_LEVEL_RISK) shouldBe false
+        RiskLevelChangeDetector.hasHighLowLevelChanged(UNKNOWN_RISK_INITIAL, INCREASED_RISK) shouldBe true
+        RiskLevelChangeDetector.hasHighLowLevelChanged(INCREASED_RISK, UNKNOWN_RISK_INITIAL) shouldBe true
+        RiskLevelChangeDetector.hasHighLowLevelChanged(UNDETERMINED, UNKNOWN_RISK_INITIAL) shouldBe false
+        RiskLevelChangeDetector.hasHighLowLevelChanged(UNDETERMINED, INCREASED_RISK) shouldBe true
+        RiskLevelChangeDetector.hasHighLowLevelChanged(UNKNOWN_RISK_INITIAL, UNDETERMINED) shouldBe false
+        RiskLevelChangeDetector.hasHighLowLevelChanged(INCREASED_RISK, UNDETERMINED) shouldBe true
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelResultTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelResultTest.kt
new file mode 100644
index 000000000..a5871486a
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelResultTest.kt
@@ -0,0 +1,31 @@
+package de.rki.coronawarnapp.risk
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import io.kotest.matchers.shouldBe
+import org.joda.time.Instant
+import org.junit.Test
+import testhelpers.BaseTest
+
+class RiskLevelResultTest : BaseTest() {
+
+    private fun createRiskLevel(riskLevel: RiskLevel): RiskLevelResult = object : RiskLevelResult {
+        override val riskLevel: RiskLevel = riskLevel
+        override val calculatedAt: Instant = Instant.EPOCH
+        override val aggregatedRiskResult: AggregatedRiskResult? = null
+        override val exposureWindows: List<ExposureWindow>? = null
+        override val matchedKeyCount: Int = 0
+        override val daysWithEncounters: Int = 0
+    }
+
+    @Test
+    fun testUnsuccessfulRistLevels() {
+        createRiskLevel(RiskLevel.UNDETERMINED).wasSuccessfullyCalculated shouldBe false
+        createRiskLevel(RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF).wasSuccessfullyCalculated shouldBe false
+        createRiskLevel(RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS).wasSuccessfullyCalculated shouldBe false
+
+        createRiskLevel(RiskLevel.UNKNOWN_RISK_INITIAL).wasSuccessfullyCalculated shouldBe true
+        createRiskLevel(RiskLevel.LOW_LEVEL_RISK).wasSuccessfullyCalculated shouldBe true
+        createRiskLevel(RiskLevel.INCREASED_RISK).wasSuccessfullyCalculated shouldBe true
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelDataTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelSettingsTest.kt
similarity index 91%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelDataTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelSettingsTest.kt
index 41a2b3517..36b3bd07b 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelDataTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelSettingsTest.kt
@@ -10,7 +10,7 @@ import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 import testhelpers.preferences.MockSharedPreferences
 
-class RiskLevelDataTest : BaseTest() {
+class RiskLevelSettingsTest : BaseTest() {
 
     @MockK lateinit var context: Context
     lateinit var preferences: MockSharedPreferences
@@ -22,7 +22,7 @@ class RiskLevelDataTest : BaseTest() {
         every { context.getSharedPreferences("risklevel_localdata", Context.MODE_PRIVATE) } returns preferences
     }
 
-    fun createInstance() = RiskLevelData(context = context)
+    fun createInstance() = RiskLevelSettings(context = context)
 
     @Test
     fun `update last used config identifier`() {
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 f174600cf..df3dbcab8 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
@@ -7,6 +7,7 @@ import android.net.NetworkCapabilities
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.appconfig.ConfigData
 import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.task.Task
 import de.rki.coronawarnapp.util.BackgroundModeStatus
 import de.rki.coronawarnapp.util.TimeStamper
@@ -32,10 +33,10 @@ class RiskLevelTaskTest : BaseTest() {
     @MockK lateinit var enfClient: ENFClient
     @MockK lateinit var timeStamper: TimeStamper
     @MockK lateinit var backgroundModeStatus: BackgroundModeStatus
-    @MockK lateinit var riskLevelData: RiskLevelData
+    @MockK lateinit var riskLevelSettings: RiskLevelSettings
     @MockK lateinit var configData: ConfigData
     @MockK lateinit var appConfigProvider: AppConfigProvider
-    @MockK lateinit var exposureResultStore: ExposureResultStore
+    @MockK lateinit var riskLevelStorage: RiskLevelStorage
 
     private val arguments: Task.Arguments = object : Task.Arguments {}
 
@@ -45,9 +46,9 @@ class RiskLevelTaskTest : BaseTest() {
         enfClient = enfClient,
         timeStamper = timeStamper,
         backgroundModeStatus = backgroundModeStatus,
-        riskLevelData = riskLevelData,
+        riskLevelSettings = riskLevelSettings,
         appConfigProvider = appConfigProvider,
-        exposureResultStore = exposureResultStore
+        riskLevelStorage = riskLevelStorage
     )
 
     @BeforeEach
@@ -71,7 +72,7 @@ class RiskLevelTaskTest : BaseTest() {
         every { enfClient.isTracingEnabled } returns flowOf(true)
         every { timeStamper.nowUTC } returns Instant.EPOCH
 
-        every { riskLevelData.lastUsedConfigIdentifier = any() } just Runs
+        every { riskLevelSettings.lastUsedConfigIdentifier = any() } just Runs
     }
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTest.kt
index 7d4c1d7ba..74290d95b 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTest.kt
@@ -1,9 +1,7 @@
 package de.rki.coronawarnapp.risk
 
 import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
 import org.junit.Assert.assertNotEquals
-import org.junit.Assert.assertTrue
 import org.junit.Test
 
 class RiskLevelTest {
@@ -44,87 +42,4 @@ class RiskLevelTest {
         assertNotEquals(RiskLevel.forValue(RiskLevelConstants.INCREASED_RISK), RiskLevel.UNDETERMINED)
         assertNotEquals(RiskLevel.forValue(RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS), RiskLevel.UNDETERMINED)
     }
-
-    @Test
-    fun testUnsuccessfulRistLevels() {
-        assertTrue(RiskLevel.UNSUCCESSFUL_RISK_LEVELS.contains(RiskLevel.UNDETERMINED))
-        assertTrue(RiskLevel.UNSUCCESSFUL_RISK_LEVELS.contains(RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF))
-        assertTrue(RiskLevel.UNSUCCESSFUL_RISK_LEVELS.contains(RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS))
-
-        assertFalse(RiskLevel.UNSUCCESSFUL_RISK_LEVELS.contains(RiskLevel.UNKNOWN_RISK_INITIAL))
-        assertFalse(RiskLevel.UNSUCCESSFUL_RISK_LEVELS.contains(RiskLevel.LOW_LEVEL_RISK))
-        assertFalse(RiskLevel.UNSUCCESSFUL_RISK_LEVELS.contains(RiskLevel.INCREASED_RISK))
-    }
-
-    @Test
-    fun testRiskLevelChangedFromHighToHigh() {
-        val riskLevelHasChanged = RiskLevel.riskLevelChangedBetweenLowAndHigh(
-            RiskLevel.INCREASED_RISK,
-            RiskLevel.INCREASED_RISK
-        )
-        assertFalse(riskLevelHasChanged)
-    }
-
-    @Test
-    fun testRiskLevelChangedFromLowToLow() {
-        val riskLevelHasChanged = RiskLevel.riskLevelChangedBetweenLowAndHigh(
-            RiskLevel.UNKNOWN_RISK_INITIAL,
-            RiskLevel.LOW_LEVEL_RISK
-        )
-        assertFalse(riskLevelHasChanged)
-    }
-
-    @Test
-    fun testRiskLevelChangedFromLowToHigh() {
-        val riskLevelHasChanged = RiskLevel.riskLevelChangedBetweenLowAndHigh(
-            RiskLevel.UNKNOWN_RISK_INITIAL,
-            RiskLevel.INCREASED_RISK
-        )
-        assertTrue(riskLevelHasChanged)
-    }
-
-    @Test
-    fun testRiskLevelChangedFromHighToLow() {
-        val riskLevelHasChanged = RiskLevel.riskLevelChangedBetweenLowAndHigh(
-            RiskLevel.INCREASED_RISK,
-            RiskLevel.UNKNOWN_RISK_INITIAL
-        )
-        assertTrue(riskLevelHasChanged)
-    }
-
-    @Test
-    fun testRiskLevelChangedFromUndeterminedToLow() {
-        val riskLevelHasChanged = RiskLevel.riskLevelChangedBetweenLowAndHigh(
-            RiskLevel.UNDETERMINED,
-            RiskLevel.UNKNOWN_RISK_INITIAL
-        )
-        assertFalse(riskLevelHasChanged)
-    }
-
-    @Test
-    fun testRiskLevelChangedFromUndeterminedToHigh() {
-        val riskLevelHasChanged = RiskLevel.riskLevelChangedBetweenLowAndHigh(
-            RiskLevel.UNDETERMINED,
-            RiskLevel.INCREASED_RISK
-        )
-        assertTrue(riskLevelHasChanged)
-    }
-
-    @Test
-    fun testRiskLevelChangedFromLowToUndetermined() {
-        val riskLevelHasChanged = RiskLevel.riskLevelChangedBetweenLowAndHigh(
-            RiskLevel.UNKNOWN_RISK_INITIAL,
-            RiskLevel.UNDETERMINED
-        )
-        assertFalse(riskLevelHasChanged)
-    }
-
-    @Test
-    fun testRiskLevelChangedFromHighToUndetermined() {
-        val riskLevelHasChanged = RiskLevel.riskLevelChangedBetweenLowAndHigh(
-            RiskLevel.INCREASED_RISK,
-            RiskLevel.UNDETERMINED
-        )
-        assertTrue(riskLevelHasChanged)
-    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorageTest.kt
new file mode 100644
index 000000000..033cae214
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorageTest.kt
@@ -0,0 +1,229 @@
+package de.rki.coronawarnapp.risk.storage
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import com.google.android.gms.nearby.exposurenotification.ScanInstance
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.RiskLevelTaskResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase.ExposureWindowsDao
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase.Factory
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase.RiskResultsDao
+import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao
+import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao.PersistedAggregatedRiskResult
+import de.rki.coronawarnapp.risk.storage.internal.windows.PersistedExposureWindowDao
+import de.rki.coronawarnapp.risk.storage.internal.windows.PersistedExposureWindowDaoWrapper
+import de.rki.coronawarnapp.risk.storage.legacy.RiskLevelResultMigrator
+import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import io.mockk.Called
+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 io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class BaseRiskLevelStorageTest : BaseTest() {
+
+    @MockK lateinit var databaseFactory: Factory
+    @MockK lateinit var database: RiskResultDatabase
+    @MockK lateinit var riskResultTables: RiskResultsDao
+    @MockK lateinit var exposureWindowTables: ExposureWindowsDao
+    @MockK lateinit var riskLevelResultMigrator: RiskLevelResultMigrator
+
+    private val testRiskLevelResultDao = PersistedRiskLevelResultDao(
+        id = "riskresult-id",
+        riskLevel = RiskLevel.INCREASED_RISK,
+        calculatedAt = Instant.ofEpochMilli(9999L),
+        aggregatedRiskResult = PersistedAggregatedRiskResult(
+            totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+            totalMinimumDistinctEncountersWithLowRisk = 1,
+            totalMinimumDistinctEncountersWithHighRisk = 2,
+            mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+            mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+            numberOfDaysWithLowRisk = 5,
+            numberOfDaysWithHighRisk = 6
+        )
+    )
+
+    private val testRisklevelResult = RiskLevelTaskResult(
+        riskLevel = RiskLevel.INCREASED_RISK,
+        calculatedAt = Instant.ofEpochMilli(9999L),
+        aggregatedRiskResult = AggregatedRiskResult(
+            totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+            totalMinimumDistinctEncountersWithLowRisk = 1,
+            totalMinimumDistinctEncountersWithHighRisk = 2,
+            mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+            mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+            numberOfDaysWithLowRisk = 5,
+            numberOfDaysWithHighRisk = 6
+        ),
+        exposureWindows = null
+    )
+
+    private val testExposureWindowDaoWrapper = PersistedExposureWindowDaoWrapper(
+        exposureWindowDao = PersistedExposureWindowDao(
+            id = 1,
+            riskLevelResultId = "riskresult-id",
+            dateMillisSinceEpoch = 123L,
+            calibrationConfidence = 1,
+            infectiousness = 2,
+            reportType = 3
+        ),
+        scanInstances = listOf(
+            PersistedExposureWindowDao.PersistedScanInstance(
+                exposureWindowId = 1,
+                minAttenuationDb = 10,
+                secondsSinceLastScan = 20,
+                typicalAttenuationDb = 30
+            )
+        )
+    )
+    private val testExposureWindow = ExposureWindow.Builder().apply {
+        setDateMillisSinceEpoch(123L)
+        setCalibrationConfidence(1)
+        setInfectiousness(2)
+        setReportType(3)
+        ScanInstance.Builder().apply {
+            setMinAttenuationDb(10)
+            setSecondsSinceLastScan(20)
+            setTypicalAttenuationDb(30)
+        }.build().let { setScanInstances(listOf(it)) }
+    }.build()
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { databaseFactory.create() } returns database
+        every { database.riskResults() } returns riskResultTables
+        every { database.exposureWindows() } returns exposureWindowTables
+        every { database.clearAllTables() } just Runs
+
+        every { riskLevelResultMigrator.getLegacyResults() } returns emptyList()
+
+        every { riskResultTables.allEntries() } returns emptyFlow()
+        coEvery { riskResultTables.insertEntry(any()) } just Runs
+        coEvery { riskResultTables.deleteOldest(any()) } returns 7
+
+        every { exposureWindowTables.allEntries() } returns emptyFlow()
+    }
+
+    @AfterEach
+    fun tearDown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance(
+        storedResultLimit: Int = 10,
+        onStoreExposureWindows: (String, RiskLevelResult) -> Unit = { id, result -> },
+        onDeletedOrphanedExposureWindows: () -> Unit = {}
+    ) = object : BaseRiskLevelStorage(
+        riskResultDatabaseFactory = databaseFactory,
+        riskLevelResultMigrator = riskLevelResultMigrator
+    ) {
+        override val storedResultLimit: Int = storedResultLimit
+
+        override suspend fun storeExposureWindows(storedResultId: String, result: RiskLevelResult) {
+            onStoreExposureWindows(storedResultId, result)
+        }
+
+        override suspend fun deletedOrphanedExposureWindows() {
+            onDeletedOrphanedExposureWindows()
+        }
+    }
+
+    @Test
+    fun `exposureWindows are returned from database and mapped`() {
+        every { exposureWindowTables.allEntries() } returns flowOf(listOf(testExposureWindowDaoWrapper))
+
+        runBlockingTest {
+            val instance = createInstance()
+            instance.exposureWindows.first() shouldBe listOf(testExposureWindow)
+        }
+    }
+
+    @Test
+    fun `riskLevelResults are returned from database and mapped`() {
+        every { riskResultTables.allEntries() } returns flowOf(listOf(testRiskLevelResultDao))
+
+        runBlockingTest {
+            val instance = createInstance()
+            instance.riskLevelResults.first() shouldBe listOf(testRisklevelResult)
+
+            verify { riskLevelResultMigrator wasNot Called }
+        }
+    }
+
+    @Test
+    fun `if no risk level results are available we try to get legacy results`() {
+        every { riskLevelResultMigrator.getLegacyResults() } returns listOf(mockk(), mockk())
+        every { riskResultTables.allEntries() } returns flowOf(emptyList())
+
+        runBlockingTest {
+            val instance = createInstance()
+            instance.riskLevelResults.first().size shouldBe 2
+
+            verify { riskLevelResultMigrator.getLegacyResults() }
+        }
+    }
+
+    @Test
+    fun `errors when storing risklevel result are rethrown`() = runBlockingTest {
+        coEvery { riskResultTables.insertEntry(any()) } throws IllegalStateException("No body expects the...")
+        val instance = createInstance()
+        shouldThrow<java.lang.IllegalStateException> {
+            instance.storeResult(testRisklevelResult)
+        }
+    }
+
+    @Test
+    fun `errors when storing exposure window results are thrown`() = runBlockingTest {
+        val instance = createInstance(onStoreExposureWindows = { _, _ -> throw IllegalStateException("Surprise!") })
+        shouldThrow<IllegalStateException> {
+            instance.storeResult(testRisklevelResult)
+        }
+    }
+
+    @Test
+    fun `storeResult works`() = runBlockingTest {
+        val mockStoreWindows: (String, RiskLevelResult) -> Unit = spyk()
+        val mockDeleteOrphanedWindows: () -> Unit = spyk()
+
+        val instance = createInstance(
+            onStoreExposureWindows = mockStoreWindows,
+            onDeletedOrphanedExposureWindows = mockDeleteOrphanedWindows
+        )
+        instance.storeResult(testRisklevelResult)
+
+        coVerify {
+            riskResultTables.insertEntry(any())
+            riskResultTables.deleteOldest(instance.storedResultLimit)
+            mockStoreWindows.invoke(any(), testRisklevelResult)
+            mockDeleteOrphanedWindows.invoke()
+        }
+    }
+
+    @Test
+    fun `clear works`() = runBlockingTest {
+        createInstance().clear()
+        verify { database.clearAllTables() }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/internal/PersistedExposureWindowDaoTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/internal/PersistedExposureWindowDaoTest.kt
new file mode 100644
index 000000000..61aee1225
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/internal/PersistedExposureWindowDaoTest.kt
@@ -0,0 +1,41 @@
+package de.rki.coronawarnapp.risk.storage.internal
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import com.google.android.gms.nearby.exposurenotification.ScanInstance
+import de.rki.coronawarnapp.risk.storage.internal.windows.toPersistedExposureWindow
+import de.rki.coronawarnapp.risk.storage.internal.windows.toPersistedScanInstance
+import io.kotest.matchers.shouldBe
+import io.mockk.every
+import io.mockk.mockk
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class PersistedExposureWindowDaoTest : BaseTest() {
+
+    @Test
+    fun `mapping is correct`() {
+        val window: ExposureWindow = mockk()
+        every { window.calibrationConfidence } returns 0
+        every { window.dateMillisSinceEpoch } returns 849628347458723L
+        every { window.infectiousness } returns 2
+        every { window.reportType } returns 2
+        window.toPersistedExposureWindow("RESULT_ID").apply {
+            riskLevelResultId shouldBe "RESULT_ID"
+            dateMillisSinceEpoch shouldBe 849628347458723L
+            calibrationConfidence shouldBe 0
+            infectiousness shouldBe 2
+            reportType shouldBe 2
+        }
+
+        val scanInstance: ScanInstance = mockk()
+        every { scanInstance.minAttenuationDb } returns 30
+        every { scanInstance.secondsSinceLastScan } returns 300
+        every { scanInstance.typicalAttenuationDb } returns 25
+        scanInstance.toPersistedScanInstance(5000L).apply {
+            exposureWindowId shouldBe 5000
+            minAttenuationDb shouldBe 30
+            typicalAttenuationDb shouldBe 25
+            secondsSinceLastScan shouldBe 300
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/internal/PersistedRiskResultDaoTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/internal/PersistedRiskResultDaoTest.kt
new file mode 100644
index 000000000..155c9e81b
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/internal/PersistedRiskResultDaoTest.kt
@@ -0,0 +1,46 @@
+package de.rki.coronawarnapp.risk.storage.internal
+
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao
+import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
+import org.joda.time.Instant
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class PersistedRiskResultDaoTest : BaseTest() {
+    @Test
+    fun `mapping is correct`() {
+        PersistedRiskLevelResultDao(
+            id = "",
+            riskLevel = RiskLevel.LOW_LEVEL_RISK,
+            calculatedAt = Instant.ofEpochMilli(931161601L),
+            aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+                totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.LOW,
+                totalMinimumDistinctEncountersWithLowRisk = 89,
+                totalMinimumDistinctEncountersWithHighRisk = 59,
+                mostRecentDateWithLowRisk = Instant.ofEpochMilli(852191241L),
+                mostRecentDateWithHighRisk = Instant.ofEpochMilli(790335113L),
+                numberOfDaysWithLowRisk = 52,
+                numberOfDaysWithHighRisk = 81
+            )
+        ).toRiskResult().apply {
+            riskLevel shouldBe RiskLevel.LOW_LEVEL_RISK
+            calculatedAt.millis shouldBe 931161601L
+            exposureWindows shouldBe null
+            aggregatedRiskResult shouldNotBe null
+            aggregatedRiskResult?.apply {
+                totalRiskLevel shouldBe RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.LOW
+                totalMinimumDistinctEncountersWithLowRisk shouldBe 89
+                totalMinimumDistinctEncountersWithHighRisk shouldBe 59
+                mostRecentDateWithLowRisk shouldNotBe null
+                mostRecentDateWithLowRisk?.millis shouldBe 852191241L
+                mostRecentDateWithHighRisk shouldNotBe null
+                mostRecentDateWithHighRisk?.millis shouldBe 790335113L
+                numberOfDaysWithLowRisk shouldBe 52
+                numberOfDaysWithHighRisk shouldBe 81
+            }
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/legacy/RiskLevelResultMigratorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/legacy/RiskLevelResultMigratorTest.kt
new file mode 100644
index 000000000..832d0e8c3
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/legacy/RiskLevelResultMigratorTest.kt
@@ -0,0 +1,124 @@
+package de.rki.coronawarnapp.risk.storage.legacy
+
+import androidx.core.content.edit
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.preferences.MockSharedPreferences
+
+class RiskLevelResultMigratorTest : BaseTest() {
+
+    @MockK lateinit var timeStamper: TimeStamper
+    private val mockPreferences = MockSharedPreferences()
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1337)
+    }
+
+    @AfterEach
+    fun tearDown() {
+        clearAllMocks()
+    }
+
+    fun createInstance() = RiskLevelResultMigrator(
+        timeStamper = timeStamper,
+        encryptedPreferences = { mockPreferences }
+    )
+
+    @Test
+    fun `normal case with full values`() {
+        mockPreferences.edit {
+            putInt("preference_risk_level_score", RiskLevel.INCREASED_RISK.raw)
+            putInt("preference_risk_level_score_successful", RiskLevel.LOW_LEVEL_RISK.raw)
+            putLong("preference_timestamp_risk_level_calculation", 1234567890L)
+        }
+        createInstance().apply {
+            val legacyResults = getLegacyResults()
+            legacyResults[0].apply {
+                riskLevel shouldBe RiskLevel.INCREASED_RISK
+                calculatedAt shouldBe Instant.ofEpochMilli(1234567890L)
+            }
+            legacyResults[1].apply {
+                riskLevel shouldBe RiskLevel.LOW_LEVEL_RISK
+                calculatedAt shouldBe Instant.EPOCH.plus(1337)
+            }
+        }
+    }
+
+    @Test
+    fun `empty list if no previous data was available`() {
+        mockPreferences.dataMapPeek.isEmpty() shouldBe true
+        createInstance().getLegacyResults() shouldBe emptyList()
+    }
+
+    @Test
+    fun `if no timestamp is available we use the current time`() {
+        mockPreferences.edit {
+            putInt("preference_risk_level_score", RiskLevel.INCREASED_RISK.raw)
+            putInt("preference_risk_level_score_successful", RiskLevel.LOW_LEVEL_RISK.raw)
+        }
+        createInstance().apply {
+            val legacyResults = getLegacyResults()
+            legacyResults[0].apply {
+                riskLevel shouldBe RiskLevel.INCREASED_RISK
+                calculatedAt shouldBe Instant.EPOCH.plus(1337)
+            }
+            legacyResults[1].apply {
+                riskLevel shouldBe RiskLevel.LOW_LEVEL_RISK
+                calculatedAt shouldBe Instant.EPOCH.plus(1337)
+            }
+        }
+    }
+
+    @Test
+    fun `last successful is null`() {
+        mockPreferences.edit {
+            putInt("preference_risk_level_score_successful", RiskLevel.INCREASED_RISK.raw)
+        }
+        createInstance().apply {
+            val legacyResults = getLegacyResults()
+            legacyResults.size shouldBe 1
+            legacyResults.first().apply {
+                riskLevel shouldBe RiskLevel.INCREASED_RISK
+                calculatedAt shouldBe Instant.EPOCH.plus(1337)
+            }
+        }
+    }
+
+    @Test
+    fun `last successfully calculated is null`() {
+        mockPreferences.edit {
+            putInt("preference_risk_level_score", RiskLevel.INCREASED_RISK.raw)
+            putLong("preference_timestamp_risk_level_calculation", 1234567890L)
+        }
+        createInstance().apply {
+            val legacyResults = getLegacyResults()
+            legacyResults.size shouldBe 1
+            legacyResults.first().apply {
+                riskLevel shouldBe RiskLevel.INCREASED_RISK
+                calculatedAt shouldBe Instant.ofEpochMilli(1234567890L)
+            }
+        }
+    }
+
+    @Test
+    fun `exceptions are handled gracefully`() {
+        mockPreferences.edit {
+            putInt("preference_risk_level_score", RiskLevel.INCREASED_RISK.raw)
+        }
+        every { timeStamper.nowUTC } throws Exception("Surprise!")
+        createInstance().getLegacyResults() shouldBe emptyList()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt
index ba00e3484..1d6bd137d 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt
@@ -17,6 +17,7 @@ import io.mockk.impl.annotations.MockK
 import io.mockk.mockk
 import io.mockk.verify
 import io.mockk.verifySequence
+import org.joda.time.Instant
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
@@ -42,8 +43,8 @@ class TracingCardStateTest : BaseTest() {
         riskLevel: Int = 0,
         tracingProgress: TracingProgress = TracingProgress.Idle,
         riskLevelLastSuccessfulCalculation: Int = 0,
-        matchedKeyCount: Int = 0,
-        daysSinceLastExposure: Int = 0,
+        daysWithEncounters: Int = 0,
+        lastEncounterAt: Instant? = null,
         activeTracingDaysInRetentionPeriod: Long = 0,
         lastTimeDiagnosisKeysFetched: Date? = mockk(),
         isBackgroundJobEnabled: Boolean = false,
@@ -54,8 +55,8 @@ class TracingCardStateTest : BaseTest() {
         riskLevelScore = riskLevel,
         tracingProgress = tracingProgress,
         lastRiskLevelScoreCalculated = riskLevelLastSuccessfulCalculation,
-        matchedKeyCount = matchedKeyCount,
-        daysSinceLastExposure = daysSinceLastExposure,
+        daysWithEncounters = daysWithEncounters,
+        lastEncounterAt = lastEncounterAt,
         activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod,
         lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched,
         isBackgroundJobEnabled = isBackgroundJobEnabled,
@@ -336,88 +337,44 @@ class TracingCardStateTest : BaseTest() {
 
     @Test
     fun `risk contact body is affected by risklevel`() {
-        createInstance(
-            riskLevel = INCREASED_RISK,
-            matchedKeyCount = 0
-        ).apply {
-            getRiskContactBody(context)
-            verify { context.getString(R.string.risk_card_body_contact) }
-        }
-
-        createInstance(
-            riskLevel = INCREASED_RISK,
-            matchedKeyCount = 2
-        ).apply {
-            getRiskContactBody(context)
-            verify {
-                context.resources.getQuantityString(
-                    R.plurals.risk_card_body_contact_value_high_risk,
-                    2,
-                    2
-                )
-            }
-        }
-
-        createInstance(
-            riskLevel = LOW_LEVEL_RISK,
-            matchedKeyCount = 0
-        ).apply {
-            getRiskContactBody(context)
-            verify { context.getString(R.string.risk_card_body_contact) }
-        }
-
-        createInstance(
-            riskLevel = LOW_LEVEL_RISK,
-            matchedKeyCount = 2
-        ).apply {
-            getRiskContactBody(context)
-            verify {
-                context.resources.getQuantityString(
-                    R.plurals.risk_card_body_contact_value,
-                    2,
-                    2
-                )
-            }
-        }
-
         createInstance(
             riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS,
-            matchedKeyCount = 0
+            daysWithEncounters = 0
         ).apply {
             getRiskContactBody(context) shouldBe ""
         }
 
         createInstance(
             riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            matchedKeyCount = 0
+            daysWithEncounters = 0
         ).apply {
             getRiskContactBody(context) shouldBe ""
         }
 
         createInstance(
             riskLevel = UNKNOWN_RISK_INITIAL,
-            matchedKeyCount = 0
+            daysWithEncounters = 0
         ).apply {
             getRiskContactBody(context) shouldBe ""
         }
 
         createInstance(
             riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS,
-            matchedKeyCount = 2
+            daysWithEncounters = 2
         ).apply {
             getRiskContactBody(context) shouldBe ""
         }
 
         createInstance(
             riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            matchedKeyCount = 2
+            daysWithEncounters = 2
         ).apply {
             getRiskContactBody(context) shouldBe ""
         }
 
         createInstance(
             riskLevel = UNKNOWN_RISK_INITIAL,
-            matchedKeyCount = 2
+            daysWithEncounters = 2
         ).apply {
             getRiskContactBody(context) shouldBe ""
         }
@@ -454,57 +411,25 @@ class TracingCardStateTest : BaseTest() {
     @Test
     fun `last risk contact text formatting`() {
         createInstance(
-            riskLevel = INCREASED_RISK,
-            daysSinceLastExposure = 2
-        ).apply {
-            getRiskContactLast(context)
-            verify {
-                context.resources.getQuantityString(
-                    R.plurals.risk_card_increased_risk_body_contact_last,
-                    2,
-                    2
-                )
-            }
-        }
-
-        createInstance(
-            riskLevel = INCREASED_RISK,
-            daysSinceLastExposure = 0
-        ).apply {
-            getRiskContactLast(context)
-            verify {
-                context.resources.getQuantityString(
-                    R.plurals.risk_card_increased_risk_body_contact_last,
-                    0,
-                    0
-                )
-            }
-        }
-
-        createInstance(
-            riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS,
-            daysSinceLastExposure = 2
+            riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS
         ).apply {
             getRiskContactLast(context) shouldBe ""
         }
 
         createInstance(
-            riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            daysSinceLastExposure = 2
+            riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF
         ).apply {
             getRiskContactLast(context) shouldBe ""
         }
 
         createInstance(
-            riskLevel = LOW_LEVEL_RISK,
-            daysSinceLastExposure = 2
+            riskLevel = LOW_LEVEL_RISK
         ).apply {
             getRiskContactLast(context) shouldBe ""
         }
 
         createInstance(
-            riskLevel = UNKNOWN_RISK_INITIAL,
-            daysSinceLastExposure = 2
+            riskLevel = UNKNOWN_RISK_INITIAL
         ).apply {
             getRiskContactLast(context) shouldBe ""
         }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt
index 75687a7d8..d5e6090b8 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt
@@ -59,12 +59,6 @@ class BaseTracingStateTest : BaseTest() {
         override val tracingStatus: GeneralTracingStatus.Status = tracingStatus
         override val riskLevelScore: Int = riskLevelScore
         override val tracingProgress: TracingProgress = tracingProgress
-        override val lastRiskLevelScoreCalculated: Int = riskLevelLastSuccessfulCalculation
-        override val matchedKeyCount: Int = matchedKeyCount
-        override val daysSinceLastExposure: Int = daysSinceLastExposure
-        override val activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod
-        override val lastTimeDiagnosisKeysFetched: Date? = lastTimeDiagnosisKeysFetched
-        override val isBackgroundJobEnabled: Boolean = isBackgroundJobEnabled
         override val showDetails: Boolean = showDetails
         override val isManualKeyRetrievalEnabled: Boolean = isManualKeyRetrievalEnabled
         override val manualKeyRetrievalTime: Long = manualKeyRetrievalTime
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/RiskLevelExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/RiskLevelExtensionsTest.kt
new file mode 100644
index 000000000..77208efdd
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/RiskLevelExtensionsTest.kt
@@ -0,0 +1,93 @@
+package de.rki.coronawarnapp.ui.tracing.common
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import io.kotest.matchers.longs.shouldBeInRange
+import io.kotest.matchers.shouldBe
+import org.joda.time.Instant
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class RiskLevelExtensionsTest : BaseTest() {
+
+    private fun createRiskLevel(
+        riskLevel: RiskLevel,
+        calculatedAt: Instant
+    ): RiskLevelResult = object : RiskLevelResult {
+        override val riskLevel: RiskLevel = riskLevel
+        override val calculatedAt: Instant = calculatedAt
+        override val aggregatedRiskResult: AggregatedRiskResult? = null
+        override val exposureWindows: List<ExposureWindow>? = null
+        override val matchedKeyCount: Int = 0
+        override val daysWithEncounters: Int = 0
+    }
+
+    @Test
+    fun `getLastestAndLastSuccessful on empty results`() {
+        val emptyResults = emptyList<RiskLevelResult>()
+
+        emptyResults.tryLatestResultsWithDefaults().apply {
+            lastCalculated.apply {
+                riskLevel shouldBe RiskLevel.LOW_LEVEL_RISK
+                val now = Instant.now().millis
+                calculatedAt.millis shouldBeInRange ((now - 60 * 1000L)..now + 60 * 1000L)
+            }
+            lastSuccessfullyCalculated.apply {
+                riskLevel shouldBe RiskLevel.UNDETERMINED
+            }
+        }
+    }
+
+    @Test
+    fun `getLastestAndLastSuccessful last calculation was successful`() {
+        val results = listOf(
+            createRiskLevel(RiskLevel.INCREASED_RISK, calculatedAt = Instant.EPOCH),
+            createRiskLevel(RiskLevel.LOW_LEVEL_RISK, calculatedAt = Instant.EPOCH.plus(1))
+        )
+
+        results.tryLatestResultsWithDefaults().apply {
+            lastCalculated.riskLevel shouldBe lastSuccessfullyCalculated.riskLevel
+            lastCalculated.calculatedAt shouldBe lastSuccessfullyCalculated.calculatedAt
+
+            lastCalculated.riskLevel shouldBe RiskLevel.LOW_LEVEL_RISK
+            lastSuccessfullyCalculated.calculatedAt shouldBe Instant.EPOCH.plus(1)
+        }
+    }
+
+    @Test
+    fun `getLastestAndLastSuccessful last calculation was not successful`() {
+        val results = listOf(
+            createRiskLevel(RiskLevel.INCREASED_RISK, calculatedAt = Instant.EPOCH),
+            createRiskLevel(RiskLevel.LOW_LEVEL_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+            createRiskLevel(RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF, calculatedAt = Instant.EPOCH.plus(2))
+        )
+
+        results.tryLatestResultsWithDefaults().apply {
+            lastCalculated.riskLevel shouldBe RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF
+            lastCalculated.calculatedAt shouldBe Instant.EPOCH.plus(2)
+
+            lastSuccessfullyCalculated.riskLevel shouldBe RiskLevel.LOW_LEVEL_RISK
+            lastSuccessfullyCalculated.calculatedAt shouldBe Instant.EPOCH.plus(1)
+        }
+    }
+
+    @Test
+    fun `getLastestAndLastSuccessful no successful calculations yet`() {
+        val results = listOf(
+            createRiskLevel(RiskLevel.UNDETERMINED, calculatedAt = Instant.EPOCH.plus(10)),
+            createRiskLevel(RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL, calculatedAt = Instant.EPOCH.plus(11)),
+            createRiskLevel(RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS, calculatedAt = Instant.EPOCH.plus(12)),
+            createRiskLevel(RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF, calculatedAt = Instant.EPOCH.plus(13))
+        )
+
+        results.tryLatestResultsWithDefaults().apply {
+            lastCalculated.riskLevel shouldBe RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF
+            lastCalculated.calculatedAt shouldBe Instant.EPOCH.plus(13)
+
+            lastSuccessfullyCalculated.riskLevel shouldBe RiskLevel.UNDETERMINED
+            lastSuccessfullyCalculated.calculatedAt shouldBe Instant.EPOCH
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
index 93154d759..46f3bf443 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
@@ -17,7 +17,6 @@ import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
-import java.util.Date
 
 class TracingDetailsStateTest : BaseTest() {
 
@@ -40,11 +39,9 @@ class TracingDetailsStateTest : BaseTest() {
         tracingStatus: GeneralTracingStatus.Status = mockk(),
         riskLevelScore: Int = 0,
         tracingProgress: TracingProgress = TracingProgress.Idle,
-        riskLevelLastSuccessfulCalculation: Int = 0,
         matchedKeyCount: Int = 3,
         daysSinceLastExposure: Int = 2,
         activeTracingDaysInRetentionPeriod: Long = 0,
-        lastTimeDiagnosisKeysFetched: Date? = mockk(),
         isBackgroundJobEnabled: Boolean = false,
         isManualKeyRetrievalEnabled: Boolean = false,
         manualKeyRetrievalTime: Long = 0L,
@@ -54,11 +51,9 @@ class TracingDetailsStateTest : BaseTest() {
         tracingStatus = tracingStatus,
         riskLevelScore = riskLevelScore,
         tracingProgress = tracingProgress,
-        lastRiskLevelScoreCalculated = riskLevelLastSuccessfulCalculation,
         matchedKeyCount = matchedKeyCount,
         daysSinceLastExposure = daysSinceLastExposure,
         activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod,
-        lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched,
         isBackgroundJobEnabled = isBackgroundJobEnabled,
         isManualKeyRetrievalEnabled = isManualKeyRetrievalEnabled,
         manualKeyRetrievalTime = manualKeyRetrievalTime,
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt
index 88976e539..385d00586 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt
@@ -8,7 +8,7 @@ import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler
 import de.rki.coronawarnapp.deadman.DeadmanNotificationSender
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.playbook.Playbook
-import de.rki.coronawarnapp.risk.ExposureResultStore
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.util.di.AssistedInjectModule
 import io.github.classgraph.ClassGraph
@@ -95,5 +95,5 @@ class MockProvider {
     fun enfClient(): ENFClient = mockk()
 
     @Provides
-    fun exposureSummaryRepository(): ExposureResultStore = mockk()
+    fun exposureSummaryRepository(): RiskLevelStorage = mockk()
 }
diff --git a/Corona-Warn-App/src/testDevice/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt b/Corona-Warn-App/src/testDevice/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
new file mode 100644
index 000000000..a739176be
--- /dev/null
+++ b/Corona-Warn-App/src/testDevice/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
@@ -0,0 +1,122 @@
+package de.rki.coronawarnapp.test.risk.storage
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelTaskResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.risk.storage.DefaultRiskLevelStorage
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
+import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao
+import de.rki.coronawarnapp.risk.storage.legacy.RiskLevelResultMigrator
+import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass
+import io.kotest.matchers.shouldBe
+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.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DefaultRiskLevelStorageTest : BaseTest() {
+
+    @MockK lateinit var databaseFactory: RiskResultDatabase.Factory
+    @MockK lateinit var database: RiskResultDatabase
+    @MockK lateinit var riskResultTables: RiskResultDatabase.RiskResultsDao
+    @MockK lateinit var exposureWindowTables: RiskResultDatabase.ExposureWindowsDao
+    @MockK lateinit var riskLevelResultMigrator: RiskLevelResultMigrator
+
+    private val testRiskLevelResultDao = PersistedRiskLevelResultDao(
+        id = "riskresult-id",
+        riskLevel = RiskLevel.INCREASED_RISK,
+        calculatedAt = Instant.ofEpochMilli(9999L),
+        aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+            totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+            totalMinimumDistinctEncountersWithLowRisk = 1,
+            totalMinimumDistinctEncountersWithHighRisk = 2,
+            mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+            mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+            numberOfDaysWithLowRisk = 5,
+            numberOfDaysWithHighRisk = 6
+        )
+    )
+    private val testRisklevelResult = RiskLevelTaskResult(
+        riskLevel = RiskLevel.INCREASED_RISK,
+        calculatedAt = Instant.ofEpochMilli(9999L),
+        aggregatedRiskResult = AggregatedRiskResult(
+            totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+            totalMinimumDistinctEncountersWithLowRisk = 1,
+            totalMinimumDistinctEncountersWithHighRisk = 2,
+            mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+            mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+            numberOfDaysWithLowRisk = 5,
+            numberOfDaysWithHighRisk = 6
+        ),
+        exposureWindows = listOf(
+            ExposureWindow.Builder().build(),
+            ExposureWindow.Builder().build()
+        )
+    )
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { databaseFactory.create() } returns database
+        every { database.riskResults() } returns riskResultTables
+        every { database.exposureWindows() } returns exposureWindowTables
+        every { database.clearAllTables() } just Runs
+
+        every { riskLevelResultMigrator.getLegacyResults() } returns emptyList()
+
+        every { riskResultTables.allEntries() } returns flowOf(listOf(testRiskLevelResultDao))
+        coEvery { riskResultTables.insertEntry(any()) } just Runs
+        coEvery { riskResultTables.deleteOldest(any()) } returns 7
+
+        every { exposureWindowTables.allEntries() } returns emptyFlow()
+        coEvery { exposureWindowTables.insertWindows(any()) } returns listOf(111L, 222L)
+        coEvery { exposureWindowTables.insertScanInstances(any()) } just Runs
+        coEvery { exposureWindowTables.deleteByRiskResultId(any()) } returns 1
+    }
+
+    @AfterEach
+    fun tearDown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance() = DefaultRiskLevelStorage(
+        riskResultDatabaseFactory = databaseFactory,
+        riskLevelResultMigrator = riskLevelResultMigrator
+    )
+
+    @Test
+    fun `stored item limit for deviceForTesters`() {
+        createInstance().storedResultLimit shouldBe 2 * 6
+    }
+
+    @Test
+    fun `we are NOT storing or cleaning up exposure windows`() = runBlockingTest {
+        val instance = createInstance()
+        instance.storeResult(testRisklevelResult)
+
+        coVerify {
+            riskResultTables.insertEntry(any())
+            riskResultTables.deleteOldest(instance.storedResultLimit)
+        }
+
+        coVerify(exactly = 0) {
+            exposureWindowTables.insertWindows(any())
+            exposureWindowTables.insertScanInstances(any())
+            exposureWindowTables.deleteByRiskResultId(listOf("riskresult-id"))
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
new file mode 100644
index 000000000..f2036adfd
--- /dev/null
+++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
@@ -0,0 +1,120 @@
+package de.rki.coronawarnapp.test.risk.storage
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelTaskResult
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.risk.storage.DefaultRiskLevelStorage
+import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
+import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao
+import de.rki.coronawarnapp.risk.storage.legacy.RiskLevelResultMigrator
+import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass
+import io.kotest.matchers.shouldBe
+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.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DefaultRiskLevelStorageTest : BaseTest() {
+
+    @MockK lateinit var databaseFactory: RiskResultDatabase.Factory
+    @MockK lateinit var database: RiskResultDatabase
+    @MockK lateinit var riskResultTables: RiskResultDatabase.RiskResultsDao
+    @MockK lateinit var exposureWindowTables: RiskResultDatabase.ExposureWindowsDao
+    @MockK lateinit var riskLevelResultMigrator: RiskLevelResultMigrator
+
+    private val testRiskLevelResultDao = PersistedRiskLevelResultDao(
+        id = "riskresult-id",
+        riskLevel = RiskLevel.INCREASED_RISK,
+        calculatedAt = Instant.ofEpochMilli(9999L),
+        aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+            totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+            totalMinimumDistinctEncountersWithLowRisk = 1,
+            totalMinimumDistinctEncountersWithHighRisk = 2,
+            mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+            mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+            numberOfDaysWithLowRisk = 5,
+            numberOfDaysWithHighRisk = 6
+        )
+    )
+    private val testRisklevelResult = RiskLevelTaskResult(
+        riskLevel = RiskLevel.INCREASED_RISK,
+        calculatedAt = Instant.ofEpochMilli(9999L),
+        aggregatedRiskResult = AggregatedRiskResult(
+            totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+            totalMinimumDistinctEncountersWithLowRisk = 1,
+            totalMinimumDistinctEncountersWithHighRisk = 2,
+            mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+            mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+            numberOfDaysWithLowRisk = 5,
+            numberOfDaysWithHighRisk = 6
+        ),
+        exposureWindows = listOf(
+            ExposureWindow.Builder().build(),
+            ExposureWindow.Builder().build()
+        )
+    )
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { databaseFactory.create() } returns database
+        every { database.riskResults() } returns riskResultTables
+        every { database.exposureWindows() } returns exposureWindowTables
+        every { database.clearAllTables() } just Runs
+
+        every { riskLevelResultMigrator.getLegacyResults() } returns emptyList()
+
+        every { riskResultTables.allEntries() } returns flowOf(listOf(testRiskLevelResultDao))
+        coEvery { riskResultTables.insertEntry(any()) } just Runs
+        coEvery { riskResultTables.deleteOldest(any()) } returns 7
+
+        every { exposureWindowTables.allEntries() } returns emptyFlow()
+        coEvery { exposureWindowTables.insertWindows(any()) } returns listOf(111L, 222L)
+        coEvery { exposureWindowTables.insertScanInstances(any()) } just Runs
+        coEvery { exposureWindowTables.deleteByRiskResultId(any()) } returns 1
+    }
+
+    @AfterEach
+    fun tearDown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance() = DefaultRiskLevelStorage(
+        riskResultDatabaseFactory = databaseFactory,
+        riskLevelResultMigrator = riskLevelResultMigrator
+    )
+
+    @Test
+    fun `stored item limit for deviceForTesters`() {
+        createInstance().storedResultLimit shouldBe 14 * 6
+    }
+
+    @Test
+    fun `we are storing and cleaning up exposure windows`() = runBlockingTest {
+        val instance = createInstance()
+        instance.storeResult(testRisklevelResult)
+
+        coVerify {
+            riskResultTables.insertEntry(any())
+            riskResultTables.deleteOldest(instance.storedResultLimit)
+
+            exposureWindowTables.insertWindows(any())
+            exposureWindowTables.insertScanInstances(any())
+            exposureWindowTables.deleteByRiskResultId(listOf("riskresult-id"))
+        }
+    }
+}
-- 
GitLab