diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle
index 7cd67f74b4201019176e6559bb946ad15e1042a4..1fb62d84fd0f4a3db61d212a57f07acca1e72665 100644
--- a/Corona-Warn-App/build.gradle
+++ b/Corona-Warn-App/build.gradle
@@ -313,7 +313,7 @@ dependencies {
     implementation 'androidx.navigation:navigation-ui-ktx:2.2.2'
     implementation 'androidx.legacy:legacy-support-v4:1.0.0'
     implementation 'androidx.preference:preference:1.1.1'
-    implementation 'androidx.work:work-runtime-ktx:2.4.0'
+    implementation 'androidx.work:work-runtime-ktx:2.5.0-beta01'
 
     implementation 'androidx.lifecycle:lifecycle-common-java8:2.2.0'
     implementation 'androidx.lifecycle:lifecycle-process:2.2.0'
@@ -344,7 +344,7 @@ dependencies {
     implementation 'com.google.zxing:core:3.3.0'
 
     //ENA
-    implementation files('libs\\play-services-nearby-exposurenotification-1.6.1-eap.aar')
+    implementation files('libs\\play-services-nearby-exposurenotification-1.7.2-eap.aar')
 
     // Testing
     testImplementation "androidx.arch.core:core-testing:2.1.0"
@@ -359,6 +359,8 @@ dependencies {
     // Testing - jUnit4
     testImplementation 'junit:junit:4.13.1'
     testImplementation "org.junit.vintage:junit-vintage-engine:5.7.0"
+    testImplementation "androidx.test:core-ktx:1.3.0"
+
 
     // Testing - jUnit5
     testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.0"
@@ -380,7 +382,7 @@ dependencies {
     androidTestImplementation 'androidx.test:rules:1.3.0'
     androidTestImplementation 'androidx.test.ext:truth:1.3.0'
     androidTestImplementation 'androidx.test.ext:junit:1.1.2'
-    androidTestImplementation 'androidx.work:work-testing:2.4.0'
+    androidTestImplementation 'androidx.work:work-testing:2.5.0-beta01'
     androidTestImplementation "io.mockk:mockk-android:1.10.2"
     debugImplementation 'androidx.fragment:fragment-testing:1.2.5'
 
diff --git a/Corona-Warn-App/config/detekt.yml b/Corona-Warn-App/config/detekt.yml
index 0d06a280c125c8b8c72ebac0e0e8d104882ef824..16605a8917887ed41ae1933e82c95a3dd0a3cb0a 100644
--- a/Corona-Warn-App/config/detekt.yml
+++ b/Corona-Warn-App/config/detekt.yml
@@ -513,7 +513,7 @@ style:
     active: true
     maxJumpCount: 1
   MagicNumber:
-    active: true
+    active: false
     excludes: [
       '**/test/**',
       '**/androidTest/**',
diff --git a/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.6.1-eap.aar b/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.6.1-eap.aar
deleted file mode 100644
index 6eddb67adf1c8419af03a6c68153ba5dbb7397b1..0000000000000000000000000000000000000000
Binary files a/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.6.1-eap.aar and /dev/null differ
diff --git a/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.7.2-eap.aar b/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.7.2-eap.aar
new file mode 100644
index 0000000000000000000000000000000000000000..a2ec17e31ceaf61366982ab414810b5c7f268ece
Binary files /dev/null and b/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.7.2-eap.aar differ
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabaseTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabaseTest.kt
index ac0ccdefe87983cc638001ef67e900c75810b44a..62230915dc95eda085f563425251a52969ffbb52 100644
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabaseTest.kt
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabaseTest.kt
@@ -22,14 +22,14 @@ class KeyCacheDatabaseTest {
     @Test
     fun crud() {
         val keyDay = CachedKeyInfo(
-            type = CachedKeyInfo.Type.COUNTRY_DAY,
+            type = CachedKeyInfo.Type.LOCATION_DAY,
             location = LocationCode("DE"),
             day = LocalDate.now(),
             hour = null,
             createdAt = Instant.now()
         )
         val keyHour = CachedKeyInfo(
-            type = CachedKeyInfo.Type.COUNTRY_HOUR,
+            type = CachedKeyInfo.Type.LOCATION_HOUR,
             location = LocationCode("DE"),
             day = LocalDate.now(),
             hour = LocalTime.now(),
@@ -41,34 +41,34 @@ class KeyCacheDatabaseTest {
             dao.insertEntry(keyDay)
             dao.insertEntry(keyHour)
             dao.getAllEntries() shouldBe listOf(keyDay, keyHour)
-            dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_DAY.typeValue) shouldBe listOf(keyDay)
-            dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR.typeValue) shouldBe listOf(keyHour)
+            dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_DAY.typeValue) shouldBe listOf(keyDay)
+            dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_HOUR.typeValue) shouldBe listOf(keyHour)
 
             dao.updateDownloadState(keyDay.toDownloadUpdate("coffee"))
-            dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_DAY.typeValue).single().apply {
+            dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_DAY.typeValue).single().apply {
                 isDownloadComplete shouldBe true
-                checksumMD5 shouldBe "coffee"
+                etag shouldBe "coffee"
             }
-            dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR.typeValue).single().apply {
+            dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_HOUR.typeValue).single().apply {
                 isDownloadComplete shouldBe false
-                checksumMD5 shouldBe null
+                etag shouldBe null
             }
 
             dao.updateDownloadState(keyHour.toDownloadUpdate("with milk"))
-            dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_DAY.typeValue).single().apply {
+            dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_DAY.typeValue).single().apply {
                 isDownloadComplete shouldBe true
-                checksumMD5 shouldBe "coffee"
+                etag shouldBe "coffee"
             }
-            dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR.typeValue).single().apply {
+            dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_HOUR.typeValue).single().apply {
                 isDownloadComplete shouldBe true
-                checksumMD5 shouldBe "with milk"
+                etag shouldBe "with milk"
             }
 
             dao.deleteEntry(keyDay)
             dao.getAllEntries() shouldBe listOf(
                 keyHour.copy(
                     isDownloadComplete = true,
-                    checksumMD5 = "with milk"
+                    etag = "with milk"
                 )
             )
 
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt
index 12c569990d6ca9a63ebccfaa7651240997557846..0c19f0fae546ff78633ec7513e583e6d657c8e2a 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt
@@ -9,7 +9,7 @@ import de.rki.coronawarnapp.bugreporting.event.DefaultBugEvent
 import de.rki.coronawarnapp.bugreporting.loghistory.RollingLogHistory
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.di.AppContext
-import de.rki.coronawarnapp.util.tryFormattedError
+import de.rki.coronawarnapp.util.tryHumanReadableError
 import javax.inject.Inject
 import javax.inject.Singleton
 
@@ -21,8 +21,9 @@ class DefaultBugProcessor @Inject constructor(
 ) : BugProcessor {
 
     override suspend fun processor(throwable: Throwable, tag: String?, info: String?): BugEvent {
+        val formattedError = throwable.tryHumanReadableError(context)
+
         val crashedAt = timeStamper.nowUTC
-        val exceptionMessage = throwable.tryFormattedError(context)
         val exceptionClass = throwable::class.java.simpleName
         val stacktrace = Log.getStackTraceString(throwable)
         val deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})"
@@ -38,7 +39,7 @@ class DefaultBugProcessor @Inject constructor(
             tag = tag,
             info = info,
             exceptionClass = exceptionClass,
-            exceptionMessage = exceptionMessage,
+            exceptionMessage = formattedError.description,
             stackTrace = stacktrace,
             deviceInfo = deviceInfo,
             appVersionName = appVersionName,
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt
deleted file mode 100644
index 1b6dfba3e38df02fb09a5a9ea586cf4d2eabf1da..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package de.rki.coronawarnapp.test.api.ui
-
-data class DebugOptionsState(
-    val areNotificationsEnabled: Boolean,
-    val isHourlyTestingMode: Boolean
-)
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 fbdd56b5352c37f695c0ffa31727a7074bc68af1..694615a9ae234ae6f626b12a7ff9f0f179402ff1 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
@@ -1,7 +1,6 @@
 package de.rki.coronawarnapp.test.api.ui
 
 import android.annotation.SuppressLint
-import android.content.Context
 import android.content.Intent
 import android.graphics.Bitmap
 import android.graphics.Color
@@ -10,8 +9,6 @@ import android.util.Base64
 import android.view.LayoutInflater
 import android.view.View
 import android.view.ViewGroup
-import android.view.inputmethod.EditorInfo
-import android.view.inputmethod.InputMethodManager
 import android.widget.ImageView
 import android.widget.Toast
 import androidx.fragment.app.Fragment
@@ -31,7 +28,6 @@ import com.google.zxing.integration.android.IntentResult
 import com.google.zxing.qrcode.QRCodeWriter
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentTestForAPIBinding
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.ExceptionCategory.INTERNAL
 import de.rki.coronawarnapp.exception.TransactionException
@@ -46,7 +42,6 @@ import de.rki.coronawarnapp.storage.AppDatabase
 import de.rki.coronawarnapp.storage.ExposureSummaryRepository
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.tracing.TracingIntervalRepository
-import de.rki.coronawarnapp.test.RiskLevelAndKeyRetrievalBenchmark
 import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
 import de.rki.coronawarnapp.util.KeyFileHelper
 import de.rki.coronawarnapp.util.di.AppInjector
@@ -66,7 +61,7 @@ import java.lang.reflect.Type
 import java.util.UUID
 import javax.inject.Inject
 
-@SuppressWarnings("TooManyFunctions", "MagicNumber", "LongMethod")
+@SuppressWarnings("TooManyFunctions", "LongMethod")
 class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
     InternalExposureNotificationPermissionHelper.Callback, AutoInject {
 
@@ -79,7 +74,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
             description = "A mix of API related test options.",
             targetId = R.id.test_for_api_fragment
         )
-        const val CONFIG_SCORE = 8
 
         fun keysToJson(keys: List<TemporaryExposureKey>): String {
             return Gson().toJson(keys).toString()
@@ -110,8 +104,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
     // Data and View binding
     private val binding: FragmentTestForAPIBinding by viewBindingLazy()
 
-    private var lastSetCountries: List<String>? = null
-
     @SuppressLint("SetTextI18n")
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
@@ -212,29 +204,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
                 }
             }
         }
-
-        // Country benchmark card
-        // Load countries from App config and update Country UI element states
-        lifecycleScope.launch {
-            lastSetCountries =
-                AppInjector.component.appConfigProvider.getAppConfig().supportedCountries
-            binding.inputCountryCodesEditText.setText(
-                lastSetCountries?.joinToString(",")
-            )
-
-            updateCountryStatusLabel()
-        }
-        binding.buttonFilterCountryCodes.setOnClickListener { filterCountryCodes() }
-        binding.buttonRetrieveDiagnosisKeysAndCalcRiskLevel.setOnClickListener {
-            startKeyRetrievalAndRiskCalcBenchmark()
-        }
-
-        binding.inputMeasureRiskKeyRepeatCount.setOnEditorActionListener { v, actionCode, event ->
-            if (actionCode == EditorInfo.IME_ACTION_DONE) {
-                startKeyRetrievalAndRiskCalcBenchmark()
-            }
-            false
-        }
     }
 
     override fun onResume() {
@@ -243,56 +212,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
         updateExposureSummaryDisplay(null)
     }
 
-    private fun startKeyRetrievalAndRiskCalcBenchmark() {
-        hideKeyboard()
-        lifecycleScope.launch {
-            val repeatCount =
-                binding.inputMeasureRiskKeyRepeatCount.text.toString().toInt()
-            context?.let {
-                RiskLevelAndKeyRetrievalBenchmark(
-                    it,
-                    lastSetCountries ?: listOf("DE")
-                ).start(repeatCount) { status ->
-                    binding.labelTestApiMeasureCalcKeyStatus.text = status
-                }
-            }
-        }
-    }
-
-    private fun filterCountryCodes() {
-        hideKeyboard()
-        // Get user input country codes
-        val rawCountryCodes = binding.inputCountryCodesEditText.text.toString()
-
-        // Country codes can be separated by space or ,
-        val countryCodes = rawCountryCodes.split(',', ' ').filter { it.isNotEmpty() }
-
-        lastSetCountries = countryCodes
-
-        // Trigger asyncFetchFiles which will use all Countries passed as parameter
-        lifecycleScope.launch {
-            val locationCodes = countryCodes.map { LocationCode(it) }
-            AppInjector.component.keyFileDownloader.asyncFetchKeyFiles(locationCodes)
-            updateCountryStatusLabel()
-        }
-    }
-
-    private fun hideKeyboard() {
-        activity?.currentFocus.let {
-            val inputManager =
-                context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
-            inputManager.hideSoftInputFromWindow(it?.windowToken, 0)
-        }
-    }
-
-    /**
-     * Updates the Label for country filter
-     */
-    private fun updateCountryStatusLabel() {
-        binding.labelCountryCodeFilterStatus.text = "Country filter applied for: \n " +
-            "${lastSetCountries?.joinToString(",")}"
-    }
-
     private val prettyKey = { key: AppleLegacyKeyExchange.Key ->
         StringBuilder()
             .append("\nKey data: ${key.keyData}")
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt
index feedc56bc7d28d2f0be692f7410a2e026c6662b7..742411f39edaa87b0a911267cb3b4d527326c194 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt
@@ -36,7 +36,7 @@ class AppConfigTestFragment : Fragment(R.layout.fragment_test_appconfig), AutoIn
                 data?.rawConfig?.toString() ?: "No config available."
             binding.lastUpdate.text = data?.updatedAt?.let { timeFormatter.print(it) } ?: "n/a"
             binding.timeOffset.text = data?.let {
-                "${it.localOffset.millis}ms (isFallbackConfig=${it.isFallback})"
+                "${it.localOffset.millis}ms (configType=${it.configType})"
             } ?: "n/a"
         }
 
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt
index 8015debc73d4333c59d03accd9e61e6e823e9005..91b1206da2ff70f7df02367cf75d340c70809c70 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt
@@ -32,10 +32,6 @@ class DebugOptionsFragment : Fragment(R.layout.fragment_test_debugoptions), Auto
         super.onViewCreated(view, savedInstanceState)
 
         // Debug card
-        binding.hourlyKeyPkgMode.apply {
-            setOnClickListener { vm.setHourlyKeyPkgMode(isChecked) }
-        }
-
         binding.backgroundNotificationsToggle.apply {
             setOnClickListener { vm.setBackgroundNotifications(isChecked) }
         }
@@ -45,7 +41,6 @@ class DebugOptionsFragment : Fragment(R.layout.fragment_test_debugoptions), Auto
         vm.debugOptionsState.observe2(this) { state ->
             binding.apply {
                 backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled
-                hourlyKeyPkgMode.isChecked = state.isHourlyTestingMode
             }
         }
         binding.testLogfileToggle.apply {
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt
index 784c9731edd17cf949a8bb4d22d8898732fb8691..c58e64556e2a35b1f6604de20ecaee41ba75638e 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt
@@ -10,7 +10,6 @@ import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.TestSettings
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.task.common.DefaultTaskRequest
-import de.rki.coronawarnapp.test.api.ui.DebugOptionsState
 import de.rki.coronawarnapp.test.api.ui.EnvironmentState.Companion.toEnvironmentState
 import de.rki.coronawarnapp.test.api.ui.LoggerState.Companion.toLoggerState
 import de.rki.coronawarnapp.util.CWADebug
@@ -34,18 +33,10 @@ class DebugOptionsFragmentViewModel @AssistedInject constructor(
 
     val debugOptionsState by smartLiveData {
         DebugOptionsState(
-            areNotificationsEnabled = LocalData.backgroundNotification(),
-            isHourlyTestingMode = testSettings.isHourKeyPkgMode
+            areNotificationsEnabled = LocalData.backgroundNotification()
         )
     }
 
-    fun setHourlyKeyPkgMode(enabled: Boolean) {
-        debugOptionsState.update {
-            testSettings.isHourKeyPkgMode = enabled
-            it.copy(isHourlyTestingMode = enabled)
-        }
-    }
-
     val environmentState by smartLiveData {
         envSetup.toEnvironmentState()
     }
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsState.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsState.kt
new file mode 100644
index 0000000000000000000000000000000000000000..da57e399eaf9c4caf03c07a9e2fa719d3375fa17
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsState.kt
@@ -0,0 +1,5 @@
+package de.rki.coronawarnapp.test.debugoptions.ui
+
+data class DebugOptionsState(
+    val areNotificationsEnabled: Boolean
+)
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/CachedKeyListItem.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/CachedKeyListItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a659fbacff63f239e7baec4f795a987c51a5b2a5
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/CachedKeyListItem.kt
@@ -0,0 +1,12 @@
+package de.rki.coronawarnapp.test.keydownload.ui
+
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.util.lists.HasStableId
+
+data class CachedKeyListItem(
+    val info: CachedKeyInfo,
+    val fileSize: Long
+) : HasStableId {
+    override val stableId: Long
+        get() = info.id.hashCode().toLong()
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..60233a4eaee37dfb957b7aef30ecf52be4186d2d
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragment.kt
@@ -0,0 +1,79 @@
+package de.rki.coronawarnapp.test.keydownload.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.Fragment
+import androidx.recyclerview.widget.LinearLayoutManager
+import com.google.android.material.snackbar.Snackbar
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.FragmentTestKeydownloadBinding
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
+import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.lists.diffutil.update
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.viewBindingLazy
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
+import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
+import javax.inject.Inject
+
+@SuppressLint("SetTextI18n")
+class KeyDownloadTestFragment : Fragment(R.layout.fragment_test_keydownload), AutoInject {
+
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val vm: KeyDownloadTestFragmentViewModel by cwaViewModels { viewModelFactory }
+
+    private val binding: FragmentTestKeydownloadBinding by viewBindingLazy()
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        vm.fakeMeteredConnection.observe2(this) {
+            binding.fakeMeteredConnectionToggle.isChecked = it
+        }
+        binding.fakeMeteredConnectionToggle.setOnClickListener { vm.toggleAllowMeteredConnections() }
+
+        vm.isMeteredConnection.observe2(this) {
+            binding.infoMeteredNetwork.text = "Is metered network? $it"
+        }
+
+        binding.apply {
+            downloadAction.setOnClickListener { vm.download() }
+            clearAction.setOnClickListener { vm.clearDownloads() }
+        }
+
+        vm.isSyncRunning.observe2(this) { isRunning ->
+            binding.apply {
+                downloadAction.isEnabled = !isRunning
+                clearAction.isEnabled = !isRunning
+            }
+        }
+
+        val keyFileAdapter = KeyFileDownloadAdapter { vm.deleteKeyFile(it) }
+        binding.cacheList.apply {
+            adapter = keyFileAdapter
+            layoutManager = LinearLayoutManager(requireContext())
+        }
+
+        vm.currentCache.observe2(this) { items ->
+            val dayCount = items.count { it.info.type == CachedKeyInfo.Type.LOCATION_DAY }
+            val hourCount = items.count { it.info.type == CachedKeyInfo.Type.LOCATION_HOUR }
+            binding.cacheListInfos.text = "${items.size} files, $dayCount days, $hourCount hours."
+
+            keyFileAdapter.update(items)
+        }
+
+        vm.errorEvent.observe2(this) {
+            Snackbar.make(requireView(), it.toString(), Snackbar.LENGTH_LONG).show()
+        }
+    }
+
+    companion object {
+        val MENU_ITEM = TestMenuItem(
+            title = "Key Packages",
+            description = "View & Control the downloaded key pkgs..",
+            targetId = R.id.test_keydownload_fragment
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c5af46aeb618516de1de0c69af12a6dd4b3f96c2
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentModule.kt
@@ -0,0 +1,18 @@
+package de.rki.coronawarnapp.test.keydownload.ui
+
+import dagger.Binds
+import dagger.Module
+import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey
+
+@Module
+abstract class KeyDownloadTestFragmentModule {
+    @Binds
+    @IntoMap
+    @CWAViewModelKey(KeyDownloadTestFragmentViewModel::class)
+    abstract fun testKeyDownloadFragment(
+        factory: KeyDownloadTestFragmentViewModel.Factory
+    ): CWAViewModelFactory<out CWAViewModel>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c2dbd49fc3f779fa2ca313fcb178db5a8c10c42e
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentViewModel.kt
@@ -0,0 +1,80 @@
+package de.rki.coronawarnapp.test.keydownload.ui
+
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asLiveData
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.diagnosiskeys.download.KeyPackageSyncTool
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.storage.TestSettings
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.network.NetworkStateProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.sample
+import kotlinx.coroutines.runBlocking
+import timber.log.Timber
+
+class KeyDownloadTestFragmentViewModel @AssistedInject constructor(
+    dispatcherProvider: DispatcherProvider,
+    networkStateProvider: NetworkStateProvider,
+    private val testSettings: TestSettings,
+    private val keyPackageSyncTool: KeyPackageSyncTool,
+    private val keyCacheRepository: KeyCacheRepository
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    val currentCache = runBlocking {
+        // TODO runBlocking is not nice, how can we solve this better?
+        keyCacheRepository
+            .allCachedKeys()
+            .sample(250)
+            .map { items ->
+                items
+                    .sortedWith(compareBy({ it.info.day }, { it.info.hour }))
+                    .reversed()
+                    .map { CachedKeyListItem(it.info, it.path.length()) }
+            }
+            .asLiveData()
+    }
+
+    val isMeteredConnection = networkStateProvider.networkState
+        .map { it.isMeteredConnection }
+        .asLiveData()
+
+    val fakeMeteredConnection = testSettings.fakeMeteredConnection.flow.asLiveData()
+
+    val isSyncRunning = MutableLiveData(false)
+    val errorEvent = SingleLiveEvent<Exception>()
+
+    fun toggleAllowMeteredConnections() {
+        testSettings.fakeMeteredConnection.update { !it }
+    }
+
+    fun download() = launchWithSyncProgress {
+        keyPackageSyncTool.syncKeyFiles()
+    }
+
+    fun clearDownloads() = launchWithSyncProgress { keyCacheRepository.clear() }
+
+    private fun launchWithSyncProgress(action: suspend () -> Unit) {
+        isSyncRunning.postValue(true)
+        launch {
+            try {
+                action()
+            } catch (e: Exception) {
+                Timber.e(e, "Call failed.")
+                errorEvent.postValue(e)
+            } finally {
+                isSyncRunning.postValue(false)
+            }
+        }
+    }
+
+    fun deleteKeyFile(it: CachedKeyListItem) = launchWithSyncProgress {
+        keyCacheRepository.delete(listOf(it.info))
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<KeyDownloadTestFragmentViewModel>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyFileDownloadAdapter.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyFileDownloadAdapter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..596413a519f808b973c7998038b35fa9751582ba
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyFileDownloadAdapter.kt
@@ -0,0 +1,70 @@
+package de.rki.coronawarnapp.test.keydownload.ui
+
+import android.text.format.Formatter
+import android.view.ViewGroup
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.FragmentTestKeydownloadAdapterLineBinding
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.ui.lists.BaseAdapter
+import de.rki.coronawarnapp.util.lists.BindableVH
+import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffUtilAdapter
+import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffer
+import de.rki.coronawarnapp.util.ui.setGone
+import org.joda.time.format.DateTimeFormat
+
+class KeyFileDownloadAdapter(
+    private val deleteAction: (CachedKeyListItem) -> Unit
+) : BaseAdapter<KeyFileDownloadAdapter.CachedKeyViewHolder>(), AsyncDiffUtilAdapter<CachedKeyListItem> {
+
+    init {
+        setHasStableIds(true)
+    }
+
+    override val asyncDiffer: AsyncDiffer<CachedKeyListItem> = AsyncDiffer(this)
+
+    override fun getItemCount(): Int = data.size
+
+    override fun getItemId(position: Int): Long = data[position].stableId
+
+    override fun onCreateBaseVH(parent: ViewGroup, viewType: Int): CachedKeyViewHolder = CachedKeyViewHolder(parent)
+
+    override fun onBindBaseVH(holder: CachedKeyViewHolder, position: Int) {
+        val item = data[position]
+        holder.itemView.setOnLongClickListener {
+            deleteAction(item)
+            true
+        }
+        holder.bind(item)
+    }
+
+    class CachedKeyViewHolder(
+        val parent: ViewGroup
+    ) : BaseAdapter.VH(
+        R.layout.fragment_test_keydownload_adapter_line, parent
+    ), BindableVH<CachedKeyListItem, FragmentTestKeydownloadAdapterLineBinding> {
+
+        override val viewBinding = lazy { FragmentTestKeydownloadAdapterLineBinding.bind(itemView) }
+
+        override val onBindData: FragmentTestKeydownloadAdapterLineBinding.(key: CachedKeyListItem) -> Unit = { item ->
+            locationInfo.text = item.info.location.identifier
+
+            val shortSize = Formatter.formatShortFileSize(context, item.fileSize)
+            typeInfo.text = when (item.info.type) {
+                CachedKeyInfo.Type.LOCATION_DAY -> "Day ($shortSize)"
+                CachedKeyInfo.Type.LOCATION_HOUR -> "Hour ($shortSize)"
+            }
+            timeInfo.text = when (item.info.type) {
+                CachedKeyInfo.Type.LOCATION_DAY -> "${item.info.day}"
+                CachedKeyInfo.Type.LOCATION_HOUR -> "${item.info.day} ${item.info.hour!!.hourOfDay}:00"
+            }
+            creationData.text = item.info.createdAt.toString(DOWNLOAD_TIME_FORMATTER)
+            creationLabel.setGone(!item.info.isDownloadComplete)
+            creationData.setGone(!item.info.isDownloadComplete)
+            progressIndicator.setGone(item.info.isDownloadComplete)
+        }
+    }
+
+    companion object {
+        private val DOWNLOAD_TIME_FORMATTER = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSSS")
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt
index d3316873e6c0bb8d17fc9cf190189b9e1eb4faed..36d71e9cb89f0fde006cbb5c9f2df53e228d6c33 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt
@@ -6,6 +6,7 @@ import de.rki.coronawarnapp.test.api.ui.TestForAPIFragment
 import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment
 import de.rki.coronawarnapp.test.crash.ui.SettingsCrashReportFragment
 import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment
+import de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragment
 import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment
 import de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
@@ -20,6 +21,7 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() {
             AppConfigTestFragment.MENU_ITEM,
             TestForAPIFragment.MENU_ITEM,
             TestRiskLevelCalculationFragment.MENU_ITEM,
+            KeyDownloadTestFragment.MENU_ITEM,
             TestTaskControllerFragment.MENU_ITEM,
             SettingsCrashReportFragment.MENU_ITEM
         ).let { MutableLiveData(it) }
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
index 0644ec8261959aac9dd0a9428a133b11b15d01f3..0867b6e6bfcbce4261cde511273134389288fe83 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
@@ -24,7 +24,7 @@ import de.rki.coronawarnapp.util.viewmodel.cwaViewModelsAssisted
 import timber.log.Timber
 import javax.inject.Inject
 
-@Suppress("MagicNumber", "LongMethod")
+@Suppress("LongMethod")
 class TestRiskLevelCalculationFragment : Fragment(R.layout.fragment_test_risk_level_calculation),
     AutoInject {
     private val navArgs by navArgs<TestRiskLevelCalculationFragmentArgs>()
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt
index 762a10de5f296adb9f3a06fa6846532c65ffeb59..45c6a1a39cb71af33faf15ee88baf8433099c4a4 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt
@@ -31,7 +31,6 @@ class TestTask @Inject constructor() : Task<DefaultProgress, TestTask.Result> {
         internalProgress.close()
     }
 
-    @Suppress("MagicNumber")
     private suspend fun runSafely(arguments: Arguments): Result {
         for (it in 1..10) {
             internalProgress.send(DefaultProgress("${arguments.prefix}: ${Instant.now()}"))
@@ -65,7 +64,8 @@ class TestTask @Inject constructor() : Task<DefaultProgress, TestTask.Result> {
         private val taskByDagger: Provider<TestTask>
     ) : TaskFactory<DefaultProgress, Result> {
 
-        override val config: TaskFactory.Config = Config()
+        override suspend fun createConfig(): TaskFactory.Config = Config()
+
         override val taskProvider: () -> Task<DefaultProgress, Result> = {
             taskByDagger.get()
         }
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragment.kt
index 4fd79b65b8f483eec939d0ecc93aa5bab934fd8e..c990c6980263ead1aa8bbdcfcb466edea665be66 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragment.kt
@@ -16,7 +16,7 @@ import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
 import javax.inject.Inject
 
 @SuppressLint("SetTextI18n")
-@Suppress("MagicNumber", "LongMethod")
+@Suppress("LongMethod")
 class TestTaskControllerFragment : Fragment(R.layout.fragment_test_task_controller), AutoInject {
 
     @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragmentViewModel.kt
index 7b88f9bf51ab4d9ef4685cac6dba6e32f634c63d..82b274e9b6196175afa396580e06d77b5d3114e2 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragmentViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragmentViewModel.kt
@@ -32,7 +32,7 @@ class TestTaskControllerFragmentViewModel @AssistedInject constructor(
     val factoryState: LiveData<FactoryState> = liveData(context = dispatcherProvider.Default) {
         val infoStrings = taskFactories.map {
             val taskLabel = it.key.simpleName
-            val collisionBehavior = it.value.config.collisionBehavior.toString()
+            val collisionBehavior = it.value.createConfig().collisionBehavior.toString()
             """
                 $taskLabel - Behavior: $collisionBehavior
             """.trimIndent()
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt
index dae7827378498fa470ebb0df278741e4d1e00522..41cca0d6071aab1aca83653e9283893028312c39 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt
@@ -8,6 +8,8 @@ import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment
 import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragmentModule
 import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment
 import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragmentModule
+import de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragment
+import de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragmentModule
 import de.rki.coronawarnapp.test.menu.ui.TestMenuFragment
 import de.rki.coronawarnapp.test.menu.ui.TestMenuFragmentModule
 import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment
@@ -35,4 +37,7 @@ abstract class MainActivityTestModule {
 
     @ContributesAndroidInjector(modules = [DebugOptionsFragmentModule::class])
     abstract fun debugOptions(): DebugOptionsFragment
+
+    @ContributesAndroidInjector(modules = [KeyDownloadTestFragmentModule::class])
+    abstract fun keyDownload(): KeyDownloadTestFragment
 }
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml
index 628201557567cccfde46d6928854209b410860e2..867b86569315d89fa9c080b0944a56deee7970cb 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml
@@ -32,18 +32,6 @@
                     app:layout_constraintStart_toStartOf="parent"
                     app:layout_constraintTop_toTopOf="parent" />
 
-                <Switch
-                    android:id="@+id/hourly_key_pkg_mode"
-                    style="@style/body1"
-                    android:layout_width="match_parent"
-                    android:layout_height="0dp"
-                    android:layout_marginTop="@dimen/spacing_small"
-                    android:text="Hourly keyfile mode (last 24)"
-                    android:theme="@style/switchBase"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/debug_container_title" />
-
                 <Switch
                     android:id="@+id/background_notifications_toggle"
                     style="@style/body1"
@@ -54,7 +42,7 @@
                     android:theme="@style/switchBase"
                     app:layout_constraintEnd_toEndOf="parent"
                     app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/hourly_key_pkg_mode" />
+                    app:layout_constraintTop_toBottomOf="@+id/debug_container_title" />
 
                 <Switch
                     android:id="@+id/test_logfile_toggle"
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml
index a72eafd72f6db23a094411021dbf8dd578e9cfc2..a4eb49227c15f443d2dde6b13eb76d3affca190c 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml
@@ -253,89 +253,6 @@
                     android:text="Get Active Tracing Duration in Retention Period" />
             </LinearLayout>
 
-            <LinearLayout
-                android:id="@+id/country_container"
-                style="@style/card"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_margin="@dimen/spacing_tiny"
-                android:orientation="vertical">
-
-                <TextView
-                    android:id="@+id/label_country_filter"
-                    style="@style/headline6"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:text="Country Settings" />
-
-                <LinearLayout
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content">
-
-                    <EditText
-                        android:id="@+id/input_country_codes_editText"
-                        android:layout_width="0dp"
-                        android:layout_height="wrap_content"
-                        android:layout_weight="1" />
-
-                    <Button
-                        android:id="@+id/button_filter_country_codes"
-                        style="@style/buttonPrimary"
-                        android:layout_width="wrap_content"
-                        android:layout_height="wrap_content"
-                        android:text="Apply" />
-                </LinearLayout>
-
-                <TextView
-                    android:id="@+id/label_country_code_filter_status"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:text="Country filter applied for:">
-
-                </TextView>
-
-                <TextView
-                    android:id="@+id/label_test_api_measure"
-                    style="@style/headline6"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_normal"
-                    android:text="Statistics" />
-
-                <LinearLayout
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:gravity="center_vertical"
-                    android:orientation="horizontal">
-
-                    <EditText
-                        android:id="@+id/input_measure_risk_key_repeat_count"
-                        android:layout_width="90dp"
-                        android:layout_height="wrap_content"
-                        android:inputType="number"
-                        android:text="1" />
-
-                    <Button
-                        android:id="@+id/button_retrieve_diagnosis_keys_and_calc_risk_level"
-                        style="@style/buttonPrimary"
-                        android:layout_width="0dp"
-                        android:layout_height="wrap_content"
-                        android:layout_marginTop="@dimen/spacing_normal"
-                        android:layout_marginBottom="@dimen/spacing_normal"
-                        android:layout_weight="1"
-                        android:imeOptions="actionDone"
-                        android:text="Measure: Calculate Risk Level / Key Retrieval" />
-
-                </LinearLayout>
-
-                <TextView
-                    android:id="@+id/label_test_api_measure_calc_key_status"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:text="Result: " />
-
-            </LinearLayout>
-
             <de.rki.coronawarnapp.ui.calendar.CalendarView
                 android:id="@+id/calendar_container"
                 android:layout_width="match_parent"
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload.xml
new file mode 100644
index 0000000000000000000000000000000000000000..7c160bde79a9c55807e3402cc0fae18afca6accd
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload.xml
@@ -0,0 +1,88 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:ignore="HardcodedText">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_margin="8dp"
+        android:orientation="vertical"
+        android:paddingBottom="32dp">
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            style="@style/card"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/spacing_tiny"
+            android:layout_marginStart="8dp"
+            android:layout_marginEnd="8dp"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/info_metered_network"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Is metered network: ??"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent" />
+
+            <Switch
+                android:id="@+id/fake_metered_connection_toggle"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Fake metered connection status"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@+id/info_metered_network" />
+
+            <Button
+                android:id="@+id/clear_action"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:text="Clear"
+                app:layout_constraintEnd_toStartOf="@+id/download_action"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@+id/fake_metered_connection_toggle" />
+
+            <Button
+                android:id="@+id/download_action"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:text="Download"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toEndOf="@+id/clear_action"
+                app:layout_constraintTop_toBottomOf="@+id/fake_metered_connection_toggle" />
+
+        </androidx.constraintlayout.widget.ConstraintLayout>
+
+        <TextView
+            style="@style/TextAppearance.AppCompat.Caption"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/spacing_tiny"
+            android:gravity="center"
+            android:text="Long press entries to delete them." />
+        <TextView
+            android:id="@+id/cache_list_infos"
+            style="@style/TextAppearance.AppCompat.Caption"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="@dimen/spacing_tiny"
+            android:gravity="center"
+            tools:text="17 files, 14 days, 3 hours." />
+
+        <androidx.recyclerview.widget.RecyclerView
+            android:id="@+id/cache_list"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent" />
+
+    </LinearLayout>
+</androidx.core.widget.NestedScrollView>
\ No newline at end of file
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload_adapter_line.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload_adapter_line.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ba86dfd179b0b4b0d970b67d873886cef7453cc0
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload_adapter_line.xml
@@ -0,0 +1,77 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:background="?selectableItemBackground"
+    android:layout_height="wrap_content">
+    <TextView
+        android:id="@+id/location_info"
+        style="@style/body1"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="16dp"
+        android:layout_marginTop="8dp"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        tools:text="EUR" />
+    <TextView
+        android:id="@+id/type_info"
+        style="@style/TextAppearance.AppCompat.Caption"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="8dp"
+        app:layout_constraintBottom_toBottomOf="@+id/location_info"
+        app:layout_constraintEnd_toStartOf="@+id/creation_label"
+        app:layout_constraintHorizontal_bias="0.0"
+        app:layout_constraintStart_toEndOf="@id/location_info"
+        app:layout_constraintTop_toTopOf="@+id/location_info"
+        tools:text="Day Package" />
+
+    <TextView
+        android:id="@+id/time_info"
+        style="@style/body1"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="16dp"
+        android:layout_marginBottom="8dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@id/type_info"
+        tools:text="2020-11-02 12:00" />
+
+    <TextView
+        android:id="@+id/creation_label"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        style="@style/TextAppearance.AppCompat.Caption"
+        android:layout_marginEnd="16dp"
+        android:text="Downloaded at"
+        app:layout_constraintBottom_toBottomOf="@+id/location_info"
+        app:layout_constraintEnd_toStartOf="@+id/progress_indicator"
+        app:layout_constraintTop_toTopOf="@+id/location_info" />
+
+    <TextView
+        android:id="@+id/creation_data"
+        style="@style/TextAppearance.AppCompat.Caption"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="8dp"
+        android:layout_marginEnd="16dp"
+        app:layout_constraintBottom_toBottomOf="@+id/time_info"
+        app:layout_constraintEnd_toStartOf="@+id/progress_indicator"
+        app:layout_constraintHorizontal_bias="1.0"
+        app:layout_constraintStart_toEndOf="@+id/time_info"
+        app:layout_constraintTop_toTopOf="@+id/time_info"
+        tools:text="1111-11-11 11:11" />
+
+    <ProgressBar
+        android:id="@+id/progress_indicator"
+        android:layout_width="32dp"
+        android:layout_height="32dp"
+        android:layout_marginEnd="16dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintTop_toTopOf="parent" />
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml b/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml
index 1c53c77729006536c92d18efecd0b547370bb3cf..226a618a77e11982b3a1ead5d9fbf879429c7188 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml
@@ -28,6 +28,9 @@
         <action
             android:id="@+id/action_test_menu_fragment_to_debugOptionsFragment"
             app:destination="@id/test_debugoptions_fragment" />
+        <action
+            android:id="@+id/action_test_menu_fragment_to_keyDownloadTestFragment"
+            app:destination="@id/test_keydownload_fragment" />
     </fragment>
 
     <fragment
@@ -78,5 +81,10 @@
         android:name="de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment"
         android:label="DebugOptionsFragment"
         tools:layout="@layout/fragment_test_debugoptions" />
+    <fragment
+        android:id="@+id/test_keydownload_fragment"
+        android:name="de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragment"
+        android:label="KeyDownloadTestFragment"
+        tools:layout="@layout/fragment_test_keydownload" />
 
 </navigation>
diff --git a/Corona-Warn-App/src/main/AndroidManifest.xml b/Corona-Warn-App/src/main/AndroidManifest.xml
index c4906d42e94beacd8d93e1454cc46327332be173..6556197e9545e30520b7ac4d2027f8a5d6772781 100644
--- a/Corona-Warn-App/src/main/AndroidManifest.xml
+++ b/Corona-Warn-App/src/main/AndroidManifest.xml
@@ -48,6 +48,10 @@
             </intent-filter>
         </receiver>
 
+        <receiver
+            android:name=".notification.NotificationReceiver"
+            android:enabled="true"/>
+
         <activity
             android:name=".ui.LauncherActivity"
             android:screenOrientation="portrait"
diff --git a/Corona-Warn-App/src/main/assets/default_app_config.bin b/Corona-Warn-App/src/main/assets/default_app_config.bin
new file mode 100644
index 0000000000000000000000000000000000000000..d04532907debe2eaf94fb70f39a3a0ccb7d9937a
Binary files /dev/null and b/Corona-Warn-App/src/main/assets/default_app_config.bin differ
diff --git a/Corona-Warn-App/src/main/assets/default_app_config.sha256 b/Corona-Warn-App/src/main/assets/default_app_config.sha256
new file mode 100644
index 0000000000000000000000000000000000000000..686b1764cf7bd1c0662651322c5ca3f29c8f2ff5
--- /dev/null
+++ b/Corona-Warn-App/src/main/assets/default_app_config.sha256
@@ -0,0 +1 @@
+a562bf5940b8c149138634d313db69a298a50e8c52c0b42d18ddf608c385b598
\ No newline at end of file
diff --git a/Corona-Warn-App/src/main/assets/privacy_de.html b/Corona-Warn-App/src/main/assets/privacy_de.html
index ef95dc99ffd7ded32261d06b2f8ccba4283c5686..dd168314234a071eee96b203b3a0d4c60167b306 100644
--- a/Corona-Warn-App/src/main/assets/privacy_de.html
+++ b/Corona-Warn-App/src/main/assets/privacy_de.html
@@ -16,8 +16,7 @@
     <strong>2. Ist die Nutzung der App freiwillig?</strong>
 </p>
 <p>
-    <strong>
-        Auf welcher Rechtsgrundlage werden Ihre Daten verarbeitet?
+    <strong>3. Auf welcher Rechtsgrundlage werden Ihre Daten verarbeitet?
     </strong>
 </p>
 <p>
@@ -792,4 +791,4 @@
 </p>
 <p>
     Stand: 15.10.2020
-</p>
\ No newline at end of file
+</p>
diff --git a/Corona-Warn-App/src/main/assets/terms_de.html b/Corona-Warn-App/src/main/assets/terms_de.html
index 49dfc65a29b23d2ce05571516b824266dfe0b9bd..380c9d68b8d3ab6ff7a2040a9514a8db76734f73 100644
--- a/Corona-Warn-App/src/main/assets/terms_de.html
+++ b/Corona-Warn-App/src/main/assets/terms_de.html
@@ -454,7 +454,7 @@
 <p>
     Bestimmte Funktionen der App setzen auf zentrale Dienste und Systeme auf,
     die über die CWA-Dienste zur Verfügung gestellt werden. Diese Funktionen
-    stehen daher nur zur Verfügung, wenn Ihr Smartpnone über eine Datenverbindung
+    stehen daher nur zur Verfügung, wenn Ihr Smartphone über eine Datenverbindung
     mit dem Internet verfügt, z.B. über UMTS, LTE oder WLAN, um hierüber auf
     die CWA-Dienste zugreifen zu können. Ohne Datenverbindung stehen einige
     oder alle Funktionen der App nicht zur Verfügung. Dies gilt auch, wenn Sie
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 473252103438e393733130f36590eb7841fb2b36..ebe7c473fc3d6037becb4bcab9981d8fe5fccf6f 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
@@ -7,20 +7,22 @@ import android.content.IntentFilter
 import android.os.Bundle
 import android.view.WindowManager
 import androidx.localbroadcastmanager.content.LocalBroadcastManager
+import androidx.work.WorkManager
 import dagger.android.AndroidInjector
 import dagger.android.DispatchingAndroidInjector
 import dagger.android.HasAndroidInjector
 import de.rki.coronawarnapp.bugreporting.loghistory.LogHistoryTree
+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.storage.LocalData
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.util.CWADebug
 import de.rki.coronawarnapp.util.ForegroundState
 import de.rki.coronawarnapp.util.WatchdogService
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.ApplicationComponent
-import de.rki.coronawarnapp.util.worker.WorkManagerSetup
 import de.rki.coronawarnapp.worker.BackgroundWorkHelper
 import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.flow.launchIn
@@ -41,7 +43,8 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
     @Inject lateinit var watchdogService: WatchdogService
     @Inject lateinit var taskController: TaskController
     @Inject lateinit var foregroundState: ForegroundState
-    @Inject lateinit var workManagerSetup: WorkManagerSetup
+    @Inject lateinit var workManager: WorkManager
+    @Inject lateinit var deadmanNotificationScheduler: DeadmanNotificationScheduler
     @LogHistoryTree @Inject lateinit var rollingLogHistory: Timber.Tree
 
     override fun onCreate() {
@@ -54,8 +57,7 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
 
         Timber.plant(rollingLogHistory)
 
-        Timber.v("onCreate(): Initializing WorkManager")
-        workManagerSetup.setup()
+        Timber.v("onCreate(): WorkManager setup done: $workManager")
 
         NotificationHelper.createNotificationChannel()
 
@@ -73,6 +75,10 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
         foregroundState.isInForeground
             .onEach { isAppInForeground = it }
             .launchIn(GlobalScope)
+
+        if (LocalData.onboardingCompletedTimestamp() != null) {
+            deadmanNotificationScheduler.schedulePeriodic()
+        }
     }
 
     private val activityLifecycleCallback = object : ActivityLifecycleCallbacks {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt
index 82936e52b38862e1288652fe250952d4e5669375..19ae812f371b271dcd77c11f8037b61d8d393f09 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt
@@ -6,8 +6,8 @@ import dagger.Provides
 import de.rki.coronawarnapp.appconfig.download.AppConfigApiV1
 import de.rki.coronawarnapp.appconfig.download.AppConfigHttpCache
 import de.rki.coronawarnapp.appconfig.mapping.CWAConfigMapper
-import de.rki.coronawarnapp.appconfig.mapping.DownloadConfigMapper
 import de.rki.coronawarnapp.appconfig.mapping.ExposureDetectionConfigMapper
+import de.rki.coronawarnapp.appconfig.mapping.KeyDownloadParametersMapper
 import de.rki.coronawarnapp.appconfig.mapping.RiskCalculationConfigMapper
 import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient
 import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl
@@ -64,7 +64,7 @@ class AppConfigModule {
     fun cwaMapper(mapper: CWAConfigMapper): CWAConfig.Mapper = mapper
 
     @Provides
-    fun downloadMapper(mapper: DownloadConfigMapper): KeyDownloadConfig.Mapper = mapper
+    fun downloadMapper(mapper: KeyDownloadParametersMapper): KeyDownloadConfig.Mapper = mapper
 
     @Provides
     fun exposurMapper(mapper: ExposureDetectionConfigMapper): ExposureDetectionConfig.Mapper =
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
index 91866abb5c8d0d302038a0ffce536ab629246140..71906e83169d8e0ef4a68e34aa1208ec958defc5 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
@@ -2,12 +2,11 @@ package de.rki.coronawarnapp.appconfig
 
 import de.rki.coronawarnapp.util.coroutine.AppScope
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.flow.HotDataFlow
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.flow.SharingStarted
 import timber.log.Timber
 import javax.inject.Inject
 import javax.inject.Singleton
@@ -19,24 +18,31 @@ class AppConfigProvider @Inject constructor(
     @AppScope private val scope: CoroutineScope
 ) {
 
-    private val mutex = Mutex()
-    private val currentConfigInternal = MutableStateFlow<ConfigData?>(null)
-
-    val currentConfig: Flow<ConfigData?> = currentConfigInternal
-
-    suspend fun clear() = mutex.withLock {
-        Timber.tag(TAG).v("clear()")
-        source.clear()
-        currentConfigInternal.value = null
+    private val configHolder = HotDataFlow(
+        loggingTag = "AppConfigProvider",
+        scope = scope,
+        coroutineContext = dispatcherProvider.IO,
+        sharingBehavior = SharingStarted.WhileSubscribed(replayExpirationMillis = 0)
+    ) {
+        source.retrieveConfig()
     }
 
-    suspend fun getAppConfig(): ConfigData = mutex.withLock {
-        Timber.tag(TAG).v("getAppConfig()")
-        withContext(context = scope.coroutineContext + dispatcherProvider.IO) {
-            source.retrieveConfig().also {
-                currentConfigInternal.emit(it)
+    val currentConfig: Flow<ConfigData> = configHolder.data
+
+    suspend fun getAppConfig(): ConfigData {
+        // Switch scope so the app config can't get canceled due to unsubscription,
+        // we'd still like to have that new config in any case.
+        val deferred = scope.async(context = dispatcherProvider.IO) {
+            configHolder.updateBlocking {
+                source.retrieveConfig()
             }
         }
+        return deferred.await()
+    }
+
+    suspend fun clear() {
+        Timber.tag(TAG).v("clear()")
+        source.clear()
     }
 
     companion object {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt
index fd31afca8ef1e6ce15beef7927dab09473d752ca..fa981d6dc18048fd2a70a74e5bc42672db1f5bff 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt
@@ -2,10 +2,12 @@ package de.rki.coronawarnapp.appconfig
 
 import de.rki.coronawarnapp.appconfig.download.AppConfigServer
 import de.rki.coronawarnapp.appconfig.download.AppConfigStorage
-import de.rki.coronawarnapp.appconfig.download.ApplicationConfigurationInvalidException
+import de.rki.coronawarnapp.appconfig.download.DefaultAppConfigSource
 import de.rki.coronawarnapp.appconfig.mapping.ConfigParser
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import kotlinx.coroutines.withContext
+import org.joda.time.Duration
+import org.joda.time.Instant
 import timber.log.Timber
 import javax.inject.Inject
 import javax.inject.Singleton
@@ -15,6 +17,7 @@ class AppConfigSource @Inject constructor(
     private val server: AppConfigServer,
     private val storage: AppConfigStorage,
     private val parser: ConfigParser,
+    private val defaultAppConfig: DefaultAppConfigSource,
     private val dispatcherProvider: DispatcherProvider
 ) {
 
@@ -36,7 +39,8 @@ class AppConfigSource @Inject constructor(
                         mappedConfig = it,
                         serverTime = configDownload.serverTime,
                         localOffset = configDownload.localOffset,
-                        isFallback = false
+                        identifier = configDownload.etag,
+                        configType = ConfigData.Type.FROM_SERVER
                     )
                 }
             } catch (e: Exception) {
@@ -53,7 +57,8 @@ class AppConfigSource @Inject constructor(
                             mappedConfig = parser.parse(it.rawData),
                             serverTime = it.serverTime,
                             localOffset = it.localOffset,
-                            isFallback = true
+                            identifier = it.etag,
+                            configType = ConfigData.Type.LAST_RETRIEVED
                         )
                     }
                 } catch (e: Exception) {
@@ -64,7 +69,14 @@ class AppConfigSource @Inject constructor(
         }
 
         if (parsedConfig == null) {
-            throw ApplicationConfigurationInvalidException(serverError)
+            Timber.tag(TAG).w("Current or fallback config was unavailable, using default.")
+            parsedConfig = DefaultConfigData(
+                mappedConfig = parser.parse(defaultAppConfig.getRawDefaultConfig()),
+                serverTime = Instant.EPOCH,
+                localOffset = Duration.standardHours(12),
+                identifier = "fallback.local",
+                configType = ConfigData.Type.LOCAL_DEFAULT
+            )
         }
 
         return@withContext parsedConfig
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt
index 6845e3d29be4db488fb1fe29e5bfdd9102040672..e903926da969d2243dc02bc4332f41d6aff51f02 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt
@@ -6,19 +6,42 @@ import org.joda.time.Instant
 
 interface ConfigData : ConfigMapping {
 
+    /**
+     * A unique value to identify this app config by.
+     * When this value changes, the app config has changed.
+     */
+    val identifier: String
+
     /**
      * serverTime + localOffset = updatedAt
      */
     val updatedAt: Instant
 
     /**
-     * If **[isFallback]** returns true,
+     * If **[configType]** is not **[Type.FROM_SERVER]**,
      * you should probably ignore the time offset.
      */
     val localOffset: Duration
 
     /**
-     * Returns true if this is not a fresh config, e.g. server could not be reached.
+     * Returns the type config this is.
      */
-    val isFallback: Boolean
+    val configType: Type
+
+    enum class Type {
+        /**
+         * Fresh one from a server.
+         */
+        FROM_SERVER,
+
+        /**
+         * Server config locally stored.
+         */
+        LAST_RETRIEVED,
+
+        /**
+         * Last resort, default config shipped with the app.
+         */
+        LOCAL_DEFAULT
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt
index 5fc918d095771bf9df33b81ec5cf591595ed46b5..d2ab41944ce8533f43ab7a82be7421899f823790 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt
@@ -7,8 +7,9 @@ import org.joda.time.Instant
 data class DefaultConfigData(
     val serverTime: Instant,
     val mappedConfig: ConfigMapping,
+    override val identifier: String,
     override val localOffset: Duration,
-    override val isFallback: Boolean
+    override val configType: ConfigData.Type
 ) : ConfigData, ConfigMapping by mappedConfig {
     override val updatedAt: Instant = serverTime.plus(localOffset)
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt
index 5281c51ede2e8a7d12d55e56be4ccb92b8155031..be47c2f17b4d1834f1de0d0370eb2e4918138de5 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt
@@ -3,9 +3,14 @@ package de.rki.coronawarnapp.appconfig
 import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
 import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
 import de.rki.coronawarnapp.server.protocols.internal.ExposureDetectionParameters
+import org.joda.time.Duration
 
 interface ExposureDetectionConfig {
 
+    val maxExposureDetectionsPerUTCDay: Int
+    val minTimeBetweenDetections: Duration
+    val overallDetectionTimeout: Duration
+
     val exposureDetectionConfiguration: ExposureConfiguration
     val exposureDetectionParameters: ExposureDetectionParameters.ExposureDetectionParametersAndroid
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
index d82a6cd3a3ecc741986426e6de9013ee8a3626f6..3c0a31697f3e0e87529481961e4c98943b3dc1ed 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
@@ -1,11 +1,33 @@
 package de.rki.coronawarnapp.appconfig
 
 import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
-import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import org.joda.time.Duration
+import org.joda.time.LocalDate
+import org.joda.time.LocalTime
 
 interface KeyDownloadConfig {
 
-    val keyDownloadParameters: KeyDownloadParameters.KeyDownloadParametersAndroid
+    val individualDownloadTimeout: Duration
+
+    val overallDownloadTimeout: Duration
+
+    val revokedDayPackages: Collection<RevokedKeyPackage.Day>
+
+    val revokedHourPackages: Collection<RevokedKeyPackage.Hour>
+
+    interface RevokedKeyPackage {
+        val etag: String
+        val region: LocationCode
+
+        interface Day : RevokedKeyPackage {
+            val day: LocalDate
+        }
+
+        interface Hour : Day, RevokedKeyPackage {
+            val hour: LocalTime
+        }
+    }
 
     interface Mapper : ConfigMapper<KeyDownloadConfig>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt
index 1d707c26bb248982443582fdba7ed672c640fe1e..7d174ac15251e8ca5f928e8b135f1985f32050bd 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt
@@ -7,6 +7,7 @@ import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.ZipHelper.readIntoMap
 import de.rki.coronawarnapp.util.ZipHelper.unzip
+import de.rki.coronawarnapp.util.retrofit.etag
 import de.rki.coronawarnapp.util.security.VerificationKeys
 import okhttp3.Cache
 import org.joda.time.Duration
@@ -33,9 +34,6 @@ class AppConfigServer @Inject constructor(
         val response = api.get().getApplicationConfiguration(homeCountry.identifier)
         if (!response.isSuccessful) throw HttpException(response)
 
-        // If this is a cached response, we need the original timestamp to calculate the time offset
-        val localTime = response.getCacheTimestamp() ?: timeStamper.nowUTC
-
         val rawConfig = with(
             requireNotNull(response.body()) { "Response was successful but body was null" }
         ) {
@@ -55,12 +53,20 @@ class AppConfigServer @Inject constructor(
             exportBinary
         }
 
+        // If this is a cached response, we need the original timestamp to calculate the time offset
+        val localTime = response.getCacheTimestamp() ?: timeStamper.nowUTC
+
+        // Shouldn't happen, but hey ¯\_(ツ)_/¯
+        val etag =
+            response.headers().etag() ?: throw ApplicationConfigurationInvalidException(message = "Server has no ETAG.")
+
         val serverTime = response.getServerDate() ?: localTime
         val offset = Duration(serverTime, localTime)
         Timber.tag(TAG).v("Time offset was %dms", offset.millis)
 
         return ConfigDownload(
             rawData = rawConfig,
+            etag = etag,
             serverTime = serverTime,
             localOffset = offset
         )
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt
index c5a86a92887ab5c26dd0afe1dd54ee4220186e6f..1d939896ce9e0b2f7eadf831cb40586ac73ce50b 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt
@@ -47,7 +47,8 @@ class AppConfigStorage @Inject constructor(
                 ConfigDownload(
                     rawData = legacyConfigFile.readBytes(),
                     serverTime = timeStamper.nowUTC,
-                    localOffset = Duration.ZERO
+                    localOffset = Duration.ZERO,
+                    etag = "legacy.migration"
                 )
             } catch (e: Exception) {
                 Timber.e(e, "Legacy config exits but couldn't be read.")
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt
index 1f9ba9050b61f2a5d802e2f5d692cdeba3881369..d6114e5a89fa050b3773d9263efbfa5a1801d552 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt
@@ -6,6 +6,7 @@ import org.joda.time.Instant
 
 data class ConfigDownload(
     @SerializedName("rawData") val rawData: ByteArray,
+    @SerializedName("etag") val etag: String,
     @SerializedName("serverTime") val serverTime: Instant,
     @SerializedName("localOffset") val localOffset: Duration
 ) {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSource.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSource.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ddb4e4528de00d44c08b7ba6e0910371c2ab9916
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSource.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import android.content.Context
+import dagger.Reusable
+import de.rki.coronawarnapp.util.di.AppContext
+import javax.inject.Inject
+
+@Reusable
+class DefaultAppConfigSource @Inject constructor(
+    @AppContext private val context: Context
+) {
+
+    fun getRawDefaultConfig(): ByteArray {
+        return context.assets.open("default_app_config.bin").readBytes()
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt
deleted file mode 100644
index 752f41cb176c35ca3ba562185dcf172548e098d1..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt
+++ /dev/null
@@ -1,21 +0,0 @@
-package de.rki.coronawarnapp.appconfig.mapping
-
-import dagger.Reusable
-import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig
-import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters
-import javax.inject.Inject
-
-@Reusable
-class DownloadConfigMapper @Inject constructor() : KeyDownloadConfig.Mapper {
-    override fun map(rawConfig: AppConfig.ApplicationConfiguration): KeyDownloadConfig {
-
-        return KeyDownloadConfigContainer(
-            keyDownloadParameters = rawConfig.androidKeyDownloadParameters
-        )
-    }
-
-    data class KeyDownloadConfigContainer(
-        override val keyDownloadParameters: KeyDownloadParameters.KeyDownloadParametersAndroid
-    ) : KeyDownloadConfig
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt
index c010e25af42a0d9b70693ae7522488c33d5bcc4c..92473af001e20e37facf9758760eb9724195c0b3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt
@@ -6,22 +6,57 @@ import dagger.Reusable
 import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
 import de.rki.coronawarnapp.server.protocols.internal.AppConfig
 import de.rki.coronawarnapp.server.protocols.internal.ExposureDetectionParameters.ExposureDetectionParametersAndroid
+import org.joda.time.Duration
 import javax.inject.Inject
 
 @Reusable
 class ExposureDetectionConfigMapper @Inject constructor() : ExposureDetectionConfig.Mapper {
-    override fun map(rawConfig: AppConfig.ApplicationConfiguration): ExposureDetectionConfig =
-        ExposureDetectionConfigContainer(
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): ExposureDetectionConfig {
+        val exposureParams = rawConfig.androidExposureDetectionParameters
+        return ExposureDetectionConfigContainer(
             exposureDetectionConfiguration = rawConfig.mapRiskScoreToExposureConfiguration(),
-            exposureDetectionParameters = rawConfig.androidExposureDetectionParameters
+            exposureDetectionParameters = exposureParams,
+            maxExposureDetectionsPerUTCDay = exposureParams.maxExposureDetectionsPerDay(),
+            minTimeBetweenDetections = exposureParams.minTimeBetweenExposureDetections(),
+            overallDetectionTimeout = exposureParams.overAllDetectionTimeout()
         )
+    }
 
     data class ExposureDetectionConfigContainer(
         override val exposureDetectionConfiguration: ExposureConfiguration,
-        override val exposureDetectionParameters: ExposureDetectionParametersAndroid
+        override val exposureDetectionParameters: ExposureDetectionParametersAndroid,
+        override val maxExposureDetectionsPerUTCDay: Int,
+        override val minTimeBetweenDetections: Duration,
+        override val overallDetectionTimeout: Duration
     ) : ExposureDetectionConfig
 }
 
+// If we are outside the valid data range, fallback to default value.
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+fun ExposureDetectionParametersAndroid.overAllDetectionTimeout(): Duration = when {
+    overallTimeoutInSeconds > 3600 -> Duration.standardMinutes(15)
+    overallTimeoutInSeconds <= 0 -> Duration.standardMinutes(15)
+    else -> Duration.standardSeconds(overallTimeoutInSeconds.toLong())
+}
+
+// If we are outside the valid data range, fallback to default value.
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+fun ExposureDetectionParametersAndroid.maxExposureDetectionsPerDay(): Int = when {
+    maxExposureDetectionsPerInterval > 6 -> 6
+    maxExposureDetectionsPerInterval < 0 -> 6
+    else -> maxExposureDetectionsPerInterval
+}
+
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+fun ExposureDetectionParametersAndroid.minTimeBetweenExposureDetections(): Duration {
+    val detectionsPerDay = maxExposureDetectionsPerDay()
+    return if (detectionsPerDay == 0) {
+        Duration.standardDays(99)
+    } else {
+        (24 / detectionsPerDay).let { Duration.standardHours(it.toLong()) }
+    }
+}
+
 @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
 fun AppConfig.ApplicationConfiguration.mapRiskScoreToExposureConfiguration(): ExposureConfiguration =
     ExposureConfiguration
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4c55393ec77de81c5d97d75e2804218d419fbcf6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt
@@ -0,0 +1,100 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import androidx.annotation.VisibleForTesting
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters.KeyDownloadParametersAndroid
+import org.joda.time.Duration
+import org.joda.time.LocalDate
+import org.joda.time.LocalTime
+import org.joda.time.format.DateTimeFormat
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class KeyDownloadParametersMapper @Inject constructor() : KeyDownloadConfig.Mapper {
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): KeyDownloadConfig {
+        val rawParameters = rawConfig.androidKeyDownloadParameters
+
+        return KeyDownloadConfigContainer(
+            individualDownloadTimeout = rawParameters.individualTimeout(),
+            overallDownloadTimeout = rawParameters.overAllTimeout(),
+            revokedDayPackages = rawParameters.mapDayEtags(),
+            revokedHourPackages = rawParameters.mapHourEtags()
+        )
+    }
+
+    // If we are outside the valid data range, fallback to default value.
+    private fun KeyDownloadParametersAndroid.individualTimeout(): Duration = when {
+        downloadTimeoutInSeconds > 1800 -> Duration.standardSeconds(60)
+        downloadTimeoutInSeconds <= 0 -> Duration.standardSeconds(60)
+        else -> Duration.standardSeconds(downloadTimeoutInSeconds.toLong())
+    }
+
+    // If we are outside the valid data range, fallback to default value.
+    private fun KeyDownloadParametersAndroid.overAllTimeout(): Duration = when {
+        overallTimeoutInSeconds > 1800 -> Duration.standardMinutes(8)
+        overallTimeoutInSeconds <= 0 -> Duration.standardMinutes(8)
+        else -> Duration.standardSeconds(overallTimeoutInSeconds.toLong())
+    }
+
+    private fun KeyDownloadParametersAndroid.mapDayEtags(): List<RevokedKeyPackage.Day> =
+        this.revokedDayPackagesList.mapNotNull {
+            try {
+                RevokedKeyPackage.Day(
+                    etag = it.etag,
+                    region = LocationCode(it.region),
+                    day = LocalDate.parse(it.date, DAY_FORMATTER)
+                )
+            } catch (e: Exception) {
+                Timber.e(e, "Failed to parse revoked day metadata: %s", it)
+                null
+            }
+        }
+
+    private fun KeyDownloadParametersAndroid.mapHourEtags(): List<RevokedKeyPackage.Hour> =
+        this.revokedHourPackagesList.mapNotNull {
+            try {
+                RevokedKeyPackage.Hour(
+                    etag = it.etag,
+                    region = LocationCode(it.region),
+                    day = LocalDate.parse(it.date, DAY_FORMATTER),
+                    hour = LocalTime.parse("${it.hour}", HOUR_FORMATTER)
+                )
+            } catch (e: Exception) {
+                Timber.e(e, "Failed to parse revoked hour metadata: %s", it)
+                null
+            }
+        }
+
+    data class KeyDownloadConfigContainer(
+        override val individualDownloadTimeout: Duration,
+        override val overallDownloadTimeout: Duration,
+        override val revokedDayPackages: Collection<KeyDownloadConfig.RevokedKeyPackage.Day>,
+        override val revokedHourPackages: Collection<KeyDownloadConfig.RevokedKeyPackage.Hour>
+    ) : KeyDownloadConfig
+
+    companion object {
+        private val DAY_FORMATTER = DateTimeFormat.forPattern("yyyy-MM-dd")
+        private val HOUR_FORMATTER = DateTimeFormat.forPattern("H")
+    }
+}
+
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+internal sealed class RevokedKeyPackage : KeyDownloadConfig.RevokedKeyPackage {
+
+    data class Day(
+        override val etag: String,
+        override val region: LocationCode,
+        override val day: LocalDate
+    ) : RevokedKeyPackage(), KeyDownloadConfig.RevokedKeyPackage.Day
+
+    data class Hour(
+        override val etag: String,
+        override val region: LocationCode,
+        override val day: LocalDate,
+        override val hour: LocalTime
+    ) : RevokedKeyPackage(), KeyDownloadConfig.RevokedKeyPackage.Hour
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt
index d3ae63b5936f0cb30d67f227d1640aef9603d987..31913f27d014b3340462b4271e205d07c03b9c90 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt
@@ -1,12 +1,17 @@
 package de.rki.coronawarnapp.bugreporting
 
+import de.rki.coronawarnapp.util.CWADebug
 import de.rki.coronawarnapp.util.di.AppInjector
+import timber.log.Timber
 
 interface BugReporter {
     fun report(throwable: Throwable, tag: String? = null, info: String? = null)
 }
 
 fun Throwable.reportProblem(tag: String? = null, info: String? = null) {
+    Timber.tag("BugReporter").v(this, "report(tag=$tag, info=$info)")
+
+    if (CWADebug.isAUnitTest) return
     val reporter = AppInjector.component.bugReporter
     reporter.report(this, tag, info)
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..039330a1ca998c927852a16cf9d0a0465b072503
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt
@@ -0,0 +1,44 @@
+package de.rki.coronawarnapp.deadman
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
+import de.rki.coronawarnapp.worker.BackgroundConstants
+import timber.log.Timber
+
+/**
+ * One time background deadman notification worker
+ *
+ * @see DeadmanNotificationScheduler
+ */
+class DeadmanNotificationOneTimeWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters,
+    private val sender: DeadmanNotificationSender
+) :
+    CoroutineWorker(context, workerParams) {
+
+    override suspend fun doWork(): Result {
+        Timber.d("Background job started. Run attempt: $runAttemptCount")
+
+        if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
+            Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling")
+
+            return Result.failure()
+        }
+        var result = Result.success()
+        try {
+            sender.sendNotification()
+        } catch (e: Exception) {
+            result = Result.retry()
+        }
+
+        return result
+    }
+
+    @AssistedInject.Factory
+    interface Factory : InjectedWorkerFactory<DeadmanNotificationOneTimeWorker>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..53e5d06ca010c8e45f40be61ab9219f0141d0381
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt
@@ -0,0 +1,46 @@
+package de.rki.coronawarnapp.deadman
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
+import de.rki.coronawarnapp.worker.BackgroundConstants
+import timber.log.Timber
+
+/**
+ * Periodic background deadman notification worker
+ *
+ * @see DeadmanNotificationScheduler
+ */
+class DeadmanNotificationPeriodicWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters,
+    private val scheduler: DeadmanNotificationScheduler
+) :
+    CoroutineWorker(context, workerParams) {
+
+    override suspend fun doWork(): Result {
+        Timber.d("Background job started. Run attempt: $runAttemptCount")
+
+        if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
+            Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling")
+
+            return Result.failure()
+        }
+        var result = Result.success()
+        try {
+            // Schedule one time deadman notification send work
+            scheduler.scheduleOneTime()
+        } catch (e: Exception) {
+            Timber.d(e)
+            result = Result.retry()
+        }
+
+        return result
+    }
+
+    @AssistedInject.Factory
+    interface Factory : InjectedWorkerFactory<DeadmanNotificationPeriodicWorker>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d3b1523be13e76e842a6536bedfbd18fc4e2bcca
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt
@@ -0,0 +1,59 @@
+package de.rki.coronawarnapp.deadman
+
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.WorkManager
+import dagger.Reusable
+import javax.inject.Inject
+
+@Reusable
+class DeadmanNotificationScheduler @Inject constructor(
+    val timeCalculation: DeadmanNotificationTimeCalculation,
+    val workManager: WorkManager,
+    val workBuilder: DeadmanNotificationWorkBuilder
+) {
+
+    /**
+     * Enqueue background deadman notification onetime work
+     * Replace with new if older work exists.
+     */
+    suspend fun scheduleOneTime() {
+        // Get initial delay
+        val delay = timeCalculation.getDelay()
+
+        if (delay < 0) {
+            return
+        } else {
+            // Create unique work and enqueue
+            workManager.enqueueUniqueWork(
+                ONE_TIME_WORK_NAME,
+                ExistingWorkPolicy.REPLACE,
+                workBuilder.buildOneTimeWork(delay)
+            )
+        }
+    }
+
+    /**
+     * Enqueue background deadman notification onetime work
+     * Replace with new if older work exists.
+     */
+    fun schedulePeriodic() {
+        // Create unique work and enqueue
+        workManager.enqueueUniquePeriodicWork(
+            PERIODIC_WORK_NAME,
+            ExistingPeriodicWorkPolicy.REPLACE,
+            workBuilder.buildPeriodicWork()
+        )
+    }
+
+    companion object {
+        /**
+         * Deadman notification one time work
+         */
+        const val ONE_TIME_WORK_NAME = "DeadmanNotificationOneTimeWork"
+        /**
+         * Deadman notification periodic work
+         */
+        const val PERIODIC_WORK_NAME = "DeadmanNotificationPeriodicWork"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a261454f0bcf45edc674d58e539bc284fdf58edf
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt
@@ -0,0 +1,73 @@
+package de.rki.coronawarnapp.deadman
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
+import dagger.Reusable
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.notification.NotificationConstants
+import de.rki.coronawarnapp.ui.main.MainActivity
+import de.rki.coronawarnapp.util.ForegroundState
+import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+
+@Reusable
+class DeadmanNotificationSender @Inject constructor(
+    @AppContext private val context: Context,
+    private val foregroundState: ForegroundState,
+    private val notificationManagerCompat: NotificationManagerCompat
+) {
+
+    private val channelId =
+        context.getString(NotificationConstants.NOTIFICATION_CHANNEL_ID)
+
+    private fun createPendingIntentToMainActivity() =
+        PendingIntent.getActivity(
+            context,
+            0,
+            Intent(context, MainActivity::class.java),
+            0
+        )
+
+    private fun buildNotification(
+        title: String,
+        content: String
+    ): Notification? {
+        val builder = NotificationCompat.Builder(context,
+            channelId
+        )
+            .setSmallIcon(NotificationConstants.NOTIFICATION_SMALL_ICON)
+            .setPriority(NotificationCompat.PRIORITY_MAX)
+            .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+            .setContentIntent(createPendingIntentToMainActivity())
+            .setAutoCancel(true)
+            .setContentTitle(title)
+            .setContentText(content)
+
+        return builder.build()
+    }
+
+    suspend fun sendNotification() {
+        if (foregroundState.isInForeground.first()) {
+            return
+        }
+        val title = context.getString(R.string.risk_details_deadman_notification_title)
+        val content = context.getString(R.string.risk_details_deadman_notification_body)
+        val notification =
+            buildNotification(title, content) ?: return
+        with(notificationManagerCompat) {
+            notify(DEADMAN_NOTIFICATION_ID, notification)
+        }
+    }
+
+    companion object {
+        /**
+         * Deadman notification id
+         */
+        const val DEADMAN_NOTIFICATION_ID = 3
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt
new file mode 100644
index 0000000000000000000000000000000000000000..48e6d3a8eba598fb4121e761684c7595230f544b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt
@@ -0,0 +1,45 @@
+package de.rki.coronawarnapp.deadman
+
+import dagger.Reusable
+import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.util.TimeStamper
+import kotlinx.coroutines.flow.first
+import org.joda.time.DateTimeConstants
+import org.joda.time.Hours
+import org.joda.time.Instant
+import javax.inject.Inject
+
+@Reusable
+class DeadmanNotificationTimeCalculation @Inject constructor(
+    val timeStamper: TimeStamper,
+    val enfClient: ENFClient
+) {
+
+    /**
+     * Calculate initial delay in minutes for deadman notification
+     */
+    fun getHoursDiff(lastSuccess: Instant): Int {
+        val hoursDiff = Hours.hoursBetween(lastSuccess, timeStamper.nowUTC)
+        return (DEADMAN_NOTIFICATION_DELAY - hoursDiff.hours) * DateTimeConstants.MINUTES_PER_HOUR
+    }
+
+    /**
+     * Get initial delay in minutes for deadman notification
+     * If last success date time is null (eg: on application first start) - return [DEADMAN_NOTIFICATION_DELAY]
+     */
+    suspend fun getDelay(): Long {
+        val lastSuccess = enfClient.lastSuccessfulTrackedExposureDetection().first()?.finishedAt
+        return if (lastSuccess != null) {
+            getHoursDiff(lastSuccess).toLong()
+        } else {
+            (DEADMAN_NOTIFICATION_DELAY * DateTimeConstants.MINUTES_PER_HOUR).toLong()
+        }
+    }
+
+    companion object {
+        /**
+         * Deadman notification background job delay set to 36 hours
+         */
+        const val DEADMAN_NOTIFICATION_DELAY = 36
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c574f5d761c3d8a613dc25ad5f235ef29ea9c2ea
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt
@@ -0,0 +1,43 @@
+package de.rki.coronawarnapp.deadman
+
+import androidx.work.BackoffPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.OneTimeWorkRequestBuilder
+import androidx.work.PeriodicWorkRequest
+import androidx.work.PeriodicWorkRequestBuilder
+import dagger.Reusable
+import de.rki.coronawarnapp.worker.BackgroundConstants
+import org.joda.time.DateTimeConstants
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+@Reusable
+class DeadmanNotificationWorkBuilder @Inject constructor() {
+
+    fun buildOneTimeWork(delay: Long): OneTimeWorkRequest =
+        OneTimeWorkRequestBuilder<DeadmanNotificationOneTimeWorker>()
+            .setInitialDelay(
+                delay,
+                TimeUnit.MINUTES
+            )
+            .setBackoffCriteria(
+                BackoffPolicy.EXPONENTIAL,
+                BackgroundConstants.BACKOFF_INITIAL_DELAY,
+                TimeUnit.MINUTES
+            )
+            .build()
+
+    fun buildPeriodicWork(): PeriodicWorkRequest = PeriodicWorkRequestBuilder<DeadmanNotificationPeriodicWorker>(
+        DateTimeConstants.MINUTES_PER_HOUR.toLong(), TimeUnit.MINUTES
+    )
+        .setInitialDelay(
+            BackgroundConstants.KIND_DELAY,
+            TimeUnit.MINUTES
+        )
+        .setBackoffCriteria(
+            BackoffPolicy.EXPONENTIAL,
+            BackgroundConstants.BACKOFF_INITIAL_DELAY,
+            TimeUnit.MINUTES
+        )
+        .build()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a93bbcab0bfd6e6fc60c12d57a3bf1c9a34c63dd
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt
@@ -0,0 +1,109 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.storage.DeviceStorage
+import timber.log.Timber
+
+open class BaseKeyPackageSyncTool(
+    private val keyCache: KeyCacheRepository,
+    private val deviceStorage: DeviceStorage,
+    private val tag: String
+) {
+
+    /**
+     * Returns true if any of our cached keys were revoked
+     */
+    internal suspend fun revokeCachedKeys(
+        revokedKeyPackages: Collection<KeyDownloadConfig.RevokedKeyPackage>
+    ): Boolean {
+        if (revokedKeyPackages.isEmpty()) {
+            Timber.tag(tag).d("No revoked key packages to delete.")
+            return false
+        }
+
+        val badEtags = revokedKeyPackages.map { it.etag }
+        val toDelete = keyCache.getAllCachedKeys().filter { badEtags.contains(it.info.etag) }
+
+        return if (toDelete.isEmpty()) {
+            Timber.tag(tag).d("No local cached keys matched the revoked ones.")
+            false
+        } else {
+            Timber.tag(tag).w("Deleting revoked cached keys: %s", toDelete.joinToString("\n"))
+            keyCache.delete(toDelete.map { it.info })
+            true
+        }
+    }
+
+    internal suspend fun requireStorageSpace(data: List<LocationData>): DeviceStorage.CheckResult {
+        val requiredBytes = data.fold(0L) { acc, item ->
+            acc + item.approximateSizeInBytes
+        }
+        Timber.tag(tag).d("%dB are required for %s", requiredBytes, data)
+        return deviceStorage.requireSpacePrivateStorage(requiredBytes).also {
+            Timber.tag(tag).d("Storage check result: %s", it)
+        }
+    }
+
+    // All cached files that are no longer on the server are considered stale
+    internal fun List<CachedKey>.findStaleData(
+        availableData: List<LocationData>
+    ): List<CachedKey> = filter { (cachedKey, _) ->
+        // Is there a day on the server that matches our cached keys day?
+        val serverHasMatchingDay = availableData
+            .mapNotNull { it as? LocationDays }
+            .any { it.dayData.contains(cachedKey.day) }
+
+        when {
+            cachedKey.type == CachedKeyInfo.Type.LOCATION_DAY -> {
+                // If there is no matching day on the server, our cached key is stale
+                return@filter !serverHasMatchingDay
+            }
+            cachedKey.type == CachedKeyInfo.Type.LOCATION_HOUR && serverHasMatchingDay -> {
+                // A cached hour for which a server day exists, means we don't need the hour anymore
+                // If there is no match, then we can't decide yet, and need to check the server for hours
+                return@filter true // Stale
+            }
+        }
+
+        // Is there an hour on the server that matches our cached hour?
+        val serverHasMatchingHour = availableData
+            .mapNotNull { it as? LocationHours }
+            .any { serverHours ->
+                serverHours.hourData.any { (day, hours) ->
+                    cachedKey.day == day && hours.contains(cachedKey.hour)
+                }
+            }
+
+        if (serverHasMatchingHour) {
+            // Our hour is still on the server
+            return@filter false // Not stale
+        }
+
+        // If we couldn't find match against the server data, our cache entry is probably stale
+        return@filter true
+    }
+
+    internal suspend fun getDownloadedCachedKeys(
+        location: LocationCode,
+        type: CachedKeyInfo.Type
+    ): List<CachedKey> = keyCache.getEntriesForType(type)
+        .filter { it.info.location == location }
+        .filter { key ->
+            val complete = key.info.isDownloadComplete
+            val exists = key.path.exists()
+            if (complete && !exists) {
+                Timber.tag(tag).v("Incomplete download, will overwrite: %s", key)
+            }
+            // We overwrite not completed ones
+            complete && exists
+        }
+
+    data class SyncResult(
+        val successful: Boolean = true,
+        val newPackages: List<CachedKey> = emptyList()
+    )
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9474d942db8ec51dbc6d405e493886940d2bde59
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt
@@ -0,0 +1,144 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import androidx.annotation.VisibleForTesting
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.storage.DeviceStorage
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDate
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.withContext
+import org.joda.time.LocalDate
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class DayPackageSyncTool @Inject constructor(
+    deviceStorage: DeviceStorage,
+    private val keyServer: DiagnosisKeyServer,
+    private val keyCache: KeyCacheRepository,
+    private val downloadTool: KeyDownloadTool,
+    private val timeStamper: TimeStamper,
+    private val configProvider: AppConfigProvider,
+    private val dispatcherProvider: DispatcherProvider
+) : BaseKeyPackageSyncTool(
+    keyCache = keyCache,
+    deviceStorage = deviceStorage,
+    tag = TAG
+) {
+
+    internal suspend fun syncMissingDayPackages(
+        targetLocations: List<LocationCode>,
+        forceIndexLookup: Boolean
+    ): SyncResult {
+        Timber.tag(TAG).v("syncMissingDays(targetLocations=%s)", targetLocations)
+
+        val downloadConfig: KeyDownloadConfig = configProvider.getAppConfig()
+        val keysWereRevoked = revokeCachedKeys(downloadConfig.revokedDayPackages)
+
+        val missingDays = targetLocations.mapNotNull {
+            determineMissingDayPackages(it, forceIndexLookup || keysWereRevoked)
+        }
+        if (missingDays.isEmpty()) {
+            Timber.tag(TAG).i("There were no missing day packages.")
+            return SyncResult(successful = true, newPackages = emptyList())
+        }
+
+        Timber.tag(TAG).d("Downloading missing day packages: %s", missingDays)
+        requireStorageSpace(missingDays)
+
+        val downloads = launchDownloads(missingDays, downloadConfig)
+
+        Timber.tag(TAG).d("Waiting for %d missing day downloads.", downloads.size)
+        val downloadedDays = downloads.awaitAll().filterNotNull().also {
+            Timber.tag(TAG).v("Downloaded keyfile: %s", it.joinToString("\n"))
+        }
+        Timber.tag(TAG).i("Download success: ${downloadedDays.size}/${downloads.size}")
+
+        return SyncResult(
+            successful = downloads.size == downloadedDays.size,
+            newPackages = downloadedDays
+        )
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal fun expectNewDayPackages(cachedDays: List<CachedKey>): Boolean {
+        val yesterday = timeStamper.nowUTC.toLocalDate().minusDays(1)
+        val newestDay = cachedDays.map { it.info.toDateTime() }.maxOrNull()?.toLocalDate()
+
+        return yesterday != newestDay
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal suspend fun determineMissingDayPackages(
+        location: LocationCode,
+        forceIndexLookup: Boolean
+    ): LocationDays? {
+        val cachedDays = getDownloadedCachedKeys(location, Type.LOCATION_DAY)
+
+        if (!forceIndexLookup && !expectNewDayPackages(cachedDays)) {
+            Timber.tag(TAG).d("We don't expect new day packages.")
+            return null
+        }
+
+        val availableDays = LocationDays(location, keyServer.getDayIndex(location))
+
+        val staleDays = cachedDays.findStaleData(listOf(availableDays))
+
+        if (staleDays.isNotEmpty()) {
+            Timber.tag(TAG).d("Deleting stale days (loation=%s): %s", location, staleDays)
+            keyCache.delete(staleDays.map { it.info })
+        }
+
+        val nonStaleDays = cachedDays.minus(staleDays)
+
+        return availableDays.toMissingDays(nonStaleDays) // The missing days
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal suspend fun launchDownloads(
+        missingDayData: Collection<LocationDays>,
+        downloadConfig: KeyDownloadConfig
+    ): Collection<Deferred<CachedKey?>> {
+        val launcher: CoroutineScope.(LocationDays, LocalDate) -> Deferred<CachedKey?> = { locationData, targetDay ->
+            async {
+                val cachedKey = keyCache.createCacheEntry(
+                    location = locationData.location,
+                    dayIdentifier = targetDay,
+                    hourIdentifier = null,
+                    type = Type.LOCATION_DAY
+                )
+                try {
+                    downloadTool.downloadKeyFile(cachedKey, downloadConfig)
+                } catch (e: Exception) {
+                    // We can't throw otherwise it cancels the other downloads too (awaitAll)
+                    null
+                }
+            }
+        }
+        val downloads = missingDayData.flatMap { location ->
+            location.dayData.map { dayDate -> location to dayDate }
+        }
+        Timber.tag(TAG).d("Launching %d downloads.", downloads.size)
+
+        return downloads.map { (locationData, targetDay) ->
+            withContext(context = dispatcherProvider.IO) {
+                launcher(locationData, targetDay)
+            }
+        }
+    }
+
+    companion object {
+        private const val TAG = "DayPackageSyncTool"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt
index 552dc370b440abca3609f95419dc10db966cac08..870c80617d260aafb04eedc58c461b86ae003372 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt
@@ -1,26 +1,30 @@
 package de.rki.coronawarnapp.diagnosiskeys.download
 
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
 import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
 import de.rki.coronawarnapp.environment.EnvironmentSetup
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
 import de.rki.coronawarnapp.risk.RollbackItem
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.task.Task
 import de.rki.coronawarnapp.task.TaskCancellationException
 import de.rki.coronawarnapp.task.TaskFactory
+import de.rki.coronawarnapp.task.TaskFactory.Config.CollisionBehavior
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.ui.toLazyString
 import de.rki.coronawarnapp.worker.BackgroundWorkHelper
 import kotlinx.coroutines.channels.ConflatedBroadcastChannel
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.first
 import org.joda.time.DateTime
 import org.joda.time.DateTimeZone
 import org.joda.time.Duration
+import org.joda.time.Instant
 import timber.log.Timber
-import java.io.File
 import java.util.Date
 import java.util.UUID
 import javax.inject.Inject
@@ -30,7 +34,7 @@ class DownloadDiagnosisKeysTask @Inject constructor(
     private val enfClient: ENFClient,
     private val environmentSetup: EnvironmentSetup,
     private val appConfigProvider: AppConfigProvider,
-    private val keyFileDownloader: KeyFileDownloader,
+    private val keyPackageSyncTool: KeyPackageSyncTool,
     private val timeStamper: TimeStamper
 ) : Task<DownloadDiagnosisKeysTask.Progress, Task.Result> {
 
@@ -39,6 +43,7 @@ class DownloadDiagnosisKeysTask @Inject constructor(
 
     private var isCanceled = false
 
+    @Suppress("LongMethod")
     override suspend fun run(arguments: Task.Arguments): Task.Result {
         val rollbackItems = mutableListOf<RollbackItem>()
         try {
@@ -59,7 +64,7 @@ class DownloadDiagnosisKeysTask @Inject constructor(
                 return object : Task.Result {}
             }
 
-            checkCancel()
+            throwIfCancelled()
             val currentDate = Date(timeStamper.nowUTC.millis)
             Timber.tag(TAG).d("Using $currentDate as current date in task.")
 
@@ -67,21 +72,38 @@ class DownloadDiagnosisKeysTask @Inject constructor(
              * RETRIEVE TOKEN
              ****************************************************/
             val token = retrieveToken(rollbackItems)
-            checkCancel()
+            throwIfCancelled()
 
             // RETRIEVE RISK SCORE PARAMETERS
-            val exposureConfiguration = appConfigProvider.getAppConfig().exposureDetectionConfiguration
+            val exposureConfig: ExposureDetectionConfig = appConfigProvider.getAppConfig()
 
             internalProgress.send(Progress.ApiSubmissionStarted)
             internalProgress.send(Progress.KeyFilesDownloadStarted)
 
             val requestedCountries = arguments.requestedCountries
-            val availableKeyFiles = getAvailableKeyFiles(requestedCountries)
-            checkCancel()
+            val keySyncResult = getAvailableKeyFiles(requestedCountries)
+            throwIfCancelled()
 
-            val totalFileSize = availableKeyFiles.fold(0L, { acc, file ->
-                file.length() + acc
-            })
+            val trackedExposureDetections = enfClient.latestTrackedExposureDetection().first()
+            val now = timeStamper.nowUTC
+
+            if (exposureConfig.maxExposureDetectionsPerUTCDay == 0) {
+                Timber.tag(TAG).w("Exposure detections are disabled! maxExposureDetectionsPerUTCDay=0")
+                return object : Task.Result {}
+            }
+
+            if (wasLastDetectionPerformedRecently(now, exposureConfig, trackedExposureDetections)) {
+                // At most one detection every 6h
+                return object : Task.Result {}
+            }
+
+            if (hasRecentDetectionAndNoNewFiles(now, keySyncResult, trackedExposureDetections)) {
+                //  Last check was within 24h, and there are no new files.
+                return object : Task.Result {}
+            }
+
+            val availableKeyFiles = keySyncResult.availableKeys.map { it.path }
+            val totalFileSize = availableKeyFiles.fold(0L, { acc, file -> file.length() + acc })
 
             internalProgress.send(
                 Progress.KeyFilesDownloadFinished(
@@ -93,13 +115,13 @@ class DownloadDiagnosisKeysTask @Inject constructor(
             Timber.tag(TAG).d("Attempting submission to ENF")
             val isSubmissionSuccessful = enfClient.provideDiagnosisKeys(
                 keyFiles = availableKeyFiles,
-                configuration = exposureConfiguration,
+                configuration = exposureConfig.exposureDetectionConfiguration,
                 token = token
             )
             Timber.tag(TAG).d("Diagnosis Keys provided (success=%s, token=%s)", isSubmissionSuccessful, token)
 
             internalProgress.send(Progress.ApiSubmissionFinished)
-            checkCancel()
+            throwIfCancelled()
 
             if (isSubmissionSuccessful) {
                 saveTimestamp(currentDate, rollbackItems)
@@ -118,6 +140,35 @@ class DownloadDiagnosisKeysTask @Inject constructor(
         }
     }
 
+    private fun wasLastDetectionPerformedRecently(
+        now: Instant,
+        exposureConfig: ExposureDetectionConfig,
+        trackedDetections: Collection<TrackedExposureDetection>
+    ): Boolean {
+        val lastDetection = trackedDetections.maxByOrNull { it.startedAt }
+        val nextDetectionAt = lastDetection?.startedAt?.plus(exposureConfig.minTimeBetweenDetections)
+
+        return (nextDetectionAt != null && now.isBefore(nextDetectionAt)).also {
+            if (it) Timber.tag(TAG).w("Aborting. Last detection is recent: %s (now=%s)", lastDetection, now)
+        }
+    }
+
+    private fun hasRecentDetectionAndNoNewFiles(
+        now: Instant,
+        keySyncResult: KeyPackageSyncTool.Result,
+        trackedDetections: Collection<TrackedExposureDetection>
+    ): Boolean {
+        // One forced detection every 24h, ignoring the sync results
+        val lastSuccessfulDetection = trackedDetections.filter { it.isSuccessful }.maxByOrNull { it.startedAt }
+        val nextForcedDetectionAt = lastSuccessfulDetection?.startedAt?.plus(Duration.standardDays(1))
+
+        val hasRecentDetection = nextForcedDetectionAt != null && now.isBefore(nextForcedDetectionAt)
+
+        return (hasRecentDetection && keySyncResult.newKeys.isEmpty()).also {
+            if (it) Timber.tag(TAG).w("Aborting. Last detection is recent (<24h) and no new keyfiles.")
+        }
+    }
+
     private fun saveTimestamp(
         currentDate: Date,
         rollbackItems: MutableList<RollbackItem>
@@ -168,22 +219,17 @@ class DownloadDiagnosisKeysTask @Inject constructor(
         }
     }
 
-    private suspend fun getAvailableKeyFiles(requestedCountries: List<String>?): List<File> {
-        val availableKeyFiles =
-            keyFileDownloader.asyncFetchKeyFiles(if (environmentSetup.useEuropeKeyPackageFiles) {
-                listOf("EUR")
-            } else {
-                requestedCountries
-                    ?: appConfigProvider.getAppConfig().supportedCountries
-            }.map { LocationCode(it) })
-
-        if (availableKeyFiles.isEmpty()) {
-            Timber.tag(TAG).w("No keyfiles were available!")
-        }
-        return availableKeyFiles
+    private suspend fun getAvailableKeyFiles(requestedCountries: List<String>?): KeyPackageSyncTool.Result {
+        val wantedLocations = if (environmentSetup.useEuropeKeyPackageFiles) {
+            listOf("EUR")
+        } else {
+            requestedCountries ?: appConfigProvider.getAppConfig().supportedCountries
+        }.map { LocationCode(it) }
+
+        return keyPackageSyncTool.syncKeyFiles(wantedLocations)
     }
 
-    private fun checkCancel() {
+    private fun throwIfCancelled() {
         if (isCanceled) throw TaskCancellationException()
     }
 
@@ -208,19 +254,21 @@ class DownloadDiagnosisKeysTask @Inject constructor(
     ) : Task.Arguments
 
     data class Config(
-        @Suppress("MagicNumber")
         override val executionTimeout: Duration = Duration.standardMinutes(8), // TODO unit-test that not > 9 min
 
-        override val collisionBehavior: TaskFactory.Config.CollisionBehavior =
-            TaskFactory.Config.CollisionBehavior.ENQUEUE
+        override val collisionBehavior: CollisionBehavior = CollisionBehavior.SKIP_IF_SIBLING_RUNNING
 
     ) : TaskFactory.Config
 
     class Factory @Inject constructor(
-        private val taskByDagger: Provider<DownloadDiagnosisKeysTask>
+        private val taskByDagger: Provider<DownloadDiagnosisKeysTask>,
+        private val appConfigProvider: AppConfigProvider
     ) : TaskFactory<Progress, Task.Result> {
 
-        override val config: TaskFactory.Config = Config()
+        override suspend fun createConfig(): TaskFactory.Config = Config(
+            executionTimeout = appConfigProvider.getAppConfig().overallDownloadTimeout
+        )
+
         override val taskProvider: () -> Task<Progress, Task.Result> = {
             taskByDagger.get()
         }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ad63bbb134ffc3583c9fea34bc4c31e1ee2cb724
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt
@@ -0,0 +1,172 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import androidx.annotation.VisibleForTesting
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.storage.DeviceStorage
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDate
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalTime
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.withContext
+import org.joda.time.Instant
+import org.joda.time.LocalDate
+import org.joda.time.LocalTime
+import timber.log.Timber
+import java.io.IOException
+import javax.inject.Inject
+
+@Reusable
+class HourPackageSyncTool @Inject constructor(
+    deviceStorage: DeviceStorage,
+    private val keyServer: DiagnosisKeyServer,
+    private val keyCache: KeyCacheRepository,
+    private val downloadTool: KeyDownloadTool,
+    private val timeStamper: TimeStamper,
+    private val configProvider: AppConfigProvider,
+    private val dispatcherProvider: DispatcherProvider
+) : BaseKeyPackageSyncTool(
+    keyCache = keyCache,
+    deviceStorage = deviceStorage,
+    tag = TAG
+) {
+
+    internal suspend fun syncMissingHourPackages(
+        targetLocations: List<LocationCode>,
+        forceIndexLookup: Boolean
+    ): SyncResult {
+        Timber.tag(TAG).v("syncMissingHours(targetLocations=%s)", targetLocations)
+
+        val downloadConfig: KeyDownloadConfig = configProvider.getAppConfig()
+        val keysWereRevoked = revokeCachedKeys(downloadConfig.revokedHourPackages)
+
+        val missingHours = targetLocations.mapNotNull {
+            determineMissingHours(it, forceIndexLookup || keysWereRevoked)
+        }
+        if (missingHours.isEmpty()) {
+            Timber.tag(TAG).i("There were no missing hours.")
+            return SyncResult(successful = true, newPackages = emptyList())
+        }
+
+        Timber.tag(TAG).d("Downloading missing hours: %s", missingHours)
+        requireStorageSpace(missingHours)
+
+        val hourDownloads = launchDownloads(missingHours, downloadConfig)
+
+        Timber.tag(TAG).d("Waiting for %d missing hour downloads.", hourDownloads.size)
+        val downloadedHours = hourDownloads.awaitAll().filterNotNull().also {
+            Timber.tag(TAG).v("Downloaded keyfile: %s", it.joinToString("\n"))
+        }
+        Timber.tag(TAG).i("Download success: ${downloadedHours.size}/${hourDownloads.size}")
+
+        return SyncResult(
+            successful = hourDownloads.size == downloadedHours.size,
+            newPackages = downloadedHours
+        )
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal suspend fun launchDownloads(
+        missingHours: Collection<LocationHours>,
+        downloadConfig: KeyDownloadConfig
+    ): Collection<Deferred<CachedKey?>> {
+        val launcher: CoroutineScope.(LocationHours, LocalDate, LocalTime) -> Deferred<CachedKey?> =
+            { locationData, targetDay, targetHour ->
+                async {
+                    val cachedKey = keyCache.createCacheEntry(
+                        location = locationData.location,
+                        dayIdentifier = targetDay,
+                        hourIdentifier = targetHour,
+                        type = Type.LOCATION_HOUR
+                    )
+
+                    try {
+                        downloadTool.downloadKeyFile(cachedKey, downloadConfig)
+                    } catch (e: Exception) {
+                        // We can't throw otherwise it cancels the other downloads too (awaitAll)
+                        null
+                    }
+                }
+            }
+
+        val downloads = missingHours
+            .flatMap { location ->
+                location.hourData.map { Triple(location, it.key, it.value) }
+            }
+            .flatMap { (location, day, hours) ->
+                hours.map { Triple(location, day, it) }
+            }
+        Timber.tag(TAG).d("Launching %d downloads.", downloads.size)
+
+        return downloads.map { (location, day, missingHour) ->
+            withContext(context = dispatcherProvider.IO) {
+                launcher(location, day, missingHour)
+            }
+        }
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal fun expectNewHourPackages(cachedHours: List<CachedKey>, now: Instant): Boolean {
+        val previousHour = now.toLocalTime().minusHours(1)
+        val newestHour = cachedHours.map { it.info.toDateTime() }.maxOrNull()?.toLocalTime()
+
+        return previousHour.hourOfDay != newestHour?.hourOfDay
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal suspend fun determineMissingHours(
+        location: LocationCode,
+        forceIndexLookup: Boolean
+    ): LocationHours? {
+        val cachedHours = getDownloadedCachedKeys(location, Type.LOCATION_HOUR)
+
+        val now = timeStamper.nowUTC
+
+        if (!forceIndexLookup && !expectNewHourPackages(cachedHours, now)) {
+            Timber.tag(TAG).d("We don't expect new hour packages.")
+            return null
+        }
+
+        val today = now.toLocalDate()
+
+        val availableHours = run {
+            val hoursToday = try {
+                keyServer.getHourIndex(location, today)
+            } catch (e: IOException) {
+                Timber.tag(TAG).e(e, "failed to get today's hour index.")
+                emptyList()
+            }
+            LocationHours(location, mapOf(today to hoursToday))
+        }
+
+        // If we have hours in covered by a day, delete the hours
+        val cachedDays = getDownloadedCachedKeys(location, Type.LOCATION_DAY).map {
+            it.info.day
+        }.let { LocationDays(location, it) }
+
+        val staleHours = cachedHours.findStaleData(listOf(cachedDays, availableHours))
+
+        if (staleHours.isNotEmpty()) {
+            Timber.tag(TAG).v("Deleting stale hours: %s", staleHours)
+            keyCache.delete(staleHours.map { it.info })
+        }
+
+        val nonStaleHours = cachedHours.minus(staleHours)
+
+        return availableHours.toMissingHours(nonStaleHours) // The missing hours
+    }
+
+    companion object {
+        private const val TAG = "HourPackageSyncTool"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadTool.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c182eef184c624c3a18dc416b9469101730251e0
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadTool.kt
@@ -0,0 +1,68 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer
+import de.rki.coronawarnapp.diagnosiskeys.server.DownloadInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration
+import kotlinx.coroutines.withTimeout
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class KeyDownloadTool @Inject constructor(
+    private val legacyKeyCache: LegacyKeyCacheMigration,
+    private val keyServer: DiagnosisKeyServer,
+    private val keyCache: KeyCacheRepository
+) {
+    suspend fun downloadKeyFile(
+        cachedKey: CachedKey,
+        downloadConfig: KeyDownloadConfig
+    ): CachedKey = try {
+        val saveTo = cachedKey.path
+        val keyInfo = cachedKey.info
+
+        val preconditionHook: suspend (DownloadInfo) -> Boolean =
+            { downloadInfo ->
+                /**
+                 * To try legacy migration, we attempt to the etag as checksum.
+                 * Removing the quotes, the etag can represent the file's MD5 checksum.
+                 */
+                val etagAsChecksum = downloadInfo.etag?.removePrefix("\"")?.removeSuffix("\"")
+                val continueDownload = !legacyKeyCache.tryMigration(etagAsChecksum, saveTo)
+                continueDownload // Continue download if no migration happened
+            }
+
+        val downloadInfo = withTimeout(downloadConfig.individualDownloadTimeout.millis) {
+            keyServer.downloadKeyFile(
+                locationCode = keyInfo.location,
+                day = keyInfo.day,
+                hour = keyInfo.hour,
+                saveTo = saveTo,
+                precondition = preconditionHook
+            )
+        }
+        Timber.tag(TAG).v("Download finished: %s -> %s", cachedKey, saveTo)
+
+        /**
+         * If for some reason the server doesn't supply the etag, let's make our own.
+         * If it later gets used, it will not match.
+         * Worst case, we delete it and download the same file again,
+         * hopefully then with an etag in the header.
+         */
+        val etag = requireNotNull(downloadInfo.etag) { "Server provided no ETAG!" }
+        keyCache.markKeyComplete(keyInfo, etag)
+
+        cachedKey
+    } catch (e: Exception) {
+        Timber.tag(TAG).e(e, "Download failed: %s", cachedKey)
+        keyCache.delete(listOf(cachedKey.info))
+        throw e
+    }
+
+    companion object {
+        private const val TAG = "${KeyPackageSyncTool.TAG}:DownloadTool"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloader.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloader.kt
deleted file mode 100644
index 60f6aca3482890c3a3148a2845f130293923d62e..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloader.kt
+++ /dev/null
@@ -1,348 +0,0 @@
-package de.rki.coronawarnapp.diagnosiskeys.download
-
-import dagger.Reusable
-import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer
-import de.rki.coronawarnapp.diagnosiskeys.server.DownloadInfo
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
-import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
-import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration
-import de.rki.coronawarnapp.risk.TimeVariables
-import de.rki.coronawarnapp.storage.DeviceStorage
-import de.rki.coronawarnapp.storage.TestSettings
-import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.withContext
-import org.joda.time.LocalTime
-import timber.log.Timber
-import java.io.File
-import javax.inject.Inject
-
-/**
- * Downloads new or missing key files from the CDN
- */
-@Reusable
-class KeyFileDownloader @Inject constructor(
-    private val deviceStorage: DeviceStorage,
-    private val keyServer: DiagnosisKeyServer,
-    private val keyCache: KeyCacheRepository,
-    private val legacyKeyCache: LegacyKeyCacheMigration,
-    private val testSettings: TestSettings,
-    private val dispatcherProvider: DispatcherProvider
-) {
-
-    private suspend fun requireStorageSpace(data: List<CountryData>): DeviceStorage.CheckResult {
-        val requiredBytes = data.fold(0L) { acc, item ->
-            acc + item.approximateSizeInBytes
-        }
-        Timber.d("%dB are required for %s", requiredBytes, data)
-        return deviceStorage.requireSpacePrivateStorage(requiredBytes).also {
-            Timber.tag(TAG).d("Storage check result: %s", it)
-        }
-    }
-
-    private suspend fun getCompletedKeyFiles(type: CachedKeyInfo.Type): List<CachedKeyInfo> {
-        return keyCache
-            .getEntriesForType(type)
-            .filter { (keyInfo, file) ->
-                val complete = keyInfo.isDownloadComplete
-                val exists = file.exists()
-                if (complete && !exists) {
-                    Timber.tag(TAG).v("Incomplete download, will overwrite: %s", keyInfo)
-                }
-                // We overwrite not completed ones
-                complete && exists
-            }
-            .map { it.first }
-    }
-
-    /**
-     * Fetches all necessary Files from the Cached KeyFile Entries out of the [KeyCacheRepository] and
-     * adds to that all open Files currently available from the Server.
-     *
-     * Assumptions made about the implementation:
-     * - the app initializes with an empty cache and draws in every available data set in the beginning
-     * - the difference can only work properly if the date from the device is synchronized through the net
-     * - the difference in timezone is taken into account by using UTC in the Conversion from the Date to Server format
-     *
-     * @return list of all files from both the cache and the diff query
-     */
-    suspend fun asyncFetchKeyFiles(wantedCountries: List<LocationCode>): List<File> =
-        withContext(dispatcherProvider.IO) {
-            val availableCountries = keyServer.getCountryIndex()
-            val filteredCountries = availableCountries.filter { wantedCountries.contains(it) }
-            Timber.tag(TAG).v(
-                "Available=%s; Wanted=%s; Intersect=%s",
-                availableCountries, wantedCountries, filteredCountries
-            )
-
-            val availableKeys =
-                if (testSettings.isHourKeyPkgMode) {
-                    syncMissing3Hours(filteredCountries, DEBUG_HOUR_LIMIT)
-                    keyCache.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR)
-                } else {
-                    syncMissingDays(filteredCountries)
-                    keyCache.getEntriesForType(CachedKeyInfo.Type.COUNTRY_DAY)
-                }
-
-            return@withContext availableKeys
-                .filter { it.first.isDownloadComplete && it.second.exists() }
-                .mapNotNull { (keyInfo, path) ->
-                    if (!path.exists()) {
-                        Timber.tag(TAG).w("Missing keyfile for : %s", keyInfo)
-                        null
-                    } else {
-                        Timber.tag(TAG).v("Providing available key: %s", keyInfo)
-                        path
-                    }
-                }
-                .also { Timber.tag(TAG).d("Returning %d available keyfiles", it.size) }
-        }
-
-    private suspend fun determineMissingDays(availableCountries: List<LocationCode>): List<CountryDays> {
-        val availableDays = availableCountries.map {
-            val days = keyServer.getDayIndex(it)
-            CountryDays(it, days)
-        }
-
-        val cachedDays = getCompletedKeyFiles(CachedKeyInfo.Type.COUNTRY_DAY)
-
-        val staleDays = getStale(cachedDays, availableDays)
-
-        if (staleDays.isNotEmpty()) {
-            Timber.tag(TAG).v("Deleting stale days: %s", staleDays)
-            keyCache.delete(staleDays)
-        }
-
-        val nonStaleDays = cachedDays.minus(staleDays)
-
-        // The missing days
-        return availableDays.mapNotNull { it.toMissingDays(nonStaleDays) }
-    }
-
-    /**
-     * Fetches files given by serverDates by respecting countries
-     * @param availableCountries pair of dates per country code
-     */
-    private suspend fun syncMissingDays(
-        availableCountries: List<LocationCode>
-    ) = withContext(dispatcherProvider.IO) {
-        val countriesWithMissingDays = determineMissingDays(availableCountries)
-
-        requireStorageSpace(countriesWithMissingDays)
-
-        Timber.tag(TAG).d("Downloading missing days: %s", countriesWithMissingDays)
-        val batchDownloadStart = System.currentTimeMillis()
-        val dayDownloads = countriesWithMissingDays
-            .flatMap { country ->
-                country.dayData.map { dayDate -> country to dayDate }
-            }
-            .map { (countryWrapper, dayDate) ->
-                async {
-                    val (keyInfo, path) = keyCache.createCacheEntry(
-                        location = countryWrapper.country,
-                        dayIdentifier = dayDate,
-                        hourIdentifier = null,
-                        type = CachedKeyInfo.Type.COUNTRY_DAY
-                    )
-
-                    return@async downloadKeyFile(keyInfo, path)
-                }
-            }
-
-        Timber.tag(TAG).d("Waiting for %d missing day downloads.", dayDownloads.size)
-        // execute the query plan
-        val downloadedDays = dayDownloads.awaitAll().filterNotNull()
-
-        Timber.tag(TAG).d(
-            "Batch download (%d files) finished in %dms",
-            dayDownloads.size,
-            (System.currentTimeMillis() - batchDownloadStart)
-        )
-
-        downloadedDays.map { (keyInfo, path) ->
-            Timber.tag(TAG).v("Downloaded keyfile: %s to %s", keyInfo, path)
-            path
-        }
-
-        return@withContext
-    }
-
-    private suspend fun determineMissingHours(
-        availableCountries: List<LocationCode>,
-        itemLimit: Int
-    ): List<CountryHours> {
-        val availableHours = availableCountries.flatMap { location ->
-            var remainingItems = itemLimit
-            // Descending because we go backwards newest -> oldest
-            val indexWithToday = keyServer.getDayIndex(location).let {
-                val lastDayInIndex = it.maxOrNull()
-                Timber.tag(TAG).v("Last day in index: %s", lastDayInIndex)
-                if (lastDayInIndex != null) {
-                    it.plus(lastDayInIndex.plusDays(1))
-                } else {
-                    it
-                }
-            }
-            Timber.tag(TAG).v("Day index with (fake) today entry: %s", indexWithToday)
-
-            indexWithToday.sortedDescending().mapNotNull { day ->
-                // Limit reached, return null (filtered out) instead of new CountryHours object
-                if (remainingItems <= 0) return@mapNotNull null
-
-                val hoursForDate = mutableListOf<LocalTime>()
-                for (hour in keyServer.getHourIndex(location, day).sortedDescending()) {
-                    if (remainingItems <= 0) break
-                    remainingItems--
-                    hoursForDate.add(hour)
-                }
-
-                CountryHours(location, mapOf(day to hoursForDate))
-            }
-        }
-
-        val cachedHours = getCompletedKeyFiles(CachedKeyInfo.Type.COUNTRY_HOUR)
-
-        val staleHours = getStale(cachedHours, availableHours)
-
-        if (staleHours.isNotEmpty()) {
-            Timber.tag(TAG).v("Deleting stale hours: %s", staleHours)
-            keyCache.delete(staleHours)
-        }
-
-        val nonStaleHours = cachedHours.minus(staleHours)
-
-        // The missing hours
-        return availableHours.mapNotNull { it.toMissingHours(nonStaleHours) }
-    }
-
-    // All cached files that are no longer on the server are considered stale
-    private fun getStale(
-        cachedKeys: List<CachedKeyInfo>,
-        availableData: List<CountryData>
-    ): List<CachedKeyInfo> = cachedKeys.filter { cachedKey ->
-        val availableCountry = availableData
-            .filter { it.country == cachedKey.location }
-            .singleOrNull {
-                when (cachedKey.type) {
-                    CachedKeyInfo.Type.COUNTRY_DAY -> true
-                    CachedKeyInfo.Type.COUNTRY_HOUR -> {
-                        it as CountryHours
-                        it.hourData.containsKey(cachedKey.day)
-                    }
-                }
-            }
-        if (availableCountry == null) {
-            Timber.w("Unknown location %s, assuming stale hour.", cachedKey.location)
-            return@filter true // It's stale
-        }
-
-        when (cachedKey.type) {
-            CachedKeyInfo.Type.COUNTRY_DAY -> {
-                availableCountry as CountryDays
-                availableCountry.dayData.none { date ->
-                    cachedKey.day == date
-                }
-            }
-            CachedKeyInfo.Type.COUNTRY_HOUR -> {
-                availableCountry as CountryHours
-                val availableDay = availableCountry.hourData[cachedKey.day]
-                if (availableDay == null) {
-                    Timber.d("Unknown day %s, assuming stale hour.", cachedKey.location)
-                    return@filter true // It's stale
-                }
-
-                availableDay.none { time ->
-                    cachedKey.hour == time
-                }
-            }
-        }
-    }
-
-    /**
-     * Fetches files given by serverDates by respecting countries
-     * @param availableCountries pair of dates per country code
-     * @param hourItemLimit how many hours to go back
-     */
-    private suspend fun syncMissing3Hours(
-        availableCountries: List<LocationCode>,
-        hourItemLimit: Int
-    ) = withContext(dispatcherProvider.IO) {
-        Timber.tag(TAG).v(
-            "asyncHandleLast3HoursFilesFetch(availableCountries=%s, hourLimit=%d)",
-            availableCountries, hourItemLimit
-        )
-        val missingHours = determineMissingHours(availableCountries, hourItemLimit)
-        Timber.tag(TAG).d("Downloading missing hours: %s", missingHours)
-
-        requireStorageSpace(missingHours)
-
-        val hourDownloads = missingHours.flatMap { country ->
-            country.hourData.flatMap { (day, missingHours) ->
-                missingHours.map { missingHour ->
-                    async {
-                        val (keyInfo, path) = keyCache.createCacheEntry(
-                            location = country.country,
-                            dayIdentifier = day,
-                            hourIdentifier = missingHour,
-                            type = CachedKeyInfo.Type.COUNTRY_HOUR
-                        )
-
-                        return@async downloadKeyFile(keyInfo, path)
-                    }
-                }
-            }
-        }
-
-        Timber.tag(TAG).d("Waiting for %d missing hour downloads.", hourDownloads.size)
-        val downloadedHours = hourDownloads.awaitAll().filterNotNull()
-
-        downloadedHours.map { (keyInfo, path) ->
-            Timber.tag(TAG).d("Downloaded keyfile: %s to %s", keyInfo, path)
-            path
-        }
-
-        return@withContext
-    }
-
-    private suspend fun downloadKeyFile(
-        keyInfo: CachedKeyInfo,
-        saveTo: File
-    ): Pair<CachedKeyInfo, File>? = try {
-        val preconditionHook: suspend (DownloadInfo) -> Boolean =
-            { downloadInfo ->
-                val continueDownload = !legacyKeyCache.tryMigration(
-                    downloadInfo.serverMD5, saveTo
-                )
-                continueDownload // Continue download if no migration happened
-            }
-
-        val dlInfo = keyServer.downloadKeyFile(
-            locationCode = keyInfo.location,
-            day = keyInfo.day,
-            hour = keyInfo.hour,
-            saveTo = saveTo,
-            precondition = preconditionHook
-        )
-
-        Timber.tag(TAG).v("Dowwnload finished: %s -> %s", keyInfo, saveTo)
-
-        keyCache.markKeyComplete(keyInfo, dlInfo.serverMD5 ?: dlInfo.localMD5!!)
-        keyInfo to saveTo
-    } catch (e: Exception) {
-        Timber.tag(TAG).e(e, "Download failed: %s", keyInfo)
-        keyCache.delete(listOf(keyInfo))
-        null
-    }
-
-    companion object {
-        private val TAG: String? = KeyFileDownloader::class.simpleName
-        private const val DEBUG_HOUR_LIMIT = 24
-
-        // Daymode: ~512KB per day, ~14 days
-        // Hourmode: ~20KB per hour, 24 hours, also ~512KB
-        private val EXPECTED_STORAGE_PER_COUNTRY =
-            TimeVariables.getDefaultRetentionPeriodInDays() * 512 * 1024L
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncSettings.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncSettings.kt
new file mode 100644
index 0000000000000000000000000000000000000000..46499c31230bf37e06c79f5c124a5e02f9f61215
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncSettings.kt
@@ -0,0 +1,41 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import android.content.Context
+import com.google.gson.Gson
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.preferences.FlowPreference
+import de.rki.coronawarnapp.util.serialization.BaseGson
+import org.joda.time.Instant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class KeyPackageSyncSettings @Inject constructor(
+    @AppContext private val context: Context,
+    @BaseGson private val gson: Gson
+) {
+
+    private val prefs by lazy {
+        context.getSharedPreferences("keysync_localdata", Context.MODE_PRIVATE)
+    }
+
+    val lastDownloadDays = FlowPreference(
+        preferences = prefs,
+        key = "download.last.days",
+        reader = FlowPreference.gsonReader<LastDownload?>(gson, null),
+        writer = FlowPreference.gsonWriter(gson)
+    )
+    val lastDownloadHours = FlowPreference(
+        preferences = prefs,
+        key = "download.last.hours",
+        reader = FlowPreference.gsonReader<LastDownload?>(gson, null),
+        writer = FlowPreference.gsonWriter(gson)
+    )
+
+    data class LastDownload(
+        val startedAt: Instant,
+        val finishedAt: Instant? = null,
+        val successful: Boolean = false,
+        val newData: Boolean = false
+    )
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncTool.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9c8189c90e5015085a0b360b8d23999f8e662cd6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncTool.kt
@@ -0,0 +1,140 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import dagger.Reusable
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.network.NetworkStateProvider
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class KeyPackageSyncTool @Inject constructor(
+    private val keyCache: KeyCacheRepository,
+    private val dayPackageSyncTool: DayPackageSyncTool,
+    private val hourPackageSyncTool: HourPackageSyncTool,
+    private val syncSettings: KeyPackageSyncSettings,
+    private val timeStamper: TimeStamper,
+    private val networkStateProvider: NetworkStateProvider
+) {
+
+    suspend fun syncKeyFiles(
+        wantedLocations: List<LocationCode> = listOf(LocationCode("EUR"))
+    ): Result {
+        cleanUpStaleLocation(wantedLocations)
+
+        val daySyncResult = runDaySync(wantedLocations)
+
+        val isMeteredConnection = networkStateProvider.networkState.first().isMeteredConnection
+
+        val hourSyncResult = if (!isMeteredConnection) {
+            Timber.tag(TAG).d("Running hour sync...")
+            runHourSync(wantedLocations)
+        } else {
+            Timber.tag(TAG).d("Hour sync skipped, we are on a metered connection.")
+            null
+        }
+
+        val availableKeys = keyCache.getAllCachedKeys()
+            .filter { it.info.isDownloadComplete }
+            .filter { (keyInfo, path) ->
+                path.exists().also {
+                    if (!it) Timber.tag(TAG).w("Missing keyfile for : %s", keyInfo)
+                }
+            }
+            .also { Timber.tag(TAG).i("Returning %d available keyfiles", it.size) }
+            .also { Timber.tag(TAG).d("Available keyfiles: %s", it.joinToString("\n")) }
+
+        val newKeys = mutableListOf<CachedKey>()
+        newKeys.addAll(daySyncResult.newPackages)
+        hourSyncResult?.let { newKeys.addAll(it.newPackages) }
+
+        return Result(
+            availableKeys = availableKeys,
+            newKeys = newKeys,
+            wasDaySyncSucccessful = daySyncResult.successful
+        )
+    }
+
+    private suspend fun cleanUpStaleLocation(acceptedLocations: List<LocationCode>) {
+        Timber.tag(TAG).d("Checking for stale location, acceptable is: %s", acceptedLocations)
+
+        val staleLocationData = keyCache.getAllCachedKeys()
+            .map { it.info }
+            .filter { !acceptedLocations.contains(it.location) }
+        if (staleLocationData.isNotEmpty()) {
+            Timber.tag(TAG).i("Deleting stale location data: %s", staleLocationData.joinToString("\n"))
+            keyCache.delete(staleLocationData)
+        } else {
+            Timber.tag(TAG).d("No stale location data exists.")
+        }
+    }
+
+    private suspend fun runDaySync(locations: List<LocationCode>): BaseKeyPackageSyncTool.SyncResult {
+        val lastDownload = syncSettings.lastDownloadDays.value
+        Timber.tag(TAG).d("Synchronizing available days (lastDownload=%s).", lastDownload)
+
+        syncSettings.lastDownloadDays.update {
+            KeyPackageSyncSettings.LastDownload(startedAt = timeStamper.nowUTC)
+        }
+
+        val syncResult = dayPackageSyncTool.syncMissingDayPackages(
+            targetLocations = locations,
+            forceIndexLookup = lastDownload == null || !lastDownload.successful
+        )
+
+        syncSettings.lastDownloadDays.update {
+            if (it == null) {
+                Timber.tag(TAG).e("lastDownloadDays is missing a download start!?")
+                null
+            } else {
+                it.copy(finishedAt = timeStamper.nowUTC, successful = syncResult.successful)
+            }
+        }
+
+        return syncResult.also {
+            Timber.tag(TAG).d("runDaySync(locations=%s): syncResult=%s", locations, it)
+        }
+    }
+
+    private suspend fun runHourSync(locations: List<LocationCode>): BaseKeyPackageSyncTool.SyncResult {
+        val lastDownload = syncSettings.lastDownloadHours.value
+        Timber.tag(TAG).d("Synchronizing available hours (lastDownload=%s).", lastDownload)
+
+        syncSettings.lastDownloadHours.update {
+            KeyPackageSyncSettings.LastDownload(
+                startedAt = timeStamper.nowUTC
+            )
+        }
+
+        val syncResult = hourPackageSyncTool.syncMissingHourPackages(
+            targetLocations = locations,
+            forceIndexLookup = lastDownload == null || !lastDownload.successful
+        )
+
+        syncSettings.lastDownloadHours.update {
+            if (it == null) {
+                Timber.tag(TAG).e("lastDownloadHours is missing a download start!?")
+                null
+            } else {
+                it.copy(finishedAt = timeStamper.nowUTC, successful = syncResult.successful)
+            }
+        }
+
+        return syncResult.also {
+            Timber.tag(TAG).d("runHourSync(locations=%s): syncResult=%s", locations, it)
+        }
+    }
+
+    data class Result(
+        val availableKeys: Collection<CachedKey>,
+        val newKeys: Collection<CachedKey>,
+        val wasDaySyncSucccessful: Boolean
+    )
+
+    companion object {
+        internal const val TAG = "KeySync"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/LocationData.kt
similarity index 58%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryData.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/LocationData.kt
index 6f58466cae1dd86d5200f342fb2793e6b31d7c4b..4eec1836296edc8c5dff6a5fe478ced80ec30a4d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryData.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/LocationData.kt
@@ -1,21 +1,21 @@
 package de.rki.coronawarnapp.diagnosiskeys.download
 
 import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
 import org.joda.time.LocalDate
 import org.joda.time.LocalTime
 
-sealed class CountryData {
+sealed class LocationData {
 
-    abstract val country: LocationCode
+    abstract val location: LocationCode
 
     abstract val approximateSizeInBytes: Long
 }
 
-internal data class CountryDays(
-    override val country: LocationCode,
+internal data class LocationDays(
+    override val location: LocationCode,
     val dayData: Collection<LocalDate>
-) : CountryData() {
+) : LocationData() {
 
     override val approximateSizeInBytes: Long by lazy {
         dayData.size * APPROX_DAY_SIZE
@@ -24,25 +24,25 @@ internal data class CountryDays(
     /**
      * Return a filtered list that contains all dates which are part of this wrapper, but not in the parameter.
      */
-    fun getMissingDays(cachedKeys: List<CachedKeyInfo>): Collection<LocalDate>? {
-        val cachedCountryDates = cachedKeys
-            .filter { it.location == country }
-            .map { it.day }
+    fun getMissingDays(cachedKeys: List<CachedKey>): Collection<LocalDate>? {
+        val cachedLocationDates = cachedKeys
+            .filter { it.info.location == location }
+            .map { it.info.day }
 
         return dayData.filter { date ->
-            !cachedCountryDates.contains(date)
+            !cachedLocationDates.contains(date)
         }
     }
 
     /**
-     * Create a new country object that only contains those elements,
+     * Create a new location object that only contains those elements,
      * that are part of this wrapper, but not in the cache.
      */
-    fun toMissingDays(cachedKeys: List<CachedKeyInfo>): CountryDays? {
+    fun toMissingDays(cachedKeys: List<CachedKey>): LocationDays? {
         val missingDays = this.getMissingDays(cachedKeys)
         if (missingDays == null || missingDays.isEmpty()) return null
 
-        return CountryDays(this.country, missingDays)
+        return LocationDays(this.location, missingDays)
     }
 
     companion object {
@@ -51,10 +51,10 @@ internal data class CountryDays(
     }
 }
 
-internal data class CountryHours(
-    override val country: LocationCode,
+internal data class LocationHours(
+    override val location: LocationCode,
     val hourData: Map<LocalDate, List<LocalTime>>
-) : CountryData() {
+) : LocationData() {
 
     override val approximateSizeInBytes: Long by lazy {
         hourData.values.fold(0L) { acc, hoursForDay ->
@@ -62,23 +62,23 @@ internal data class CountryHours(
         }
     }
 
-    fun getMissingHours(cachedKeys: List<CachedKeyInfo>): Map<LocalDate, List<LocalTime>>? {
+    fun getMissingHours(cachedKeys: List<CachedKey>): Map<LocalDate, List<LocalTime>>? {
         val cachedHours = cachedKeys
-            .filter { it.location == country }
+            .filter { it.info.location == location }
 
         return hourData.mapNotNull { (day, dayHours) ->
             val missingHours = dayHours.filter { hour ->
-                cachedHours.none { it.day == day && it.hour == hour }
+                cachedHours.none { it.info.day == day && it.info.hour == hour }
             }
             if (missingHours.isEmpty()) null else day to missingHours
         }.toMap()
     }
 
-    fun toMissingHours(cachedKeys: List<CachedKeyInfo>): CountryHours? {
+    fun toMissingHours(cachedKeys: List<CachedKey>): LocationHours? {
         val missingHours = this.getMissingHours(cachedKeys)
         if (missingHours == null || missingHours.isEmpty()) return null
 
-        return CountryHours(this.country, missingHours)
+        return LocationHours(this.location, missingHours)
     }
 
     companion object {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiV1.kt
index 258321c44c01b54b1c139d2c56700bc94942f2ec..575e48703aef949d33dbf0e0b187d896189bb81c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiV1.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiV1.kt
@@ -9,7 +9,7 @@ import retrofit2.http.Streaming
 interface DiagnosisKeyApiV1 {
     // TODO Let retrofit format this to CountryCode
     @GET("/version/v1/diagnosis-keys/country")
-    suspend fun getCountryIndex(): List<String>
+    suspend fun getLocationIndex(): List<String>
 
     // TODO Let retrofit format this to LocalDate
     @GET("/version/v1/diagnosis-keys/country/{country}/date")
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt
index decf192f7e4e028a4e4845efcc6bd19ea0c09d31..bc73024a624b749f486912ecdd87f28fa27ae811 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt
@@ -2,8 +2,6 @@ package de.rki.coronawarnapp.diagnosiskeys.server
 
 import dagger.Lazy
 import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry
-import de.rki.coronawarnapp.util.HashExtensions.hashToMD5
-import de.rki.coronawarnapp.util.debug.measureTimeMillisWithResult
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
 import org.joda.time.LocalDate
@@ -24,9 +22,9 @@ class DiagnosisKeyServer @Inject constructor(
     private val keyApi: DiagnosisKeyApiV1
         get() = diagnosisKeyAPI.get()
 
-    suspend fun getCountryIndex(): List<LocationCode> = withContext(Dispatchers.IO) {
+    suspend fun getLocationIndex(): List<LocationCode> = withContext(Dispatchers.IO) {
         keyApi
-            .getCountryIndex()
+            .getLocationIndex()
             .map { LocationCode(it) }
     }
 
@@ -58,7 +56,7 @@ class DiagnosisKeyServer @Inject constructor(
         precondition: suspend (DownloadInfo) -> Boolean = { true }
     ): DownloadInfo = withContext(Dispatchers.IO) {
         Timber.tag(TAG).v(
-            "Starting download: country=%s, day=%s, hour=%s -> %s.",
+            "Starting download: location=%s, day=%s, hour=%s -> %s.",
             locationCode, day, hour, saveTo
         )
 
@@ -82,7 +80,7 @@ class DiagnosisKeyServer @Inject constructor(
             )
         }
 
-        var downloadInfo = DownloadInfo(response.headers())
+        val downloadInfo = DownloadInfo(response.headers())
 
         if (!precondition(downloadInfo)) {
             Timber.tag(TAG).d("Precondition is not met, aborting.")
@@ -95,10 +93,6 @@ class DiagnosisKeyServer @Inject constructor(
                 }
             }
 
-            val (localMD5, duration) = measureTimeMillisWithResult { saveTo.hashToMD5() }
-            Timber.v("Hashed to MD5 in %dms: %s", duration, saveTo)
-
-            downloadInfo = downloadInfo.copy(localMD5 = localMD5)
             Timber.tag(TAG).v("Key file download successful: %s", downloadInfo)
 
             return@withContext downloadInfo
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfo.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfo.kt
index fb1dcbeebc0c6e027f36f3f2189a820e75ca2943..1310f2a0a8d12cd0e16d5ebb6b06a70538f9e47c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfo.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfo.kt
@@ -2,24 +2,7 @@ package de.rki.coronawarnapp.diagnosiskeys.server
 
 import okhttp3.Headers
 
-data class DownloadInfo(
-    val headers: Headers,
-    val localMD5: String? = null
-) {
+data class DownloadInfo(val headers: Headers) {
 
-    val serverMD5 by lazy { headers.getPayloadChecksumMD5() }
-
-    private fun Headers.getPayloadChecksumMD5(): String? {
-
-        val fileMD5 = values("ETag").singleOrNull()
-//  TODO EXPOSUREBACK-178
-//                var fileMD5 = headers.values("x-amz-meta-cwa-hash-md5").singleOrNull()
-//                if (fileMD5 == null) {
-//                    headers.values("x-amz-meta-cwa-hash").singleOrNull()
-//                }
-//                if (fileMD5 == null) { // Fallback
-//                    fileMD5 = headers.values("ETag").singleOrNull()
-//                }
-        return fileMD5?.removePrefix("\"")?.removeSuffix("\"")
-    }
+    val etag by lazy { headers.values("ETag").singleOrNull() }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKey.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKey.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bba9bcf20b96f0dcb34550f2eb744cab2489996c
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKey.kt
@@ -0,0 +1,5 @@
+package de.rki.coronawarnapp.diagnosiskeys.storage
+
+import java.io.File
+
+data class CachedKey(val info: CachedKeyInfo, val path: File)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyInfo.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyInfo.kt
index b77f11c7be3f1f58959c1c7b19ba50414ea19724..9dd0b8ada11abec14be3e1a2f7740e5bc7715aca 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyInfo.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyInfo.kt
@@ -6,6 +6,8 @@ import androidx.room.PrimaryKey
 import androidx.room.TypeConverter
 import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
 import de.rki.coronawarnapp.util.HashExtensions.toSHA1
+import org.joda.time.DateTime
+import org.joda.time.DateTimeZone
 import org.joda.time.Instant
 import org.joda.time.LocalDate
 import org.joda.time.LocalTime
@@ -18,7 +20,7 @@ data class CachedKeyInfo(
     @ColumnInfo(name = "day") val day: LocalDate, // i.e. 2020-08-23
     @ColumnInfo(name = "hour") val hour: LocalTime?, // i.e. 23
     @ColumnInfo(name = "createdAt") val createdAt: Instant,
-    @ColumnInfo(name = "checksumMD5") val checksumMD5: String?,
+    @ColumnInfo(name = "checksumMD5") val etag: String?, // ETag
     @ColumnInfo(name = "completed") val isDownloadComplete: Boolean
 ) {
 
@@ -35,19 +37,24 @@ data class CachedKeyInfo(
         hour = hour,
         type = type,
         createdAt = createdAt,
-        checksumMD5 = null,
+        etag = null,
         isDownloadComplete = false
     )
 
     @Transient
     val fileName: String = "$id.zip"
 
-    fun toDownloadUpdate(checksumMD5: String?): DownloadUpdate = DownloadUpdate(
+    fun toDownloadUpdate(etag: String): DownloadUpdate = DownloadUpdate(
         id = id,
-        checksumMD5 = checksumMD5,
-        isDownloadComplete = checksumMD5 != null
+        etag = etag,
+        isDownloadComplete = true
     )
 
+    fun toDateTime(): DateTime = when (type) {
+        Type.LOCATION_DAY -> day.toDateTimeAtStartOfDay(DateTimeZone.UTC)
+        Type.LOCATION_HOUR -> day.toDateTime(hour, DateTimeZone.UTC)
+    }
+
     companion object {
         fun calcluateId(
             location: LocationCode,
@@ -62,8 +69,8 @@ data class CachedKeyInfo(
     }
 
     enum class Type constructor(internal val typeValue: String) {
-        COUNTRY_DAY("country_day"),
-        COUNTRY_HOUR("country_hour");
+        LOCATION_DAY("country_day"),
+        LOCATION_HOUR("country_hour");
 
         class Converter {
             @TypeConverter
@@ -78,7 +85,7 @@ data class CachedKeyInfo(
     @Entity
     data class DownloadUpdate(
         @PrimaryKey @ColumnInfo(name = "id") val id: String,
-        @ColumnInfo(name = "checksumMD5") val checksumMD5: String?,
+        @ColumnInfo(name = "checksumMD5") val etag: String?,
         @ColumnInfo(name = "completed") val isDownloadComplete: Boolean
     )
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabase.kt
index ec23391b1878354a29a391cc3b30b111c3662563..f9f97faf09d59924c926bacf5a50de7ece041130 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabase.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabase.kt
@@ -13,6 +13,7 @@ import androidx.room.TypeConverters
 import androidx.room.Update
 import de.rki.coronawarnapp.util.database.CommonConverters
 import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.flow.Flow
 import javax.inject.Inject
 
 @Database(
@@ -28,7 +29,7 @@ abstract class KeyCacheDatabase : RoomDatabase() {
     @Dao
     interface CachedKeyFileDao {
         @Query("SELECT * FROM keyfiles")
-        suspend fun getAllEntries(): List<CachedKeyInfo>
+        fun allEntries(): Flow<List<CachedKeyInfo>>
 
         @Query("SELECT * FROM keyfiles WHERE type = :type")
         suspend fun getEntriesForType(type: String): List<CachedKeyInfo>
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepository.kt
index c6f42e84ed9982d3764161f5ebfb3f35babc161d..ed05838ea1a78efb009e643e30f8c024c22ead41 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepository.kt
@@ -24,6 +24,9 @@ import android.database.sqlite.SQLiteConstraintException
 import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import org.joda.time.LocalDate
@@ -74,23 +77,32 @@ class KeyCacheRepository @Inject constructor(
     }
 
     private suspend fun doHouseKeeping() {
-        val dirtyInfos = getDao().getAllEntries().filter {
-            it.isDownloadComplete && !getPathForKey(it).exists()
+        val dirtyInfos = getAllCachedKeys().filter {
+            it.info.isDownloadComplete && !it.path.exists()
         }
         Timber.v("HouseKeeping, deleting: %s", dirtyInfos)
-        delete(dirtyInfos)
+        delete(dirtyInfos.map { it.info })
     }
 
+    private fun CachedKeyInfo.toCachedKey(): CachedKey = CachedKey(
+        info = this,
+        path = getPathForKey(this)
+    )
+
     fun getPathForKey(cachedKeyInfo: CachedKeyInfo): File {
         return File(storageDir, cachedKeyInfo.fileName)
     }
 
-    suspend fun getAllCachedKeys(): List<Pair<CachedKeyInfo, File>> {
-        return getDao().getAllEntries().map { it to getPathForKey(it) }
+    suspend fun getAllCachedKeys(): List<CachedKey> {
+        return allCachedKeys().first()
+    }
+
+    suspend fun allCachedKeys(): Flow<List<CachedKey>> {
+        return getDao().allEntries().map { entries -> entries.map { it.toCachedKey() } }
     }
 
-    suspend fun getEntriesForType(type: CachedKeyInfo.Type): List<Pair<CachedKeyInfo, File>> {
-        return getDao().getEntriesForType(type.typeValue).map { it to getPathForKey(it) }
+    suspend fun getEntriesForType(type: CachedKeyInfo.Type): List<CachedKey> {
+        return getDao().getEntriesForType(type.typeValue).map { it.toCachedKey() }
     }
 
     suspend fun createCacheEntry(
@@ -98,8 +110,8 @@ class KeyCacheRepository @Inject constructor(
         location: LocationCode,
         dayIdentifier: LocalDate,
         hourIdentifier: LocalTime?
-    ): Pair<CachedKeyInfo, File> {
-        val newKeyFile = CachedKeyInfo(
+    ): CachedKey {
+        val keyInfo = CachedKeyInfo(
             type = type,
             location = location,
             day = dayIdentifier,
@@ -107,19 +119,19 @@ class KeyCacheRepository @Inject constructor(
             createdAt = timeStamper.nowUTC
         )
 
-        val targetFile = getPathForKey(newKeyFile)
+        val targetFile = getPathForKey(keyInfo)
 
         try {
-            getDao().insertEntry(newKeyFile)
+            getDao().insertEntry(keyInfo)
             if (targetFile.exists()) {
                 Timber.w("Target path despite no collision exists, deleting: %s", targetFile)
             }
         } catch (e: SQLiteConstraintException) {
-            Timber.e(e, "Insertion collision? Overwriting for %s", newKeyFile)
-            delete(listOf(newKeyFile))
+            Timber.e(e, "Insertion collision? Overwriting for %s", keyInfo)
+            delete(listOf(keyInfo))
 
-            Timber.d(e, "Retrying insertion for %s", newKeyFile)
-            getDao().insertEntry(newKeyFile)
+            Timber.d(e, "Retrying insertion for %s", keyInfo)
+            getDao().insertEntry(keyInfo)
         }
 
         // This can't be null unless our cache dir is root `/`
@@ -129,11 +141,11 @@ class KeyCacheRepository @Inject constructor(
             targetParent.mkdirs()
         }
 
-        return newKeyFile to targetFile
+        return CachedKey(info = keyInfo, path = targetFile)
     }
 
-    suspend fun markKeyComplete(cachedKeyInfo: CachedKeyInfo, checksumMD5: String) {
-        val update = cachedKeyInfo.toDownloadUpdate(checksumMD5)
+    suspend fun markKeyComplete(cachedKeyInfo: CachedKeyInfo, etag: String) {
+        val update = cachedKeyInfo.toDownloadUpdate(etag)
         getDao().updateDownloadState(update)
     }
 
@@ -149,6 +161,6 @@ class KeyCacheRepository @Inject constructor(
 
     suspend fun clear() {
         Timber.i("clear()")
-        delete(getDao().getAllEntries())
+        delete(getDao().allEntries().first())
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ErrorReportReceiver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ErrorReportReceiver.kt
index 8aafc6246c568100c2aa7b5eaaeb8942323d3c8e..ac820f405175ffe5ab05f0edd103926d2fa8f434 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ErrorReportReceiver.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ErrorReportReceiver.kt
@@ -12,17 +12,12 @@ import timber.log.Timber
 import java.util.Locale
 
 class ErrorReportReceiver(private val activity: Activity) : BroadcastReceiver() {
-    companion object {
-        private val TAG: String = ErrorReportReceiver::class.java.simpleName
-    }
 
+    @Suppress("LongMethod")
     override fun onReceive(context: Context, intent: Intent) {
         val category = ExceptionCategory
             .valueOf(intent.getStringExtra(ReportingConstants.ERROR_REPORT_CATEGORY_EXTRA) ?: "")
-        val errorCode = intent.getIntExtra(
-            ReportingConstants.ERROR_REPORT_CODE_EXTRA,
-            ReportingConstants.ERROR_REPORT_UNKNOWN_ERROR
-        )
+
         val prefix = intent.getStringExtra(ReportingConstants.ERROR_REPORT_PREFIX_EXTRA)
         val suffix = intent.getStringExtra(ReportingConstants.ERROR_REPORT_SUFFIX_EXTRA)
 
@@ -41,7 +36,7 @@ class ErrorReportReceiver(private val activity: Activity) : BroadcastReceiver()
         val confirm = context.resources.getString(R.string.errors_generic_button_positive)
         val details = context.resources.getString(R.string.errors_generic_button_negative)
 
-        var detailsTitle = context.resources.getString(R.string.errors_generic_details_headline)
+        val detailsTitle = context.resources.getString(R.string.errors_generic_details_headline)
 
         if (intent.hasExtra(ReportingConstants.ERROR_REPORT_API_EXCEPTION_CODE)) {
             val apiStatusCode = intent.getIntExtra(
@@ -52,31 +47,44 @@ class ErrorReportReceiver(private val activity: Activity) : BroadcastReceiver()
             message += "#$apiStatusCode"
         }
 
-        val errorTitle = context.resources.getString(R.string.errors_generic_details_headline)
-            .toUpperCase(Locale.ROOT)
-
-        if (CoronaWarnApplication.isAppInForeground) {
-            DialogHelper.showDialog(
-                DialogHelper.DialogInstance(
-                    activity,
-                    "$errorTitle: $errorCode\n$title",
-                    message,
-                    confirm,
-                    details,
-                    null,
-                    {},
-                    {
-                        DialogHelper.showDialog(
-                            DialogHelper.DialogInstance(
-                                activity,
-                                title,
-                                "$detailsTitle:\n$stack",
-                                confirm
-                            )
-                        ).run {}
-                    }
-                ))
+        val dialogTitle = if (intent.hasExtra(ReportingConstants.ERROR_REPORT_TITLE_EXTRA)) {
+            intent.getStringExtra(ReportingConstants.ERROR_REPORT_TITLE_EXTRA)
+        } else {
+            val errorTitle = context.resources.getString(R.string.errors_generic_details_headline)
+                .toUpperCase(Locale.ROOT)
+            val errorCode = intent.getIntExtra(
+                ReportingConstants.ERROR_REPORT_CODE_EXTRA,
+                ReportingConstants.ERROR_REPORT_UNKNOWN_ERROR
+            )
+            "$errorTitle: $errorCode\n$title"
         }
+
         Timber.e("[$category]${(prefix ?: "")} $message${(suffix ?: "")}")
+
+        if (!CoronaWarnApplication.isAppInForeground) {
+            Timber.v("Not displaying error dialog, not in foreground.")
+            return
+        }
+
+        val dialogInstance = DialogHelper.DialogInstance(
+            context = activity,
+            title = dialogTitle,
+            message = message,
+            positiveButton = confirm,
+            negativeButton = details,
+            cancelable = null,
+            positiveButtonFunction = {},
+            negativeButtonFunction = {
+                val stackTraceDialog = DialogHelper.DialogInstance(
+                    activity,
+                    title,
+                    "$detailsTitle:\n$stack",
+                    confirm
+                )
+                DialogHelper.showDialog(stackTraceDialog.copy(isTextSelectable = true))
+                Unit
+            }
+        )
+        DialogHelper.showDialog(dialogInstance)
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt
index 8f602075d2af674dfa4b9853c9d9c22428e36777..e8b2ec5daa6317fe2db5d430567d63d143a0b6e5 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt
@@ -10,7 +10,8 @@ import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.ReportingConstants.STATUS_CODE_GOOGLE_API_FAIL
 import de.rki.coronawarnapp.exception.reporting.ReportingConstants.STATUS_CODE_GOOGLE_UPDATE_NEEDED
 import de.rki.coronawarnapp.exception.reporting.ReportingConstants.STATUS_CODE_REACHED_REQUEST_LIMIT
-import de.rki.coronawarnapp.util.tryFormattedError
+import de.rki.coronawarnapp.util.tryHumanReadableError
+import de.rki.coronawarnapp.util.CWADebug
 import java.io.PrintWriter
 import java.io.StringWriter
 
@@ -22,14 +23,19 @@ fun Throwable.report(
     prefix: String?,
     suffix: String?
 ) {
+    if (CWADebug.isAUnitTest) return
+
     reportProblem(tag = prefix, info = suffix)
     val context = CoronaWarnApplication.getAppContext()
 
+    val formattedError = this.tryHumanReadableError(context)
+
     val intent = Intent(ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL)
     intent.putExtra(ReportingConstants.ERROR_REPORT_CATEGORY_EXTRA, exceptionCategory.name)
     intent.putExtra(ReportingConstants.ERROR_REPORT_PREFIX_EXTRA, prefix)
     intent.putExtra(ReportingConstants.ERROR_REPORT_SUFFIX_EXTRA, suffix)
-    intent.putExtra(ReportingConstants.ERROR_REPORT_MESSAGE_EXTRA, this.tryFormattedError(context))
+    intent.putExtra(ReportingConstants.ERROR_REPORT_TITLE_EXTRA, formattedError.title)
+    intent.putExtra(ReportingConstants.ERROR_REPORT_MESSAGE_EXTRA, formattedError.description)
 
     if (this is ReportedExceptionInterface) {
         intent.putExtra(ReportingConstants.ERROR_REPORT_CODE_EXTRA, this.code)
@@ -51,10 +57,7 @@ fun Throwable.report(
             errorMessage = R.string.errors_google_api_error
         }
 
-        intent.putExtra(
-            ReportingConstants.ERROR_REPORT_RES_ID,
-            errorMessage
-        )
+        intent.putExtra(ReportingConstants.ERROR_REPORT_RES_ID, errorMessage)
         intent.putExtra(ReportingConstants.ERROR_REPORT_CODE_EXTRA, ErrorCodes.API_EXCEPTION.code)
         intent.putExtra(ReportingConstants.ERROR_REPORT_API_EXCEPTION_CODE, this.statusCode)
     }
@@ -69,12 +72,3 @@ fun Throwable.report(
     intent.putExtra(ReportingConstants.ERROR_REPORT_STACK_EXTRA, stackExtra)
     LocalBroadcastManager.getInstance(context).sendBroadcast(intent)
 }
-
-fun reportGeneric(
-    stackString: String
-) {
-    val intent = Intent(ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL)
-    intent.putExtra("category", ExceptionCategory.INTERNAL.name)
-    intent.putExtra("stack", stackString)
-    LocalBroadcastManager.getInstance(CoronaWarnApplication.getAppContext()).sendBroadcast(intent)
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ReportingConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ReportingConstants.kt
index 16e021b5c85da260a870e0e2b0bfefdcbe63dcda..19475b1d64ebe1a0bbc5f43b02481d985a4ee714 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ReportingConstants.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ReportingConstants.kt
@@ -5,6 +5,7 @@ object ReportingConstants {
     const val ERROR_REPORT_CATEGORY_EXTRA = "category"
     const val ERROR_REPORT_PREFIX_EXTRA = "prefix"
     const val ERROR_REPORT_SUFFIX_EXTRA = "suffix"
+    const val ERROR_REPORT_TITLE_EXTRA = "title"
     const val ERROR_REPORT_MESSAGE_EXTRA = "message"
     const val ERROR_REPORT_STACK_EXTRA = "stack"
     const val ERROR_REPORT_CODE_EXTRA = "code"
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
index 98914070b231d5485cd3b50f4302cff631e43345..c3de7abd0615b66f64d5eb6255c6d1c255e571e8 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
@@ -4,8 +4,8 @@ package de.rki.coronawarnapp.nearby
 
 import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
 import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
 import de.rki.coronawarnapp.nearby.modules.locationless.ScanningSupport
 import de.rki.coronawarnapp.nearby.modules.tracing.TracingStatus
@@ -23,7 +23,7 @@ class ENFClient @Inject constructor(
     private val diagnosisKeyProvider: DiagnosisKeyProvider,
     private val tracingStatus: TracingStatus,
     private val scanningSupport: ScanningSupport,
-    private val calculationTracker: CalculationTracker
+    private val exposureDetectionTracker: ExposureDetectionTracker
 ) : DiagnosisKeyProvider, TracingStatus, ScanningSupport {
 
     // TODO Remove this once we no longer need direct access to the ENF Client,
@@ -46,7 +46,7 @@ class ENFClient @Inject constructor(
             true
         } else {
             Timber.d("Forwarding %d key files to our DiagnosisKeyProvider.", keyFiles.size)
-            calculationTracker.trackNewCalaculation(token)
+            exposureDetectionTracker.trackNewExposureDetection(token)
             diagnosisKeyProvider.provideDiagnosisKeys(keyFiles, configuration, token)
         }
     }
@@ -57,14 +57,17 @@ class ENFClient @Inject constructor(
     override val isTracingEnabled: Flow<Boolean>
         get() = tracingStatus.isTracingEnabled
 
-    fun isCurrentlyCalculating(): Flow<Boolean> = calculationTracker.calculations
+    fun isPerformingExposureDetection(): Flow<Boolean> = exposureDetectionTracker.calculations
         .map { it.values }
         .map { values ->
             values.maxBy { it.startedAt }?.isCalculating == true
         }
 
-    fun latestFinishedCalculation(): Flow<Calculation?> =
-        calculationTracker.calculations.map { snapshot ->
+    fun latestTrackedExposureDetection(): Flow<Collection<TrackedExposureDetection>> =
+        exposureDetectionTracker.calculations.map { it.values }
+
+    fun lastSuccessfulTrackedExposureDetection(): Flow<TrackedExposureDetection?> =
+        exposureDetectionTracker.calculations.map { snapshot ->
             snapshot.values
                 .filter { !it.isCalculating && it.isSuccessful }
                 .maxByOrNull { it.finishedAt ?: Instant.EPOCH }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
index 1bf6f5dd72da98edefc721583b8aa1327d77c73d..9d98d5b33bf809dbc19995310857d2cda775fcb6 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
@@ -5,8 +5,8 @@ import com.google.android.gms.nearby.Nearby
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
 import dagger.Module
 import dagger.Provides
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.DefaultCalculationTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.DefaultExposureDetectionTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
 import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DefaultDiagnosisKeyProvider
 import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
 import de.rki.coronawarnapp.nearby.modules.locationless.DefaultScanningSupport
@@ -41,6 +41,6 @@ class ENFModule {
 
     @Singleton
     @Provides
-    fun calculationTracker(calculationTracker: DefaultCalculationTracker): CalculationTracker =
-        calculationTracker
+    fun calculationTracker(exposureDetectionTracker: DefaultExposureDetectionTracker): ExposureDetectionTracker =
+        exposureDetectionTracker
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationPermissionHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationPermissionHelper.kt
index 3468e94e6de747c6ab7089afb613765313691f1c..8bba58f42a35db70e0a5497d739d8588eabda020 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationPermissionHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationPermissionHelper.kt
@@ -81,7 +81,6 @@ class InternalExposureNotificationPermissionHelper(
      *
      */
     fun requestPermissionToShareKeys() {
-
         host.viewLifecycleOwner.lifecycleScope.launch {
             try {
                 val keys = InternalExposureNotificationClient.asyncGetTemporaryExposureKeyHistory()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt
deleted file mode 100644
index 30bbc1b25e9a1d1aede361fbb2e3f6ce11b2ab43..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package de.rki.coronawarnapp.nearby.modules.calculationtracker
-
-import kotlinx.coroutines.flow.Flow
-
-interface CalculationTracker {
-    val calculations: Flow<Map<String, Calculation>>
-
-    fun trackNewCalaculation(identifier: String)
-
-    fun finishCalculation(identifier: String, result: Calculation.Result)
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTracker.kt
similarity index 67%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTracker.kt
index c94eea81e2e5696c44fc8e76d80e837d02151c8d..c5e4b8073deb172dcb4856afb9d2406af38ef94d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTracker.kt
@@ -1,6 +1,7 @@
-package de.rki.coronawarnapp.nearby.modules.calculationtracker
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
 
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation.Result
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection.Result
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.coroutine.AppScope
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
@@ -9,6 +10,7 @@ import de.rki.coronawarnapp.util.mutate
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
@@ -21,35 +23,36 @@ import javax.inject.Singleton
 import kotlin.math.min
 
 @Singleton
-class DefaultCalculationTracker @Inject constructor(
+class DefaultExposureDetectionTracker @Inject constructor(
     @AppScope private val scope: CoroutineScope,
     private val dispatcherProvider: DispatcherProvider,
-    private val storage: CalculationTrackerStorage,
-    private val timeStamper: TimeStamper
-) : CalculationTracker {
+    private val storage: ExposureDetectionTrackerStorage,
+    private val timeStamper: TimeStamper,
+    private val appConfigProvider: AppConfigProvider
+) : ExposureDetectionTracker {
 
     init {
         Timber.v("init()")
     }
 
-    private val calculationStates: HotDataFlow<Map<String, Calculation>> by lazy {
-        val setupAutoSave: (HotDataFlow<Map<String, Calculation>>) -> Unit = { hd ->
+    private val detectionStates: HotDataFlow<Map<String, TrackedExposureDetection>> by lazy {
+        val setupAutoSave: (HotDataFlow<Map<String, TrackedExposureDetection>>) -> Unit = { hd ->
             hd.data
-                .onStart { Timber.v("Observing calculation changes.") }
+                .onStart { Timber.v("Observing detection changes.") }
                 .onEach { storage.save(it) }
                 .launchIn(scope = scope + dispatcherProvider.Default)
         }
 
-        val setupTimeoutEnforcer: (HotDataFlow<Map<String, Calculation>>) -> Unit = { hd ->
+        val setupTimeoutEnforcer: (HotDataFlow<Map<String, TrackedExposureDetection>>) -> Unit = { hd ->
             flow<Unit> {
                 while (true) {
                     hd.updateSafely {
                         val timeNow = timeStamper.nowUTC
                         Timber.v("Running timeout check (now=%s): %s", timeNow, values)
-
+                        val timeoutLimit = appConfigProvider.currentConfig.first().overallDetectionTimeout
                         mutate {
                             values.filter { it.isCalculating }.toList().forEach {
-                                if (timeNow.isAfter(it.startedAt.plus(TIMEOUT_LIMIT))) {
+                                if (timeNow.isAfter(it.startedAt.plus(timeoutLimit))) {
                                     Timber.w("Calculation timeout on %s", it)
                                     this[it.identifier] = it.copy(
                                         finishedAt = timeStamper.nowUTC,
@@ -76,13 +79,13 @@ class DefaultCalculationTracker @Inject constructor(
         }
     }
 
-    override val calculations: Flow<Map<String, Calculation>> by lazy { calculationStates.data }
+    override val calculations: Flow<Map<String, TrackedExposureDetection>> by lazy { detectionStates.data }
 
-    override fun trackNewCalaculation(identifier: String) {
-        Timber.i("trackNewCalaculation(token=%s)", identifier)
-        calculationStates.updateSafely {
+    override fun trackNewExposureDetection(identifier: String) {
+        Timber.i("trackNewExposureDetection(token=%s)", identifier)
+        detectionStates.updateSafely {
             mutate {
-                this[identifier] = Calculation(
+                this[identifier] = TrackedExposureDetection(
                     identifier = identifier,
                     startedAt = timeStamper.nowUTC
                 )
@@ -90,16 +93,16 @@ class DefaultCalculationTracker @Inject constructor(
         }
     }
 
-    override fun finishCalculation(identifier: String, result: Result) {
-        Timber.i("finishCalculation(token=%s, result=%s)", identifier, result)
-        calculationStates.updateSafely {
+    override fun finishExposureDetection(identifier: String, result: Result) {
+        Timber.i("finishExposureDetection(token=%s, result=%s)", identifier, result)
+        detectionStates.updateSafely {
             mutate {
                 val existing = this[identifier]
                 if (existing != null) {
                     if (existing.result == Result.TIMEOUT) {
-                        Timber.w("Calculation is late, already hit timeout, still updating.")
+                        Timber.w("Detection is late, already hit timeout, still updating.")
                     } else if (existing.result != null) {
-                        Timber.e("Duplicate callback. Result is already set for calculation!")
+                        Timber.e("Duplicate callback. Result is already set for detection!")
                     }
                     this[identifier] = existing.copy(
                         result = result,
@@ -107,11 +110,11 @@ class DefaultCalculationTracker @Inject constructor(
                     )
                 } else {
                     Timber.e(
-                        "Unknown calculation finished (token=%s, result=%s)",
+                        "Unknown detection finished (token=%s, result=%s)",
                         identifier,
                         result
                     )
-                    this[identifier] = Calculation(
+                    this[identifier] = TrackedExposureDetection(
                         identifier = identifier,
                         result = result,
                         startedAt = timeStamper.nowUTC,
@@ -132,9 +135,8 @@ class DefaultCalculationTracker @Inject constructor(
     }
 
     companion object {
-        private const val TAG = "DefaultCalculationTracker"
+        private const val TAG = "DefaultExposureDetectionTracker"
         private const val MAX_ENTRY_SIZE = 5
         private val TIMEOUT_CHECK_INTERVALL = Duration.standardMinutes(3)
-        private val TIMEOUT_LIMIT = Duration.standardMinutes(15)
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTracker.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4d9bcf0c6e318c96a314d71bdd0050f0ecf2bd88
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTracker.kt
@@ -0,0 +1,11 @@
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
+
+import kotlinx.coroutines.flow.Flow
+
+interface ExposureDetectionTracker {
+    val calculations: Flow<Map<String, TrackedExposureDetection>>
+
+    fun trackNewExposureDetection(identifier: String)
+
+    fun finishExposureDetection(identifier: String, result: TrackedExposureDetection.Result)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorage.kt
similarity index 70%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorage.kt
index 18fb150e31c1164039079bf0130f5037bdd2c533..01e93c4b9ab724a44dc953b0466c2b6a7b366c31 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorage.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.nearby.modules.calculationtracker
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
 
 import android.content.Context
 import com.google.gson.Gson
@@ -16,7 +16,7 @@ import javax.inject.Inject
 import javax.inject.Singleton
 
 @Singleton
-class CalculationTrackerStorage @Inject constructor(
+class ExposureDetectionTrackerStorage @Inject constructor(
     @AppContext private val context: Context,
     @BaseGson gson: Gson
 ) {
@@ -33,36 +33,36 @@ class CalculationTrackerStorage @Inject constructor(
         }
     }
     private val storageFile by lazy { File(storageDir, "calculations.json") }
-    private var lastCalcuationData: Map<String, Calculation>? = null
+    private var lastCalcuationData: Map<String, TrackedExposureDetection>? = null
 
     init {
         Timber.v("init()")
     }
 
-    suspend fun load(): Map<String, Calculation> = mutex.withLock {
+    suspend fun load(): Map<String, TrackedExposureDetection> = mutex.withLock {
         return@withLock try {
             if (!storageFile.exists()) return@withLock emptyMap()
 
-            gson.fromJson<Map<String, Calculation>>(storageFile).also {
-                Timber.v("Loaded calculation data: %s", it)
+            gson.fromJson<Map<String, TrackedExposureDetection>>(storageFile).also {
+                Timber.v("Loaded detection data: %s", it)
                 lastCalcuationData = it
             }
         } catch (e: Exception) {
-            Timber.e(e, "Failed to load tracked calculations.")
+            Timber.e(e, "Failed to load tracked detections.")
             emptyMap()
         }
     }
 
-    suspend fun save(data: Map<String, Calculation>) = mutex.withLock {
+    suspend fun save(data: Map<String, TrackedExposureDetection>) = mutex.withLock {
         if (lastCalcuationData == data) {
             Timber.v("Data didn't change, skipping save.")
             return@withLock
         }
-        Timber.v("Storing calculation data: %s", data)
+        Timber.v("Storing detection data: %s", data)
         try {
             gson.toJson(data, storageFile)
         } catch (e: Exception) {
-            Timber.e(e, "Failed to save tracked calculations.")
+            Timber.e(e, "Failed to save tracked detections.")
         }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetection.kt
similarity index 88%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetection.kt
index 13779d6ba1d22c6f55cdef891ffa9c4d74590b7a..20fdddefe71875201448539b662f89f782f986dd 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetection.kt
@@ -1,11 +1,11 @@
-package de.rki.coronawarnapp.nearby.modules.calculationtracker
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
 
 import androidx.annotation.Keep
 import com.google.gson.annotations.SerializedName
 import org.joda.time.Instant
 
 @Keep
-data class Calculation(
+data class TrackedExposureDetection(
     @SerializedName("identifier") val identifier: String,
     @SerializedName("startedAt") val startedAt: Instant,
     @SerializedName("result") val result: Result? = null,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt
index bca8dfdd48723b685774623dfcd72733203aec66..59114d58d4c0bab4709d0568ffb39c052923913d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt
@@ -2,8 +2,10 @@
 
 package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider
 
+import com.google.android.gms.common.api.ApiException
 import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import de.rki.coronawarnapp.exception.reporting.ReportingConstants
 import de.rki.coronawarnapp.util.GoogleAPIVersion
 import timber.log.Timber
 import java.io.File
@@ -101,6 +103,14 @@ class DefaultDiagnosisKeyProvider @Inject constructor(
         enfClient
             .provideDiagnosisKeys(keyFiles.toList(), configuration, token)
             .addOnSuccessListener { cont.resume(it) }
-            .addOnFailureListener { cont.resumeWithException(it) }
+            .addOnFailureListener {
+                val wrappedException = when {
+                    it is ApiException && it.statusCode == ReportingConstants.STATUS_CODE_REACHED_REQUEST_LIMIT -> {
+                        QuotaExceededException(cause = it)
+                    }
+                    else -> it
+                }
+                cont.resumeWithException(wrappedException)
+            }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/QuotaExceededException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/QuotaExceededException.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3a8f85a4aabe396bd7dd62b5be82c00973a154fe
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/QuotaExceededException.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider
+
+import android.content.Context
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.util.HasHumanReadableError
+import de.rki.coronawarnapp.util.HumanReadableError
+
+class QuotaExceededException(
+    cause: Throwable
+) : IllegalStateException("Quota limit exceeded.", cause), HasHumanReadableError {
+
+    override fun toHumanReadableError(context: Context): HumanReadableError = HumanReadableError(
+        title = context.getString(R.string.errors_risk_detection_limit_reached_title),
+        description = context.getString(R.string.errors_risk_detection_limit_reached_description)
+    )
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationConstants.kt
index 1aba38cf9b0fe897f7284960db0b74d6aded9cb3..01b785fe65070177f3bd47080b2fd1fbc0c3712e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationConstants.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationConstants.kt
@@ -1,6 +1,7 @@
 package de.rki.coronawarnapp.notification
 
 import de.rki.coronawarnapp.R
+import org.joda.time.Duration
 
 /**
  * The notification constants are used inside the NotificationHelper
@@ -9,6 +10,13 @@ import de.rki.coronawarnapp.R
  */
 object NotificationConstants {
 
+    const val NOTIFICATION_ID = "NOTIFICATION_ID"
+
+    const val POSITIVE_RESULT_NOTIFICATION_ID = 100
+    const val POSITIVE_RESULT_NOTIFICATION_TOTAL_COUNT = 2
+    val POSITIVE_RESULT_NOTIFICATION_INITIAL_OFFSET: Duration = Duration.standardHours(2)
+    val POSITIVE_RESULT_NOTIFICATION_INTERVAL: Duration = Duration.standardHours(2)
+
     /**
      * Notification channel id String.xml path
      */
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 1fd3465f14562f241e5e9ecdd1c1707ce5d05c2f..c642a8a4b7788ec131309bd8704549889abffebc 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
@@ -1,19 +1,25 @@
 package de.rki.coronawarnapp.notification
 
+import android.app.AlarmManager
 import android.app.Notification
 import android.app.NotificationChannel
 import android.app.NotificationManager
 import android.app.PendingIntent
+import android.app.PendingIntent.FLAG_CANCEL_CURRENT
 import android.content.Context
 import android.content.Intent
 import android.media.AudioAttributes
 import android.media.RingtoneManager
 import android.os.Build
 import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationCompat.PRIORITY_HIGH
 import androidx.core.app.NotificationManagerCompat
 import de.rki.coronawarnapp.BuildConfig
 import de.rki.coronawarnapp.CoronaWarnApplication
+import de.rki.coronawarnapp.notification.NotificationConstants.NOTIFICATION_ID
 import de.rki.coronawarnapp.ui.main.MainActivity
+import org.joda.time.Duration
+import org.joda.time.Instant
 import timber.log.Timber
 import kotlin.random.Random
 
@@ -26,8 +32,6 @@ import kotlin.random.Random
  */
 object NotificationHelper {
 
-    private val TAG: String? = NotificationHelper::class.simpleName
-
     /**
      * Notification channel id
      *
@@ -82,6 +86,37 @@ object NotificationHelper {
         }
     }
 
+    fun cancelFutureNotifications(notificationId: Int) {
+        val pendingIntent = createPendingIntentToScheduleNotification(notificationId)
+        val manager =
+            CoronaWarnApplication.getAppContext().getSystemService(Context.ALARM_SERVICE) as AlarmManager
+        manager.cancel(pendingIntent)
+        Timber.v("Canceled future notifications with id: %s", notificationId)
+    }
+
+    fun scheduleRepeatingNotification(
+        initialTime: Instant,
+        interval: Duration,
+        notificationId: NotificationId
+    ) {
+        val pendingIntent = createPendingIntentToScheduleNotification(notificationId)
+        val manager =
+            CoronaWarnApplication.getAppContext().getSystemService(Context.ALARM_SERVICE) as AlarmManager
+        manager.setInexactRepeating(AlarmManager.RTC, initialTime.millis, interval.millis, pendingIntent)
+    }
+
+    private fun createPendingIntentToScheduleNotification(
+        notificationId: NotificationId,
+        flag: Int = FLAG_CANCEL_CURRENT
+    ) =
+        PendingIntent.getBroadcast(
+            CoronaWarnApplication.getAppContext(),
+            notificationId,
+            Intent(CoronaWarnApplication.getAppContext(), NotificationReceiver::class.java).apply {
+                putExtra(NOTIFICATION_ID, notificationId)
+            },
+            flag)
+
     /**
      * Build notification
      * Create notification with defined title, content text and visibility.
@@ -98,13 +133,14 @@ object NotificationHelper {
         title: String,
         content: String,
         visibility: Int,
-        expandableLongText: Boolean = false
+        expandableLongText: Boolean = false,
+        pendingIntent: PendingIntent = createPendingIntentToMainActivity()
     ): Notification? {
         val builder = NotificationCompat.Builder(CoronaWarnApplication.getAppContext(), channelId)
             .setSmallIcon(NotificationConstants.NOTIFICATION_SMALL_ICON)
             .setPriority(NotificationCompat.PRIORITY_MAX)
             .setVisibility(visibility)
-            .setContentIntent(createPendingIntentToMainActivity())
+            .setContentIntent(pendingIntent)
             .setAutoCancel(true)
 
         if (expandableLongText) {
@@ -154,17 +190,22 @@ 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,
-        visibility: Int,
-        expandableLongText: Boolean = false
+        expandableLongText: Boolean = false,
+        notificationId: NotificationId = Random.nextInt(),
+        pendingIntent: PendingIntent = createPendingIntentToMainActivity()
     ) {
         val notification =
-            buildNotification(title, content, visibility, expandableLongText) ?: return
+            buildNotification(title, content, PRIORITY_HIGH, expandableLongText, pendingIntent) ?: return
         with(NotificationManagerCompat.from(CoronaWarnApplication.getAppContext())) {
-            notify(Random.nextInt(), notification)
+            notify(notificationId, notification)
         }
     }
 
@@ -174,11 +215,10 @@ object NotificationHelper {
      * Notification is only sent if app is not in foreground.
      *
      * @param content: String
-     * @param visibility: Int
      */
-    fun sendNotification(content: String, visibility: Int) {
+    fun sendNotification(content: String) {
         if (!CoronaWarnApplication.isAppInForeground) {
-            sendNotification("", content, visibility, true)
+            sendNotification("", content, true)
         }
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationReceiver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationReceiver.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3d2b6970a8a7eb31de6f3258fd17f4088eb538a8
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationReceiver.kt
@@ -0,0 +1,33 @@
+package de.rki.coronawarnapp.notification
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import dagger.android.AndroidInjection
+import de.rki.coronawarnapp.notification.NotificationConstants.NOTIFICATION_ID
+import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_ID
+import timber.log.Timber
+import javax.inject.Inject
+
+typealias NotificationId = Int
+
+class NotificationReceiver : BroadcastReceiver() {
+
+    @Inject lateinit var testResultNotificationService: TestResultNotificationService
+
+    override fun onReceive(context: Context, intent: Intent) {
+        AndroidInjection.inject(this, context)
+        when (val notificationId = intent.getIntExtra(NOTIFICATION_ID, Int.MIN_VALUE)) {
+            POSITIVE_RESULT_NOTIFICATION_ID -> {
+                Timber.tag(TAG).v("NotificationReceiver received intent to show a positive test result notification")
+                testResultNotificationService.showPositiveTestResultNotification(notificationId)
+            }
+            else ->
+                Timber.tag(TAG).d("NotificationReceiver received an undefined notificationId: %s", notificationId)
+        }
+    }
+
+    companion object {
+        private val TAG: String? = NotificationReceiver::class.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/TestResultNotificationService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/TestResultNotificationService.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b435c2ff87d82e4c5eac5125ab2ab86d4d57be0f
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/TestResultNotificationService.kt
@@ -0,0 +1,66 @@
+package de.rki.coronawarnapp.notification
+
+import android.content.Context
+import androidx.navigation.NavDeepLinkBuilder
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_ID
+import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_INITIAL_OFFSET
+import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_INTERVAL
+import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_TOTAL_COUNT
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.ui.main.MainActivity
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.di.AppContext
+import timber.log.Timber
+import javax.inject.Inject
+
+class TestResultNotificationService @Inject constructor(
+    @AppContext private val context: Context,
+    private val timeStamper: TimeStamper
+) {
+
+    fun schedulePositiveTestResultReminder() {
+        if (LocalData.numberOfRemainingPositiveTestResultReminders < 0) {
+            Timber.v("Schedule positive test result notification")
+            LocalData.numberOfRemainingPositiveTestResultReminders = POSITIVE_RESULT_NOTIFICATION_TOTAL_COUNT
+            NotificationHelper.scheduleRepeatingNotification(
+                timeStamper.nowUTC.plus(POSITIVE_RESULT_NOTIFICATION_INITIAL_OFFSET),
+                POSITIVE_RESULT_NOTIFICATION_INTERVAL,
+                POSITIVE_RESULT_NOTIFICATION_ID
+            )
+        } else {
+            Timber.v("Positive test result notification has already been scheduled")
+        }
+    }
+
+    fun showPositiveTestResultNotification(notificationId: Int) {
+        if (LocalData.numberOfRemainingPositiveTestResultReminders > 0) {
+            LocalData.numberOfRemainingPositiveTestResultReminders -= 1
+            val pendingIntent = NavDeepLinkBuilder(context)
+                .setGraph(R.navigation.nav_graph)
+                .setComponentName(MainActivity::class.java)
+                .setDestination(R.id.submissionResultFragment)
+                .createPendingIntent()
+
+            NotificationHelper.sendNotification(
+                title = context.getString(R.string.notification_headline_share_positive_result),
+                content = context.getString(R.string.notification_body_share_positive_result),
+                notificationId = notificationId,
+                pendingIntent = pendingIntent
+            )
+        } else {
+            NotificationHelper.cancelFutureNotifications(notificationId)
+        }
+    }
+
+    fun cancelPositiveTestResultNotification() {
+        NotificationHelper.cancelFutureNotifications(POSITIVE_RESULT_NOTIFICATION_ID)
+        Timber.v("Future positive test result notifications have been canceled")
+    }
+
+    fun resetPositiveTestResultNotification() {
+        cancelPositiveTestResultNotification()
+        LocalData.numberOfRemainingPositiveTestResultReminders = Int.MIN_VALUE
+        Timber.v("Positive test result notification counter has been reset")
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt
index 314a944a7aa0dd3cb775272be6f8e687a8b9f19f..dab0da7f05f50d7b6b75c1e4d81d2187113f4c00 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt
@@ -15,8 +15,8 @@ import de.rki.coronawarnapp.exception.NoTokenException
 import de.rki.coronawarnapp.exception.UnknownBroadcastException
 import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
 import de.rki.coronawarnapp.util.coroutine.AppScope
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import kotlinx.coroutines.CoroutineScope
@@ -41,7 +41,7 @@ class ExposureStateUpdateReceiver : BroadcastReceiver() {
 
     @Inject @AppScope lateinit var scope: CoroutineScope
     @Inject lateinit var dispatcherProvider: DispatcherProvider
-    @Inject lateinit var calculationTracker: CalculationTracker
+    @Inject lateinit var exposureDetectionTracker: ExposureDetectionTracker
     lateinit var context: Context
 
     override fun onReceive(context: Context, intent: Intent) {
@@ -87,9 +87,9 @@ class ExposureStateUpdateReceiver : BroadcastReceiver() {
             .build()
             .let { workManager.enqueue(it) }
 
-        calculationTracker.finishCalculation(
+        exposureDetectionTracker.finishExposureDetection(
             token,
-            Calculation.Result.UPDATED_STATE
+            TrackedExposureDetection.Result.UPDATED_STATE
         )
     }
 
@@ -98,9 +98,9 @@ class ExposureStateUpdateReceiver : BroadcastReceiver() {
 
         val token = intent.requireToken()
 
-        calculationTracker.finishCalculation(
+        exposureDetectionTracker.finishExposureDetection(
             token,
-            Calculation.Result.NO_MATCHES
+            TrackedExposureDetection.Result.NO_MATCHES
         )
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt
index 676692bdbc9f96d1eab17e557adb44f125d38a5c..3f509e47e794d23a989136c71ba17f056c959f4a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt
@@ -2,10 +2,13 @@ package de.rki.coronawarnapp.receiver
 
 import dagger.Module
 import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.notification.NotificationReceiver
 
 @Module
 internal abstract class ReceiverBinder {
 
     @ContributesAndroidInjector
     internal abstract fun exposureUpdateReceiver(): ExposureStateUpdateReceiver
+    @ContributesAndroidInjector
+    internal abstract fun notificationReceiver(): NotificationReceiver
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt
index 4df5b3228daba8b66f098621e61dc89a8ff73ec1..68d9b8b15df9a37596fab84e1438c6272dc14954 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt
@@ -1,7 +1,7 @@
 package de.rki.coronawarnapp.risk
 
 import androidx.annotation.VisibleForTesting
-import androidx.core.app.NotificationCompat
+import androidx.core.app.NotificationManagerCompat
 import com.google.android.gms.nearby.exposurenotification.ExposureSummary
 import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.R
@@ -196,19 +196,30 @@ class DefaultRiskLevels @Inject constructor(
     @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()
+                }"
+            )
+
             NotificationHelper.sendNotification(
-                CoronaWarnApplication.getAppContext().getString(R.string.notification_body),
-                NotificationCompat.PRIORITY_HIGH
+                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)
     }
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 407fef631dd901e8995d64cff3047acc22beb5c6..81959b959a242b1a4640ffd23c399780f48cea1a 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
@@ -114,8 +114,7 @@ class RiskLevelTask @Inject constructor(
             InternalExposureNotificationClient.asyncGetExposureSummary(googleToken)
 
         return exposureSummary.also {
-            Timber.tag(TAG)
-                .v("Generated new exposure summary with $googleToken")
+            Timber.tag(TAG).v("Generated new exposure summary with $googleToken")
         }
     }
 
@@ -124,18 +123,15 @@ class RiskLevelTask @Inject constructor(
     }
 
     private suspend fun backgroundJobsEnabled() =
-            backgroundModeStatus.isAutoModeEnabled.first().also {
-                if (it) {
-                    Timber.tag(TAG)
-                            .v("diagnosis keys outdated and active tracing time is above threshold")
-                    Timber.tag(TAG)
-                            .v("manual mode not active (background jobs enabled)")
-                } else {
-                    Timber.tag(TAG)
-                            .v("diagnosis keys outdated and active tracing time is above threshold")
-                    Timber.tag(TAG).v("manual mode active (background jobs disabled)")
-                }
+        backgroundModeStatus.isAutoModeEnabled.first().also {
+            if (it) {
+                Timber.tag(TAG).v("diagnosis keys outdated and active tracing time is above threshold")
+                Timber.tag(TAG).v("manual mode not active (background jobs enabled)")
+            } else {
+                Timber.tag(TAG).v("diagnosis keys outdated and active tracing time is above threshold")
+                Timber.tag(TAG).v("manual mode active (background jobs disabled)")
             }
+        }
 
     override suspend fun cancel() {
         Timber.w("cancel() called.")
@@ -150,7 +146,6 @@ class RiskLevelTask @Inject constructor(
 
     data class Config(
         // TODO unit-test that not > 9 min
-        @Suppress("MagicNumber")
         override val executionTimeout: Duration = Duration.standardMinutes(8),
 
         override val collisionBehavior: TaskFactory.Config.CollisionBehavior =
@@ -162,7 +157,7 @@ class RiskLevelTask @Inject constructor(
         private val taskByDagger: Provider<RiskLevelTask>
     ) : TaskFactory<DefaultProgress, Result> {
 
-        override val config: TaskFactory.Config = Config()
+        override suspend fun createConfig(): TaskFactory.Config = Config()
         override val taskProvider: () -> Task<DefaultProgress, Result> = {
             taskByDagger.get()
         }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/InsufficientStorageException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/InsufficientStorageException.kt
index bb38a584438e3e1078078684667e7fa96da905d2..b2203bc075fc462b1c8429b53c3ccadbd2e895b2 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/InsufficientStorageException.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/InsufficientStorageException.kt
@@ -2,19 +2,23 @@ package de.rki.coronawarnapp.storage
 
 import android.content.Context
 import android.text.format.Formatter
-import de.rki.coronawarnapp.util.FormattedError
+import de.rki.coronawarnapp.util.HasHumanReadableError
+import de.rki.coronawarnapp.util.HumanReadableError
 import java.io.IOException
 
 class InsufficientStorageException(
     val result: DeviceStorage.CheckResult
 ) : IOException(
     "Not enough free space: ${result.requiredBytes}B are required and only ${result.freeBytes}B are available."
-), FormattedError {
+), HasHumanReadableError {
 
-    override fun getFormattedError(context: Context): String {
+    override fun toHumanReadableError(context: Context): HumanReadableError {
         val formattedRequired = Formatter.formatShortFileSize(context, result.requiredBytes)
         val formattedFree = Formatter.formatShortFileSize(context, result.freeBytes)
         // TODO Replace with localized message when the exception is logged via new error tracking.
-        return "Not enough free space: $formattedRequired are required and only $formattedFree are available."
+        return HumanReadableError(
+            description = "Not enough free space: $formattedRequired are required " +
+                "and only $formattedFree are available."
+        )
     }
 }
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 d3f73dff85afeb4999f3a293680176d75e1da8b9..04b08cdd10b8ef86ebcb046d27c86077fdc6fbf9 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
@@ -507,6 +507,13 @@ object LocalData {
             isNotificationsTestEnabledFlowInternal.value = value
         }
 
+    private const val PKEY_POSITIVE_TEST_RESULT_REMINDER_COUNT = "preference_positive_test_result_reminder_count"
+    var numberOfRemainingPositiveTestResultReminders: Int
+        get() = getSharedPreferenceInstance().getInt(PKEY_POSITIVE_TEST_RESULT_REMINDER_COUNT, Int.MIN_VALUE)
+        set(value) = getSharedPreferenceInstance().edit(true) {
+            putInt(PKEY_POSITIVE_TEST_RESULT_REMINDER_COUNT, value)
+        }
+
     /**
      * Gets the decision if background jobs are enabled
      *
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt
index ed9100cda73ba3dad4bf70ede1eda68443f3dd14..b601e6d77475a9a9a2e1bd665c0f5ba50d8d58ff 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt
@@ -108,6 +108,11 @@ object SubmissionRepository {
         }
     }
 
+    fun reset() {
+        uiStateStateFlowInternal.value = ApiRequestState.IDLE
+        deviceUIStateFlowInternal.value = DeviceUIState.UNPAIRED
+    }
+
     // TODO this should be more UI agnostic
     private suspend fun refreshUIState(refreshTestResult: Boolean) {
         var uiState = DeviceUIState.UNPAIRED
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TestSettings.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TestSettings.kt
index 11152b8ff33380629b06d97205a1ba66e8bc30f0..261fd5fcacf20a08176f1db87dbc42bb1e44b8a1 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TestSettings.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TestSettings.kt
@@ -1,9 +1,8 @@
 package de.rki.coronawarnapp.storage
 
 import android.content.Context
-import androidx.core.content.edit
-import de.rki.coronawarnapp.util.CWADebug
 import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.preferences.createFlowPreference
 import javax.inject.Inject
 import javax.inject.Singleton
 
@@ -15,16 +14,8 @@ class TestSettings @Inject constructor(
         context.getSharedPreferences("test_settings", Context.MODE_PRIVATE)
     }
 
-    var isHourKeyPkgMode: Boolean
-        get() {
-            val value = prefs.getBoolean(PKEY_HOURLY_TESTING_MODE, false)
-            return value && CWADebug.isDeviceForTestersBuild
-        }
-        set(value) = prefs.edit {
-            putBoolean(PKEY_HOURLY_TESTING_MODE, value)
-        }
-
-    companion object {
-        private const val PKEY_HOURLY_TESTING_MODE = "diagnosiskeys.hourlytestmode"
-    }
+    val fakeMeteredConnection = prefs.createFlowPreference(
+        key = "connections.metered.fake",
+        defaultValue = false
+    )
 }
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 86baa1239d015ffc5bddc26f18034b7cd98e9523..0658b10604134435f471c5ef6f351372f18bdcb0 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
@@ -69,7 +69,7 @@ class TracingRepository @Inject constructor(
         }
     val tracingProgress: Flow<TracingProgress> = combine(
         internalIsRefreshing,
-        enfClient.isCurrentlyCalculating()
+        enfClient.isPerformingExposureDetection()
     ) { isDownloading, isCalculating ->
         when {
             isDownloading -> TracingProgress.Downloading
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt
index 037dfc69052e4aa8674fde07ce589b8b2ce04f70..f37199188d60f902be1cd58ae7dab5a976f570f0 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt
@@ -16,7 +16,6 @@ class DaysSinceOnsetOfSymptomsVectorDeterminator @Inject constructor(
     private val timeStamper: TimeStamper
 ) {
 
-    @Suppress("MagicNumber")
     internal fun determine(symptoms: Symptoms): DaysSinceOnsetOfSymptomsVector {
         return when (symptoms.symptomIndication) {
             Symptoms.Indication.POSITIVE ->
@@ -28,7 +27,6 @@ class DaysSinceOnsetOfSymptomsVectorDeterminator @Inject constructor(
         }
     }
 
-    @Suppress("MagicNumber")
     private fun determinePositiveIndication(symptoms: Symptoms): DaysSinceOnsetOfSymptomsVector {
         return when (symptoms.startOfSymptoms) {
             is Symptoms.StartOf.Date -> createDaysSinceOnsetOfSymptomsVectorWith(
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt
index 7b605f10eaed4cc0b218eb2819301e931d15c7a3..2bff73211488afcd7022423df0d5200583b2406e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt
@@ -85,7 +85,6 @@ class SubmissionTask @Inject constructor(
     ) : Task.Arguments
 
     data class Config(
-        @Suppress("MagicNumber")
         override val executionTimeout: Duration = Duration.standardMinutes(8), // TODO unit-test that not > 9 min
 
         override val collisionBehavior: TaskFactory.Config.CollisionBehavior =
@@ -97,7 +96,7 @@ class SubmissionTask @Inject constructor(
         private val taskByDagger: Provider<SubmissionTask>
     ) : TaskFactory<DefaultProgress, Task.Result> {
 
-        override val config: TaskFactory.Config = Config()
+        override suspend fun createConfig(): TaskFactory.Config = Config()
         override val taskProvider: () -> Task<DefaultProgress, Task.Result> = {
             taskByDagger.get()
         }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/TransmissionRiskVectorDeterminator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/TransmissionRiskVectorDeterminator.kt
index fc87a8785cf97ffd3adbe18d82e4a04aa626d3ee..20d4eb3aa94da8c8aa225431135cf45848ae6351 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/TransmissionRiskVectorDeterminator.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/TransmissionRiskVectorDeterminator.kt
@@ -14,7 +14,6 @@ class TransmissionRiskVectorDeterminator @Inject constructor(
     private val timeStamper: TimeStamper
 ) {
 
-    @Suppress("MagicNumber")
     fun determine(symptoms: Symptoms, now: LocalDate = timeStamper.nowUTC.toLocalDate()) = TransmissionRiskVector(
         when (symptoms.symptomIndication) {
             Indication.POSITIVE -> when (symptoms.startOfSymptoms) {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt
index 77e22394784193a235c6a0793ebedf78d2ff5231..932fb9bebc875c253a4e8bbae26b8bbbb5f91c7c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt
@@ -1,6 +1,9 @@
 package de.rki.coronawarnapp.task
 
 import androidx.annotation.VisibleForTesting
+import de.rki.coronawarnapp.bugreporting.reportProblem
+import de.rki.coronawarnapp.exception.ExceptionCategory
+import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.task.TaskFactory.Config.CollisionBehavior
 import de.rki.coronawarnapp.task.internal.InternalTaskState
 import de.rki.coronawarnapp.util.TimeStamper
@@ -86,7 +89,7 @@ class TaskController @Inject constructor(
         requireNotNull(taskFactory) { "No factory available for $newRequest" }
 
         Timber.tag(TAG).v("Initiating task data for request: %s", newRequest)
-        val taskConfig = taskFactory.config
+        val taskConfig = taskFactory.createConfig()
         val task = taskFactory.taskProvider()
 
         val deferred = taskScope.async(start = CoroutineStart.LAZY) {
@@ -147,6 +150,8 @@ class TaskController @Inject constructor(
                     state.job.getCompleted()
                 } else {
                     Timber.tag(TAG).e(error, "Task failed: %s", state)
+                    error.report(ExceptionCategory.INTERNAL)
+                    error.reportProblem(tag = state.request.type.simpleName)
                     null
                 }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskFactory.kt
index 8c694e86f93e5b7450c8ea5ca2c1480f7ce7ca44..652bf1b58f63604bcc2aaa5cd17f895ea7f55ac4 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskFactory.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskFactory.kt
@@ -21,7 +21,7 @@ interface TaskFactory<
         }
     }
 
-    val config: Config
+    suspend fun createConfig(): Config
 
     val taskProvider: () -> Task<ProgressType, ResultType>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/example/QueueingTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/example/QueueingTask.kt
index 88618d3ed747e42b5980e61542634a22bca4b91d..d34f0e8890919b779fb7a51dc598213b1a255f5b 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/example/QueueingTask.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/example/QueueingTask.kt
@@ -15,7 +15,6 @@ import java.util.UUID
 import javax.inject.Inject
 import javax.inject.Provider
 
-@Suppress("MagicNumber")
 open class QueueingTask @Inject constructor() : Task<DefaultProgress, QueueingTask.Result> {
 
     private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>()
@@ -72,7 +71,7 @@ open class QueueingTask @Inject constructor() : Task<DefaultProgress, QueueingTa
         private val taskByDagger: Provider<QueueingTask>
     ) : TaskFactory<DefaultProgress, Result> {
 
-        override val config: TaskFactory.Config = Config()
+        override suspend fun createConfig(): TaskFactory.Config = Config()
         override val taskProvider: () -> Task<DefaultProgress, Result> = { taskByDagger.get() }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/TransactionCoroutineScope.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/DefaultTaskCoroutineScope.kt
similarity index 75%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/TransactionCoroutineScope.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/DefaultTaskCoroutineScope.kt
index 0e27712b8af6d356585d87e67c6576dec91421ff..c10a3c04117bfa1fb76c1cf3da536a8b8684ffab 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/TransactionCoroutineScope.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/DefaultTaskCoroutineScope.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.transaction
+package de.rki.coronawarnapp.task.internal
 
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
@@ -8,6 +8,6 @@ import javax.inject.Singleton
 import kotlin.coroutines.CoroutineContext
 
 @Singleton
-class TransactionCoroutineScope @Inject constructor() : CoroutineScope {
+class DefaultTaskCoroutineScope @Inject constructor() : CoroutineScope {
     override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/TaskModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/TaskModule.kt
index c94f6beb9e882a0cf1cd9c44374ba40637fa4333..f0169422b448ae4e1153a12fda48c7ff21f89b6d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/TaskModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/TaskModule.kt
@@ -4,7 +4,6 @@ import dagger.Module
 import dagger.Provides
 import de.rki.coronawarnapp.task.TaskCoroutineScope
 import de.rki.coronawarnapp.task.example.QueueingTaskModule
-import de.rki.coronawarnapp.transaction.TransactionCoroutineScope
 import kotlinx.coroutines.CoroutineScope
 import javax.inject.Singleton
 
@@ -17,5 +16,5 @@ class TaskModule {
     @Provides
     @Singleton
     @TaskCoroutineScope
-    fun provideScope(scope: TransactionCoroutineScope): CoroutineScope = scope
+    fun provideScope(scope: DefaultTaskCoroutineScope): CoroutineScope = scope
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
index 4b07eea491c78a3135f3bd0407a1bc041ebec75e..efb78a5cbf81f040fe8b6cac1c78e64785a86303 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
@@ -15,6 +15,7 @@ import dagger.android.AndroidInjector
 import dagger.android.DispatchingAndroidInjector
 import dagger.android.HasAndroidInjector
 import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler
 import de.rki.coronawarnapp.playbook.BackgroundNoise
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.ui.base.startActivitySafely
@@ -63,11 +64,11 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector {
 
     private lateinit var settingsViewModel: SettingsViewModel
 
-    @Inject
-    lateinit var backgroundPrioritization: BackgroundPrioritization
+    @Inject lateinit var backgroundPrioritization: BackgroundPrioritization
 
-    @Inject
-    lateinit var powerManagement: PowerManagement
+    @Inject lateinit var powerManagement: PowerManagement
+
+    @Inject lateinit var deadmanScheduler: DeadmanNotificationScheduler
 
     /**
      * Register connection callback.
@@ -105,6 +106,7 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector {
         scheduleWork()
         checkShouldDisplayBackgroundWarning()
         doBackgroundNoiseCheck()
+        deadmanScheduler.schedulePeriodic()
     }
 
     private fun doBackgroundNoiseCheck() {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
index 55e426d63f01557b91be18402f430778ec28678d..aba0e9850f7be7eb0f6945755d7259da9ff4d1b9 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
@@ -5,17 +5,20 @@ import android.os.Bundle
 import android.view.View
 import android.view.accessibility.AccessibilityEvent
 import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentHomeBinding
 import de.rki.coronawarnapp.util.DialogHelper
 import de.rki.coronawarnapp.util.ExternalActionHelper
 import de.rki.coronawarnapp.util.di.AutoInject
 import de.rki.coronawarnapp.util.errors.RecoveryByResetDialogFactory
+import de.rki.coronawarnapp.util.network.NetworkStateProvider
 import de.rki.coronawarnapp.util.ui.doNavigate
 import de.rki.coronawarnapp.util.ui.observe2
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
 import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
+import kotlinx.coroutines.launch
 import javax.inject.Inject
 
 /**
@@ -35,6 +38,7 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject {
 
     @Inject lateinit var homeMenu: HomeMenu
     @Inject lateinit var tracingExplanationDialog: TracingExplanationDialog
+    @Inject lateinit var networkStateProvider: NetworkStateProvider
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
@@ -91,14 +95,17 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject {
         }
 
         vm.showLoweredRiskLevelDialog.observe2(this) {
-            if (it) { showRiskLevelLoweredDialog() }
+            if (it) {
+                showRiskLevelLoweredDialog()
+            }
         }
+
+        lifecycleScope.launch { vm.observeTestResultToSchedulePositiveTestResultReminder() }
     }
 
     override fun onResume() {
         super.onResume()
         vm.refreshRequiredData()
-
         binding.mainScrollview.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT)
     }
 
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 c50cee70f2a186980552bb1b43d562f93ff4bd0d..ea111127b91b13506bd3a057672c47a837e4d555 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
@@ -3,6 +3,7 @@ package de.rki.coronawarnapp.ui.main.home
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.asLiveData
 import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.notification.TestResultNotificationService
 import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.service.submission.SubmissionService
 import de.rki.coronawarnapp.storage.LocalData
@@ -21,6 +22,7 @@ import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.sample
 
@@ -29,9 +31,10 @@ class HomeFragmentViewModel @AssistedInject constructor(
     private val errorResetTool: EncryptionErrorResetTool,
     tracingStatus: GeneralTracingStatus,
     tracingCardStateProvider: TracingCardStateProvider,
-    submissionCardsStateProvider: SubmissionCardsStateProvider,
+    private val submissionCardsStateProvider: SubmissionCardsStateProvider,
     val settingsViewModel: SettingsViewModel,
-    private val tracingRepository: TracingRepository
+    private val tracingRepository: TracingRepository,
+    private val testResultNotificationService: TestResultNotificationService
 ) : CWAViewModel(
     dispatcherProvider = dispatcherProvider,
     childViewModels = listOf(settingsViewModel)
@@ -44,7 +47,6 @@ class HomeFragmentViewModel @AssistedInject constructor(
     val tracingCardState: LiveData<TracingCardState> = tracingCardStateProvider.state
         .asLiveData(dispatcherProvider.Default)
 
-    @Suppress("MagicNumber")
     val submissionCardState: LiveData<SubmissionCardState> = submissionCardsStateProvider.state
         .sample(150L)
         .asLiveData(dispatcherProvider.Default)
@@ -74,6 +76,11 @@ class HomeFragmentViewModel @AssistedInject constructor(
 
     private var isLoweredRiskLevelDialogBeingShown = false
 
+    suspend fun observeTestResultToSchedulePositiveTestResultReminder() =
+        submissionCardsStateProvider.state
+            .first { it.isPositiveSubmissionCardVisible() }
+            .also { testResultNotificationService.schedulePositiveTestResultReminder() }
+
     // TODO only lazy to keep tests going which would break because of LocalData access
     val showLoweredRiskLevelDialog: LiveData<Boolean> by lazy {
         LocalData
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt
index 2b559cd3a7e0e3f5ff919070de566610b31811bc..a685bab2c0ea90d6d8ea1de895e38c40f8213928 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt
@@ -5,6 +5,7 @@ import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
+import de.rki.coronawarnapp.notification.TestResultNotificationService
 import de.rki.coronawarnapp.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.DataReset
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
@@ -14,7 +15,8 @@ import de.rki.coronawarnapp.worker.BackgroundWorkScheduler
 
 class SettingsResetViewModel @AssistedInject constructor(
     dispatcherProvider: DispatcherProvider,
-    private val dataReset: DataReset
+    private val dataReset: DataReset,
+    private val testResultNotificationService: TestResultNotificationService
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
 
     val clickEvent: SingleLiveEvent<SettingsEvents> = SingleLiveEvent()
@@ -41,6 +43,7 @@ class SettingsResetViewModel @AssistedInject constructor(
                     ExceptionCategory.EXPOSURENOTIFICATION, TAG, null
                 )
             }
+            testResultNotificationService.resetPositiveTestResultNotification()
 
             dataReset.clearAllLocalData()
             clickEvent.postValue(SettingsEvents.GoToOnboarding)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt
index a9c953118a6983ab44db4e83d669d41185e25c00..7f95fa7c859f30d8b35da16d36e28aea968e2f1d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt
@@ -40,7 +40,11 @@ class SubmissionTanFragment : Fragment(R.layout.fragment_submission_tan), AutoIn
             binding.uiState = it
 
             submission_tan_character_error.setGone(it.areCharactersCorrect)
-            submission_tan_error.setGone(it.isTanValidFormat)
+            if (it.isCorrectLength) {
+                submission_tan_error.setGone(it.isTanValid)
+            } else {
+                submission_tan_error.setGone(true)
+            }
         }
 
         binding.submissionTanContent.submissionTanInput.listener = { tan ->
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt
index c947e09a91c7507945441f918e49abe8306814a2..28dbe39bc10303fb8df48d1c1458be736bde5f4f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt
@@ -28,7 +28,8 @@ class SubmissionTanViewModel @AssistedInject constructor(
         UIState(
             isTanValid = currentTan.isTanValid,
             isTanValidFormat = currentTan.isTanValidFormat,
-            areCharactersCorrect = currentTan.areCharactersValid
+            areCharactersCorrect = currentTan.areCharactersValid,
+            isCorrectLength = currentTan.isCorrectLength
         )
     }.asLiveData(context = dispatcherProvider.Default)
 
@@ -73,7 +74,8 @@ class SubmissionTanViewModel @AssistedInject constructor(
     data class UIState(
         val isTanValid: Boolean = false,
         val areCharactersCorrect: Boolean = false,
-        val isTanValidFormat: Boolean = false
+        val isTanValidFormat: Boolean = false,
+        val isCorrectLength: Boolean = false
     )
 
     @AssistedInject.Factory
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt
index a4d883605477fbc1295047d10155bb8bb5305b22..8cfc0e09483da70ab08b16405b7a9aac1ce9bb34 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt
@@ -8,8 +8,9 @@ data class Tan(
     val value: String
 ) {
 
+    val isCorrectLength = value.length == MAX_LENGTH
     val areCharactersValid = allCharactersValid(value)
-    val isTanValidFormat = value.length == MAX_LENGTH && isChecksumValid(value)
+    val isTanValidFormat = isCorrectLength && isChecksumValid(value)
     val isTanValid = areCharactersValid && isTanValidFormat
 
     companion object {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt
index 0655e3e5893e889f563f5b0b7361e3f9e4efcf0d..a1d42d3961c96b916e3498af53c1e9347c231ca8 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt
@@ -6,6 +6,7 @@ import android.view.View
 import android.view.accessibility.AccessibilityEvent
 import androidx.activity.OnBackPressedCallback
 import androidx.fragment.app.Fragment
+import androidx.lifecycle.lifecycleScope
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentSubmissionTestResultBinding
 import de.rki.coronawarnapp.exception.http.CwaClientError
@@ -21,6 +22,7 @@ import de.rki.coronawarnapp.util.ui.observe2
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
 import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
+import kotlinx.coroutines.launch
 import javax.inject.Inject
 
 class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_result),
@@ -132,6 +134,8 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_
                     )
             }
         }
+
+        lifecycleScope.launch { viewModel.observeTestResultToSchedulePositiveTestResultReminder() }
     }
 
     override fun onResume() {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt
index 96766c26904d12ab16c85b2809fe68477248cb0d..97586ba507f291498d92a0145b88e1eacedd4e76 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt
@@ -5,6 +5,7 @@ import androidx.lifecycle.asLiveData
 import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.exception.http.CwaWebException
 import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.notification.TestResultNotificationService
 import de.rki.coronawarnapp.service.submission.SubmissionService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.SubmissionRepository
@@ -24,7 +25,8 @@ import timber.log.Timber
 
 class SubmissionTestResultViewModel @AssistedInject constructor(
     dispatcherProvider: DispatcherProvider,
-    private val enfClient: ENFClient
+    private val enfClient: ENFClient,
+    private val testResultNotificationService: TestResultNotificationService
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
 
     val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
@@ -54,6 +56,11 @@ class SubmissionTestResultViewModel @AssistedInject constructor(
         ).let { emit(it) }
     }.asLiveData(context = dispatcherProvider.Default)
 
+    suspend fun observeTestResultToSchedulePositiveTestResultReminder() =
+        SubmissionRepository.deviceUIStateFlow
+            .first { it == DeviceUIState.PAIRED_POSITIVE || it == DeviceUIState.PAIRED_POSITIVE_TELETAN }
+            .also { testResultNotificationService.schedulePositiveTestResultReminder() }
+
     val uiStateError: LiveData<Event<CwaWebException>> = SubmissionRepository.uiStateError
 
     fun onBackPressed() {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt
index 9f1591ec0cb161e753cf68dbd1add15dc65bb310..4a11a2ed4da149940286c01abacf67c6b3d8462b 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt
@@ -6,6 +6,7 @@ import com.squareup.inject.assisted.Assisted
 import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.exception.NoRegistrationTokenSetException
 import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.notification.TestResultNotificationService
 import de.rki.coronawarnapp.service.submission.SubmissionService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.interoperability.InteroperabilityRepository
@@ -31,7 +32,8 @@ class SubmissionResultPositiveOtherWarningViewModel @AssistedInject constructor(
     dispatcherProvider: DispatcherProvider,
     private val enfClient: ENFClient,
     private val taskController: TaskController,
-    interoperabilityRepository: InteroperabilityRepository
+    interoperabilityRepository: InteroperabilityRepository,
+    private val testResultNotificationService: TestResultNotificationService
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
 
     private var currentSubmissionRequestId: UUID? = null
@@ -96,6 +98,7 @@ class SubmissionResultPositiveOtherWarningViewModel @AssistedInject constructor(
             submitWithNoDiagnosisKeys()
             routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSubmissionDone)
         }
+        testResultNotificationService.cancelPositiveTestResultNotification()
     }
 
     private fun submitDiagnosisKeys(keys: List<TemporaryExposureKey>) {
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 56a85ecc4d1cfd937e39b41a301588fb56e6242c..ceb286194df6b309f4b4138964e573721ddb3bc1 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
@@ -41,13 +41,19 @@ data class TracingCardState(
      * for general information when no definite risk level
      * can be calculated
      */
-    fun getRiskBody(c: Context): String = when (riskLevelScore) {
-        RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.string.risk_card_outdated_risk_body
-        RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.string.risk_card_body_tracing_off
-        RiskLevelConstants.UNKNOWN_RISK_INITIAL -> R.string.risk_card_unknown_risk_body
-        RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> R.string.risk_card_outdated_manual_risk_body
-        else -> null
-    }?.let { c.getString(it) } ?: ""
+    fun getRiskBody(c: Context): String {
+        return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) {
+            when (riskLevelScore) {
+                RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.string.risk_card_outdated_risk_body
+                RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.string.risk_card_body_tracing_off
+                RiskLevelConstants.UNKNOWN_RISK_INITIAL -> R.string.risk_card_unknown_risk_body
+                RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> R.string.risk_card_outdated_manual_risk_body
+                else -> null
+            }?.let { c.getString(it) } ?: ""
+        } else {
+            return c.getString(R.string.risk_card_body_tracing_off)
+        }
+    }
 
     /**
      * Formats the risk card text display of last persisted risk level
@@ -55,23 +61,29 @@ data class TracingCardState(
      * the persisted risk level is of importance
      */
     fun getSavedRiskBody(c: Context): String {
-        return if (
-            riskLevelScore == RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF ||
-            riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS ||
-            riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
-        ) {
-            when (lastRiskLevelScoreCalculated) {
-                RiskLevelConstants.LOW_LEVEL_RISK,
-                RiskLevelConstants.INCREASED_RISK,
-                RiskLevelConstants.UNKNOWN_RISK_INITIAL -> {
-                    val arg = formatRiskLevelHeadline(c, lastRiskLevelScoreCalculated)
-                    c.getString(R.string.risk_card_no_calculation_possible_body_saved_risk)
-                        .format(arg)
+        return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) {
+            return if (
+                riskLevelScore == RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF ||
+                riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS ||
+                riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
+            ) {
+                when (lastRiskLevelScoreCalculated) {
+                    RiskLevelConstants.LOW_LEVEL_RISK,
+                    RiskLevelConstants.INCREASED_RISK,
+                    RiskLevelConstants.UNKNOWN_RISK_INITIAL -> {
+                        val arg = formatRiskLevelHeadline(c, lastRiskLevelScoreCalculated)
+                        c.getString(R.string.risk_card_no_calculation_possible_body_saved_risk)
+                            .format(arg)
+                    }
+                    else -> ""
                 }
-                else -> ""
+            } else {
+                ""
             }
         } else {
-            ""
+            val arg = formatRiskLevelHeadline(c, lastRiskLevelScoreCalculated)
+            c.getString(R.string.risk_card_no_calculation_possible_body_saved_risk)
+                .format(arg)
         }
     }
 
@@ -189,7 +201,17 @@ data class TracingCardState(
     */
      */
     fun getTimeFetched(c: Context): String {
-        return when (riskLevelScore) {
+        if (tracingStatus == GeneralTracingStatus.Status.TRACING_INACTIVE) {
+            return if (lastTimeDiagnosisKeysFetched != null) {
+                c.getString(
+                    R.string.risk_card_body_time_fetched,
+                    formatRelativeDateTimeString(c, lastTimeDiagnosisKeysFetched)
+                )
+            } else {
+                c.getString(R.string.risk_card_body_not_yet_fetched)
+            }
+        }
+            return when (riskLevelScore) {
             RiskLevelConstants.LOW_LEVEL_RISK,
             RiskLevelConstants.INCREASED_RISK -> {
                 if (lastTimeDiagnosisKeysFetched != null) {
@@ -224,23 +246,6 @@ data class TracingCardState(
         }
     }
 
-    /**
-     * Formats the risk card text display of time when diagnosis keys will be updated
-     * from server again when applicable
-     */
-    fun getNextUpdate(c: Context): String = if (!isBackgroundJobEnabled) {
-        ""
-    } else {
-        when (riskLevelScore) {
-            RiskLevelConstants.UNKNOWN_RISK_INITIAL,
-            RiskLevelConstants.LOW_LEVEL_RISK,
-            RiskLevelConstants.INCREASED_RISK -> c.getString(
-                R.string.risk_card_body_next_update
-            )
-            else -> ""
-        }
-    }
-
     /**
      * Formats the risk card divider color depending on risk level
      * This special handling is required due to light / dark mode differences and switches
@@ -264,15 +269,22 @@ data class TracingCardState(
 
     fun getRiskLevelHeadline(c: Context) = formatRiskLevelHeadline(c, riskLevelScore)
 
-    fun formatRiskLevelHeadline(c: Context, riskLevelScore: Int) = when (riskLevelScore) {
-        RiskLevelConstants.INCREASED_RISK -> R.string.risk_card_increased_risk_headline
-        RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.string.risk_card_outdated_risk_headline
-        RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.string.risk_card_no_calculation_possible_headline
-        RiskLevelConstants.LOW_LEVEL_RISK -> R.string.risk_card_low_risk_headline
-        RiskLevelConstants.UNKNOWN_RISK_INITIAL -> R.string.risk_card_unknown_risk_headline
-        RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> R.string.risk_card_unknown_risk_headline
-        else -> null
-    }?.let { c.getString(it) } ?: ""
+    fun formatRiskLevelHeadline(c: Context, riskLevelScore: Int): String {
+        return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) {
+            when (riskLevelScore) {
+                RiskLevelConstants.INCREASED_RISK -> R.string.risk_card_increased_risk_headline
+                RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.string.risk_card_outdated_risk_headline
+                RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF ->
+                    R.string.risk_card_no_calculation_possible_headline
+                RiskLevelConstants.LOW_LEVEL_RISK -> R.string.risk_card_low_risk_headline
+                RiskLevelConstants.UNKNOWN_RISK_INITIAL -> R.string.risk_card_unknown_risk_headline
+                RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> R.string.risk_card_unknown_risk_headline
+                else -> null
+            }?.let { c.getString(it) } ?: ""
+        } else {
+            return c.getString(R.string.risk_card_no_calculation_possible_headline)
+        }
+    }
 
     fun getProgressCardHeadline(c: Context): String = when (tracingProgress) {
         TracingProgress.Downloading -> R.string.risk_card_progress_download_headline
@@ -288,11 +300,17 @@ data class TracingCardState(
 
     fun isTracingInProgress(): Boolean = tracingProgress != TracingProgress.Idle
 
-    fun getRiskInfoContainerBackgroundTint(c: Context): ColorStateList = 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) }
+    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 {
+            return c.getColorStateList(R.color.card_no_calculation)
+        }
+    }
 }
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 0961bf1c4e421a05e19f12d6d1e583a78f781e4f..a132f0cd0f96f0d316e2d5502ce4c3958463e900 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
@@ -25,18 +25,30 @@ abstract class BaseTracingState {
     /**
      * Formats the risk card colors for default and pressed states depending on risk level
      */
-    fun getRiskColor(c: Context): Int = when (riskLevelScore) {
-        RiskLevelConstants.INCREASED_RISK -> R.color.colorSemanticHighRisk
-        RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS,
-        RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.color.colorSemanticUnknownRisk
-        RiskLevelConstants.LOW_LEVEL_RISK -> R.color.colorSemanticLowRisk
-        else -> R.color.colorSemanticNeutralRisk
-    }.let { c.getColor(it) }
+    fun getRiskColor(c: Context): Int {
+        return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) {
+            when (riskLevelScore) {
+                RiskLevelConstants.INCREASED_RISK -> R.color.colorSemanticHighRisk
+                RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS,
+                RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.color.colorSemanticUnknownRisk
+                RiskLevelConstants.LOW_LEVEL_RISK -> R.color.colorSemanticLowRisk
+                else -> R.color.colorSemanticNeutralRisk
+            }.let { c.getColor(it) }
+        } else {
+            return c.getColor(R.color.colorSemanticUnknownRisk)
+        }
+    }
 
-    fun isTracingOffRiskLevel(): Boolean = when (riskLevelScore) {
-        RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF,
-        RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> true
-        else -> false
+    fun isTracingOffRiskLevel(): Boolean {
+        return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) {
+            when (riskLevelScore) {
+                RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF,
+                RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> true
+                else -> false
+            }
+        } else {
+            return true
+        }
     }
 
     fun getStableTextColor(c: Context): Int = c.getColor(
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/RiskDetailsFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/RiskDetailsFragmentViewModel.kt
index b4fa7d034d2e38e36d72a3fd1b4762bb716a2cfa..2075f82ed79aa0788b3aeec2707de2a86c338597 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/RiskDetailsFragmentViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/RiskDetailsFragmentViewModel.kt
@@ -25,12 +25,10 @@ class RiskDetailsFragmentViewModel @AssistedInject constructor(
     childViewModels = listOf(settingsViewModel)
 ) {
 
-    @Suppress("MagicNumber")
     val tracingDetailsState: LiveData<TracingDetailsState> = tracingDetailsStateProvider.state
         .sample(150L)
         .asLiveData(dispatcherProvider.Default)
 
-    @Suppress("MagicNumber")
     val tracingCardState: LiveData<TracingCardState> = tracingCardStateProvider.state
         .map { it.copy(showDetails = true) }
         .sample(150L)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt
index 8efb6c14888cb3fb0193da546f7ea5f9cf7566d4..cade97a58323736139c16c3898b04b26944a21fe 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt
@@ -31,4 +31,13 @@ object CWADebug {
         DEVICE("device"),
         DEVICE_FOR_TESTERS("deviceForTesters")
     }
+
+    val isAUnitTest: Boolean by lazy {
+        try {
+            Class.forName("testhelpers.IsAUnitTest")
+            true
+        } catch (e: Exception) {
+            false
+        }
+    }
 }
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 b0aae90040901a60c89c9ff37e0f68e07aacc983..86690e22520a4689a14f591cf5e5edab61ef853a 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
@@ -25,6 +25,7 @@ import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.storage.AppDatabase
 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
 import de.rki.coronawarnapp.util.security.SecurityHelper
@@ -59,6 +60,8 @@ class DataReset @Inject constructor(
         SecurityHelper.resetSharedPrefs()
         // Reset the current risk level stored in LiveData
         RiskLevelRepository.reset()
+        // Reset the current states stored in LiveData
+        SubmissionRepository.reset()
         keyCacheRepository.clear()
         appConfigProvider.clear()
         interoperabilityRepository.clear()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DialogHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DialogHelper.kt
index e7a295a1643aea654def25d0a152bc62bbbf12eb..48bb77b8dbcd1ccb99cd3d3793b89f940f31bf58 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DialogHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DialogHelper.kt
@@ -18,6 +18,7 @@ object DialogHelper {
         val positiveButton: String,
         val negativeButton: String? = null,
         val cancelable: Boolean? = true,
+        val isTextSelectable: Boolean = false,
         val positiveButtonFunction: () -> Unit? = {},
         val negativeButtonFunction: () -> Unit? = {}
     ) {
@@ -31,14 +32,14 @@ object DialogHelper {
             positiveButtonFunction: () -> Unit? = {},
             negativeButtonFunction: () -> Unit? = {}
         ) : this(
-            context,
-            context.resources.getString(title),
-            context.resources.getString(message),
-            context.resources.getString(positiveButton),
-            negativeButton?.let { context.resources.getString(it) },
-            cancelable,
-            positiveButtonFunction,
-            negativeButtonFunction
+            context = context,
+            title = context.resources.getString(title),
+            message = context.resources.getString(message),
+            positiveButton = context.resources.getString(positiveButton),
+            negativeButton = negativeButton?.let { context.resources.getString(it) },
+            cancelable = cancelable,
+            positiveButtonFunction = positiveButtonFunction,
+            negativeButtonFunction = negativeButtonFunction
         )
 
         constructor(
@@ -51,21 +52,25 @@ object DialogHelper {
             positiveButtonFunction: () -> Unit? = {},
             negativeButtonFunction: () -> Unit? = {}
         ) : this(
-            context,
-            context.resources.getString(title),
-            message,
-            context.resources.getString(positiveButton),
-            negativeButton?.let { context.resources.getString(it) },
-            cancelable,
-            positiveButtonFunction,
-            negativeButtonFunction
+            context = context,
+            title = context.resources.getString(title),
+            message = message,
+            positiveButton = context.resources.getString(positiveButton),
+            negativeButton = negativeButton?.let { context.resources.getString(it) },
+            cancelable = cancelable,
+            positiveButtonFunction = positiveButtonFunction,
+            negativeButtonFunction = negativeButtonFunction
         )
     }
 
     fun showDialog(
         dialogInstance: DialogInstance
     ): AlertDialog {
-        val message = getMessage(dialogInstance.context, dialogInstance.message)
+        val message = getMessage(
+            dialogInstance.context,
+            dialogInstance.message,
+            dialogInstance.isTextSelectable
+        )
         val alertDialog: AlertDialog = dialogInstance.context.let {
             val builder = AlertDialog.Builder(it)
             builder.apply {
@@ -91,7 +96,7 @@ object DialogHelper {
         return alertDialog
     }
 
-    private fun getMessage(context: Context, message: String?): TextView {
+    private fun getMessage(context: Context, message: String?, isTextSelectable: Boolean): TextView {
         // create spannable and add links, removed stack trace links into nowhere
         val spannable = SpannableString(message)
         val httpPattern: Pattern = Pattern.compile("[a-z]+://[^ \\n]*")
@@ -107,6 +112,7 @@ object DialogHelper {
         textView.setPadding(paddingStartEnd, paddingLeftRight, paddingStartEnd, paddingLeftRight)
         textView.setTextAppearance(R.style.body1)
         textView.setLinkTextColor(context.getColorStateList(R.color.button_primary))
+        if (isTextSelectable) textView.setTextIsSelectable(true)
         return textView
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/FormattedError.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/FormattedError.kt
deleted file mode 100644
index 0639465cec8c0a42e1af49781100ac85ccba1f01..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/FormattedError.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package de.rki.coronawarnapp.util
-
-import android.content.Context
-
-interface FormattedError {
-    fun getFormattedError(context: Context): String
-}
-
-fun Throwable.tryFormattedError(context: Context): String = when (this) {
-    is FormattedError -> this.getFormattedError(context)
-    else -> (localizedMessage ?: this.message) ?: this.toString()
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HasHumanReadableError.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HasHumanReadableError.kt
new file mode 100644
index 0000000000000000000000000000000000000000..82f66fda53b9599b39dd156620343d52cdb97de6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HasHumanReadableError.kt
@@ -0,0 +1,21 @@
+package de.rki.coronawarnapp.util
+
+import android.content.Context
+
+interface HasHumanReadableError {
+    fun toHumanReadableError(context: Context): HumanReadableError
+}
+
+data class HumanReadableError(
+    val title: String? = null,
+    val description: String
+)
+
+fun Throwable.tryHumanReadableError(context: Context): HumanReadableError = when (this) {
+    is HasHumanReadableError -> this.toHumanReadableError(context)
+    else -> {
+        HumanReadableError(
+            description = (localizedMessage ?: this.message) ?: this.toString()
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt
index 665a94a67bfb156af56fbc7fcb75757bc349f4a3..68c427d28d4369de241d8dc69b51f828aac49a62 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt
@@ -7,6 +7,7 @@ import org.joda.time.DateTimeZone
 import org.joda.time.Days
 import org.joda.time.Instant
 import org.joda.time.LocalDate
+import org.joda.time.LocalTime
 import org.joda.time.chrono.GJChronology
 import org.joda.time.format.DateTimeFormat
 import timber.log.Timber
@@ -80,5 +81,7 @@ object TimeAndDateExtensions {
 
     fun LocalDate.ageInDays(now: LocalDate) = Days.daysBetween(this, now).days
 
-    fun Instant.toLocalDate() = this.toDateTime(DateTimeZone.UTC).toLocalDate()
+    fun Instant.toLocalDate(): LocalDate = this.toDateTime(DateTimeZone.UTC).toLocalDate()
+
+    fun Instant.toLocalTime(): LocalTime = this.toDateTime(DateTimeZone.UTC).toLocalTime()
 }
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 3b8359370197e92077899388a9ba931e90a5e0ba..6fdc8c4a0a4137d21f1efb3535cd7cab66cf6cc4 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
@@ -4,9 +4,11 @@ import android.app.Application
 import android.bluetooth.BluetoothAdapter
 import android.content.Context
 import androidx.core.app.NotificationManagerCompat
+import androidx.work.WorkManager
 import dagger.Module
 import dagger.Provides
 import de.rki.coronawarnapp.CoronaWarnApplication
+import de.rki.coronawarnapp.util.worker.WorkManagerProvider
 import javax.inject.Singleton
 
 @Module
@@ -30,4 +32,10 @@ class AndroidModule {
     fun notificationManagerCompat(
         @AppContext context: Context
     ): NotificationManagerCompat = NotificationManagerCompat.from(context)
+
+    @Provides
+    @Singleton
+    fun workManager(
+        workManagerProvider: WorkManagerProvider
+    ): WorkManager = workManagerProvider.workManager
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
index 71c8f4f7a8770fc2cee5a1d104f469741ba72b1b..af2cb89a00f2cbb07c2f27e5abdff7d7728abafd 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
@@ -11,7 +11,6 @@ import de.rki.coronawarnapp.bugreporting.BugReporter
 import de.rki.coronawarnapp.bugreporting.BugReportingModule
 import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule
 import de.rki.coronawarnapp.diagnosiskeys.DownloadDiagnosisKeysTaskModule
-import de.rki.coronawarnapp.diagnosiskeys.download.KeyFileDownloader
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.environment.EnvironmentModule
 import de.rki.coronawarnapp.http.HttpModule
@@ -80,7 +79,6 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
     val settingsRepository: SettingsRepository
 
     val keyCacheRepository: KeyCacheRepository
-    val keyFileDownloader: KeyFileDownloader
 
     val appConfigProvider: AppConfigProvider
 
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 25b36bc91df4887155d8ded61815c0ef1ce63ad7..701dc3239b455bff16edc0b2d656f64f1e8c43ad 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
@@ -3,6 +3,7 @@ package de.rki.coronawarnapp.util.flow
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.flow.onCompletion
@@ -17,12 +18,23 @@ import timber.log.Timber
  * The flow collector will just wait for the first value
  */
 fun <T> Flow<T>.shareLatest(
-    tag: String,
+    tag: String? = null,
     scope: CoroutineScope,
-    started: SharingStarted = SharingStarted.WhileSubscribed()
-) = onStart { Timber.v("$tag FLOW start") }
-    .onEach { Timber.v("$tag FLOW emission: %s", it) }
-    .onCompletion { Timber.v("$tag FLOW completed.") }
+    started: SharingStarted = SharingStarted.WhileSubscribed(replayExpirationMillis = 0)
+) = this
+    .onStart {
+        if (tag != null) Timber.tag(tag).v("shareLatest(...) start")
+    }
+    .onEach {
+        if (tag != null) Timber.tag(tag).v("shareLatest(...) emission: %s", it)
+    }
+    .onCompletion {
+        if (tag != null) Timber.tag(tag).v("shareLatest(...) completed.")
+    }
+    .catch {
+        if (tag != null) Timber.tag(tag).w(it, "shareLatest(...) catch()!.")
+        throw it
+    }
     .stateIn(
         scope = scope,
         started = started,
@@ -30,7 +42,7 @@ fun <T> Flow<T>.shareLatest(
     )
     .mapNotNull { it }
 
-@Suppress("UNCHECKED_CAST", "MagicNumber", "LongParameterList")
+@Suppress("UNCHECKED_CAST", "LongParameterList")
 inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, R> combine(
     flow: Flow<T1>,
     flow2: Flow<T2>,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
index c3d6d5787f6c991b9a8498e6cc3b3ede70afa522..91086980e2bd33336558e889a15272962e37bd83 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
@@ -85,14 +85,7 @@ class HotDataFlow<T : Any>(
     suspend fun updateBlocking(update: suspend T.() -> T): T {
         updateActions.tryEmit(update)
         Timber.tag(tag).v("Waiting for update.")
-        return internalFlow.first {
-            val targetUpdate = it.updatedBy
-            Timber.tag(tag).v(
-                "Comparing %s with %s; match=%b",
-                targetUpdate, update, targetUpdate == update
-            )
-            it.updatedBy == update
-        }.value.also { Timber.tag(tag).v("Returning blocking update result: %s", it) }
+        return internalFlow.first { it.updatedBy == update }.value
     }
 
     internal sealed class Holder<T> {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/TestResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/TestResult.kt
index c0cacc40a79d73c32ce47d9f4863df2c6f8bc1e9..912799429291f7b7e5c99cc9d3f0629b3acbad94 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/TestResult.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/TestResult.kt
@@ -1,6 +1,5 @@
 package de.rki.coronawarnapp.util.formatter
 
-@Suppress("MagicNumber")
 enum class TestResult(val value: Int) {
     PENDING(0),
     NEGATIVE(1),
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/BindableVH.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/BindableVH.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e8db4b808a22f40ae314a07fa3a26a69e93605ee
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/BindableVH.kt
@@ -0,0 +1,12 @@
+package de.rki.coronawarnapp.util.lists
+
+import androidx.viewbinding.ViewBinding
+
+interface BindableVH<ItemT, ViewBindingT : ViewBinding> {
+
+    val viewBinding: Lazy<ViewBindingT>
+
+    val onBindData: ViewBindingT.(item: ItemT) -> Unit
+
+    fun bind(item: ItemT) = with(viewBinding.value) { onBindData(item) }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/HasStableId.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/HasStableId.kt
new file mode 100644
index 0000000000000000000000000000000000000000..096cdebe147e8b45d360f75053d96bc45a4386b7
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/HasStableId.kt
@@ -0,0 +1,5 @@
+package de.rki.coronawarnapp.util.lists
+
+interface HasStableId {
+    val stableId: Long
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/HasPayloadDiffer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/HasPayloadDiffer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..cafc44da324f553fd852b21ee8ae3353535b7005
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/HasPayloadDiffer.kt
@@ -0,0 +1,5 @@
+package de.rki.coronawarnapp.util.lists.diffutil
+
+interface HasPayloadDiffer {
+    fun diffPayload(old: Any, new: Any): Any?
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/SmartDiffUtil.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/SmartDiffUtil.kt
new file mode 100644
index 0000000000000000000000000000000000000000..52f30d628001d62dc3b39eb63114e9025ae352ef
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/SmartDiffUtil.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.util.lists.diffutil
+
+import androidx.recyclerview.widget.AsyncListDiffer
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.RecyclerView
+import de.rki.coronawarnapp.util.lists.HasStableId
+
+interface AsyncDiffUtilAdapter<T : HasStableId> {
+
+    val data: List<T>
+        get() = asyncDiffer.currentList
+
+    val asyncDiffer: AsyncDiffer<T>
+}
+
+fun <X, T> X.update(
+    newData: List<T>?,
+    notify: Boolean = true
+) where X : AsyncDiffUtilAdapter<T>, X : RecyclerView.Adapter<*> {
+
+    if (notify) asyncDiffer.submitUpdate(newData ?: emptyList())
+}
+
+class AsyncDiffer<T : HasStableId>(
+    adapter: RecyclerView.Adapter<*>,
+    compareItem: (T, T) -> Boolean = { i1, i2 -> i1.stableId == i2.stableId },
+    compareItemContent: (T, T) -> Boolean = { i1, i2 -> i1 == i2 },
+    determinePayload: (T, T) -> Any? = { i1, i2 ->
+        when {
+            i1 is HasPayloadDiffer && i1::class.java.isInstance(i2) -> i1.diffPayload(i1, i2)
+            else -> null
+        }
+    }
+) {
+    private val callback = object : DiffUtil.ItemCallback<T>() {
+        override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = compareItem(oldItem, newItem)
+        override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = compareItemContent(oldItem, newItem)
+        override fun getChangePayload(oldItem: T, newItem: T): Any? = determinePayload(oldItem, newItem)
+    }
+
+    private val listDiffer = AsyncListDiffer(adapter, callback)
+    private val internalList = mutableListOf<T>()
+    val currentList: List<T>
+        get() = synchronized(internalList) { internalList }
+
+    init {
+        adapter.setHasStableIds(true)
+    }
+
+    fun submitUpdate(newData: List<T>) {
+        listDiffer.submitList(newData) {
+            synchronized(internalList) {
+                internalList.clear()
+                internalList.addAll(newData)
+            }
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkRequestBuilderProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkRequestBuilderProvider.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9202e1bd6d82b8cde3238c8d310a9011ef8d8f36
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkRequestBuilderProvider.kt
@@ -0,0 +1,9 @@
+package de.rki.coronawarnapp.util.network
+
+import android.net.NetworkRequest
+import javax.inject.Inject
+import javax.inject.Provider
+
+class NetworkRequestBuilderProvider @Inject constructor() : Provider<NetworkRequest.Builder> {
+    override fun get(): NetworkRequest.Builder = NetworkRequest.Builder()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkStateProvider.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f30ff1b744d11f86b36225587ae3bcbad6766d80
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkStateProvider.kt
@@ -0,0 +1,96 @@
+package de.rki.coronawarnapp.util.network
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.LinkProperties
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET
+import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
+import de.rki.coronawarnapp.storage.TestSettings
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.flow.shareLatest
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class NetworkStateProvider @Inject constructor(
+    @AppContext private val context: Context,
+    @AppScope private val appScope: CoroutineScope,
+    private val testSettings: TestSettings,
+    private val networkRequestBuilderProvider: NetworkRequestBuilderProvider
+) {
+    private val manager: ConnectivityManager
+        get() = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+
+    val networkState: Flow<State> = callbackFlow {
+        send(currentState)
+        val callback = object : ConnectivityManager.NetworkCallback() {
+            override fun onAvailable(network: Network) {
+                Timber.tag(TAG).v("onAvailable(network=%s)", network)
+                appScope.launch { send(currentState) }
+            }
+
+            override fun onUnavailable() {
+                Timber.tag(TAG).v("onUnavailable()")
+                appScope.launch { send(currentState) }
+            }
+        }
+
+        val request = networkRequestBuilderProvider.get()
+            .addCapability(NET_CAPABILITY_INTERNET)
+            .build()
+        manager.registerNetworkCallback(request, callback)
+
+        val fakeConnectionSubscriber = launch {
+            testSettings.fakeMeteredConnection.flow.drop(1)
+                .collect {
+                    Timber.v("fakeMeteredConnection=%b", it)
+                    send(currentState)
+                }
+        }
+
+        awaitClose {
+            Timber.tag(TAG).v("unregisterNetworkCallback()")
+            manager.unregisterNetworkCallback(callback)
+            fakeConnectionSubscriber.cancel()
+        }
+    }
+        .shareLatest(
+            tag = TAG,
+            scope = appScope
+        )
+
+    private val currentState: State
+        get() = manager.activeNetwork.let { network ->
+            State(
+                activeNetwork = network,
+                capabilities = network?.let { manager.getNetworkCapabilities(it) },
+                linkProperties = network?.let { manager.getLinkProperties(it) },
+                isFakeMeteredConnection = testSettings.fakeMeteredConnection.value
+            )
+        }
+
+    data class State(
+        val activeNetwork: Network?,
+        val capabilities: NetworkCapabilities?,
+        val linkProperties: LinkProperties?,
+        private val isFakeMeteredConnection: Boolean = false
+    ) {
+        val isMeteredConnection: Boolean
+            get() = isFakeMeteredConnection || !(capabilities?.hasCapability(NET_CAPABILITY_NOT_METERED) ?: false)
+    }
+
+    companion object {
+        private const val TAG = "NetworkStateProvider"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/preferences/FlowPreference.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/preferences/FlowPreference.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c64cbedfde89f3c164c26e3eb1ea2204102d2bd3
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/preferences/FlowPreference.kt
@@ -0,0 +1,77 @@
+package de.rki.coronawarnapp.util.preferences
+
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import com.google.gson.Gson
+import de.rki.coronawarnapp.util.serialization.fromJson
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+class FlowPreference<T> constructor(
+    private val preferences: SharedPreferences,
+    private val key: String,
+    private val reader: SharedPreferences.(key: String) -> T,
+    private val writer: SharedPreferences.Editor.(key: String, value: T) -> Unit
+) {
+
+    private val flowInternal = MutableStateFlow(internalValue)
+    val flow: Flow<T> = flowInternal
+
+    private var internalValue: T
+        get() = reader(preferences, key)
+        set(newValue) {
+            preferences.edit {
+                writer(key, newValue)
+            }
+            flowInternal.value = internalValue
+        }
+    val value: T
+        get() = internalValue
+
+    fun update(update: (T) -> T) {
+        internalValue = update(internalValue)
+    }
+
+    companion object {
+        inline fun <reified T> gsonReader(
+            gson: Gson,
+            defaultValue: T
+        ): SharedPreferences.(key: String) -> T = { key ->
+            getString(key, null)?.let { gson.fromJson<T>(it) } ?: defaultValue
+        }
+
+        inline fun <reified T> gsonWriter(
+            gson: Gson
+        ): SharedPreferences.Editor.(key: String, value: T) -> Unit = { key, value ->
+            putString(key, value?.let { gson.toJson(it) })
+        }
+
+        inline fun <reified T> basicReader(defaultValue: T): SharedPreferences.(key: String) -> T =
+            { key ->
+                (this.all[key] ?: defaultValue) as T
+            }
+
+        inline fun <reified T> basicWriter(): SharedPreferences.Editor.(key: String, value: T) -> Unit =
+            { key, value ->
+                when (value) {
+                    is Boolean -> putBoolean(key, value)
+                    is String -> putString(key, value)
+                    is Int -> putInt(key, value)
+                    is Long -> putLong(key, value)
+                    is Float -> putFloat(key, value)
+                    null -> remove(key)
+                    else -> throw NotImplementedError()
+                }
+            }
+    }
+}
+
+inline fun <reified T : Any?> SharedPreferences.createFlowPreference(
+    key: String,
+    defaultValue: T = null as T
+) = FlowPreference(
+    preferences = this,
+    key = key,
+    reader = FlowPreference.basicReader(defaultValue),
+    writer = FlowPreference.basicWriter()
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/retrofit/RetrofitExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/retrofit/RetrofitExtensions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..aad0801ac35eb4dd4bf5a0581d019e82a42cbcc6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/retrofit/RetrofitExtensions.kt
@@ -0,0 +1,5 @@
+package de.rki.coronawarnapp.util.retrofit
+
+import okhttp3.Headers
+
+fun Headers.etag(): String? = values("ETag").singleOrNull()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt
similarity index 74%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt
index 3e7c0ee22be629676acafc785136f3a64af69f17..dff4a55e38f96608003dd00059d3c9e64e777aa2 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt
@@ -9,20 +9,23 @@ import javax.inject.Inject
 import javax.inject.Singleton
 
 @Singleton
-class WorkManagerSetup @Inject constructor(
+class WorkManagerProvider @Inject constructor(
     @AppContext private val context: Context,
     private val cwaWorkerFactory: CWAWorkerFactory
 ) {
 
-    fun setup() {
+    val workManager by lazy {
         Timber.v("Setting up WorkManager.")
         val configuration = Configuration.Builder().apply {
             setMinimumLoggingLevel(android.util.Log.DEBUG)
             setWorkerFactory(cwaWorkerFactory)
         }.build()
 
+        Timber.v("WorkManager initialize...")
         WorkManager.initialize(context, configuration)
 
-        Timber.v("WorkManager setup done.")
+        WorkManager.getInstance(context).also {
+            Timber.v("WorkManager setup done: %s", it)
+        }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt
index 7bae929f22ca532f2efcb9103cd5407e4a5e6438..99541ed60f1dfc69b41bb3a354af2dc78798d937 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt
@@ -4,6 +4,8 @@ import androidx.work.ListenableWorker
 import dagger.Binds
 import dagger.Module
 import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.deadman.DeadmanNotificationOneTimeWorker
+import de.rki.coronawarnapp.deadman.DeadmanNotificationPeriodicWorker
 import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker
 import de.rki.coronawarnapp.worker.BackgroundNoiseOneTimeWorker
 import de.rki.coronawarnapp.worker.BackgroundNoisePeriodicWorker
@@ -55,4 +57,18 @@ abstract class WorkerBinder {
     abstract fun testResultRetrievalPeriodic(
         factory: DiagnosisTestResultRetrievalPeriodicWorker.Factory
     ): InjectedWorkerFactory<out ListenableWorker>
+
+    @Binds
+    @IntoMap
+    @WorkerKey(DeadmanNotificationOneTimeWorker::class)
+    abstract fun deadmanNotificationOneTime(
+        factory: DeadmanNotificationOneTimeWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
+
+    @Binds
+    @IntoMap
+    @WorkerKey(DeadmanNotificationPeriodicWorker::class)
+    abstract fun deadmanNotificationPeriodic(
+        factory: DeadmanNotificationPeriodicWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt
index f15edf5567a084410d3accf8e9cd529b4f1a6d0a..75b991b690e2f25d6c7d474257765028129c0c80 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt
@@ -64,18 +64,6 @@ object BackgroundConstants {
      */
     const val MINUTES_IN_DAY = 1440
 
-    /**
-     * Total tries count for diagnosis key retrieval per day
-     * Internal requirement
-     */
-    const val DIAGNOSIS_KEY_RETRIEVAL_TRIES_PER_DAY = 12
-
-    /**
-     * Maximum tries count for diagnosis key retrieval per day
-     * Google API limit
-     */
-    const val GOOGLE_API_MAX_CALLS_PER_DAY = 20
-
     /**
      * Total tries count for diagnosis key retrieval per day
      * Internal requirement
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt
index 4dfc187b4cb173eba23e66924001d1925a686999..b77d7c9d79b47591ac2ae4cd42a52eca7ef227fa 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt
@@ -7,6 +7,7 @@ import com.squareup.inject.assisted.Assisted
 import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.playbook.Playbook
 import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
+import timber.log.Timber
 
 /**
  * One time background noise worker
@@ -25,6 +26,7 @@ class BackgroundNoiseOneTimeWorker @AssistedInject constructor(
      * @return Result
      */
     override suspend fun doWork(): Result {
+        Timber.d("$id: doWork() started. Run attempt: $runAttemptCount")
         var result = Result.success()
 
         try {
@@ -38,6 +40,7 @@ class BackgroundNoiseOneTimeWorker @AssistedInject constructor(
             }
         }
 
+        Timber.d("$id: doWork() finished with %s", result)
         return result
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
index 3869efb0a2ff59d518a538c814ff821cb8b6e30f..15b175f853c7dd9ffdc7385c4130b0460d293232 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
@@ -34,7 +34,7 @@ class BackgroundNoisePeriodicWorker @AssistedInject constructor(
      * @see BackgroundConstants.NUMBER_OF_DAYS_TO_RUN_PLAYBOOK
      */
     override suspend fun doWork(): Result {
-        Timber.d("Background job started. Run attempt: $runAttemptCount")
+        Timber.d("$id: doWork() started. Run attempt: $runAttemptCount")
 
         var result = Result.success()
         try {
@@ -57,11 +57,13 @@ class BackgroundNoisePeriodicWorker @AssistedInject constructor(
                 Result.retry()
             }
         }
+        Timber.d("$id: doWork() finished with %s", result)
         return result
     }
 
     private fun stopWorker() {
         BackgroundWorkScheduler.WorkType.BACKGROUND_NOISE_PERIODIC_WORK.stop()
+        Timber.d("$id: worker stopped")
     }
 
     @AssistedInject.Factory
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt
index 4578ebaac7e5e5a775478b737e6d785c48113b03..b109e8ba261979dc70e6760555a0a1c772bcf301 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt
@@ -11,7 +11,7 @@ import java.util.concurrent.TimeUnit
  * Set "kind delay" for accessibility reason.
  * Backoff criteria set to Linear type.
  *
- * @return PeriodicWorkRequest
+ * The launchInterval is 60 minutes as we want to check every hour, for new hour packages on the CDN.
  *
  * @see WorkTag.DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER
  * @see BackgroundConstants.KIND_DELAY
@@ -19,9 +19,7 @@ import java.util.concurrent.TimeUnit
  * @see BackoffPolicy.LINEAR
  */
 fun buildDiagnosisKeyRetrievalPeriodicWork() =
-    PeriodicWorkRequestBuilder<DiagnosisKeyRetrievalPeriodicWorker>(
-        BackgroundWorkHelper.getDiagnosisKeyRetrievalPeriodicWorkTimeInterval(), TimeUnit.MINUTES
-    )
+    PeriodicWorkRequestBuilder<DiagnosisKeyRetrievalPeriodicWorker>(60, TimeUnit.MINUTES)
         .addTag(WorkTag.DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER.tag)
         .setInitialDelay(
             BackgroundConstants.KIND_DELAY,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt
index 9e9fb0a702ba1f8268bd0e1c231776fccb7f55fe..2b8f2cebec6728c11a56ad81194fdb577c10720d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt
@@ -1,6 +1,5 @@
 package de.rki.coronawarnapp.worker
 
-import androidx.core.app.NotificationCompat
 import androidx.work.Constraints
 import androidx.work.NetworkType
 import de.rki.coronawarnapp.notification.NotificationHelper
@@ -17,17 +16,6 @@ import kotlin.random.Random
  */
 object BackgroundWorkHelper {
 
-    /**
-     * Calculate the time for diagnosis key retrieval periodic work
-     *
-     * @return Long
-     *
-     * @see BackgroundConstants.MINUTES_IN_DAY
-     * @see getDiagnosisKeyRetrievalMaximumCalls
-     */
-    fun getDiagnosisKeyRetrievalPeriodicWorkTimeInterval(): Long =
-        (BackgroundConstants.MINUTES_IN_DAY / getDiagnosisKeyRetrievalMaximumCalls()).toLong()
-
     /**
      * Calculate the time for diagnosis key retrieval periodic work
      *
@@ -39,18 +27,6 @@ object BackgroundWorkHelper {
         (BackgroundConstants.MINUTES_IN_DAY /
                 BackgroundConstants.DIAGNOSIS_TEST_RESULT_RETRIEVAL_TRIES_PER_DAY).toLong()
 
-    /**
-     * Get maximum calls count to Google API
-     *
-     * @return Long
-     *
-     * @see BackgroundConstants.DIAGNOSIS_KEY_RETRIEVAL_TRIES_PER_DAY
-     * @see BackgroundConstants.GOOGLE_API_MAX_CALLS_PER_DAY
-     */
-    fun getDiagnosisKeyRetrievalMaximumCalls() =
-        BackgroundConstants.DIAGNOSIS_KEY_RETRIEVAL_TRIES_PER_DAY
-            .coerceAtMost(BackgroundConstants.GOOGLE_API_MAX_CALLS_PER_DAY)
-
     /**
      * Get background noise one time work delay
      * The periodic job is already delayed by MIN_HOURS_TO_NEXT_BACKGROUND_NOISE_EXECUTION
@@ -93,6 +69,6 @@ object BackgroundWorkHelper {
     fun sendDebugNotification(title: String, content: String) {
         Timber.d("sendDebugNotification(title=%s, content=%s)", title, content)
         if (!LocalData.backgroundNotification()) return
-        NotificationHelper.sendNotification(title, content, NotificationCompat.PRIORITY_HIGH, true)
+        NotificationHelper.sendNotification(title, content, true)
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
index e899972775e9bcaf7a645bdd3e2d6013f0985b9a..60dc57174abac3d7ed941ad2dd3d7662da4066e6 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
@@ -1,7 +1,6 @@
 package de.rki.coronawarnapp.worker
 
 import android.content.Context
-import androidx.core.app.NotificationCompat
 import androidx.work.CoroutineWorker
 import androidx.work.WorkerParameters
 import com.squareup.inject.assisted.Assisted
@@ -44,19 +43,20 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
      */
     override suspend fun doWork(): Result {
 
-        Timber.d("Background job started. Run attempt: $runAttemptCount")
+        Timber.d("$id: doWork() started. Run attempt: $runAttemptCount")
         BackgroundWorkHelper.sendDebugNotification(
             "TestResult Executing: Start", "TestResult started. Run attempt: $runAttemptCount "
         )
 
         if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
-            Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling")
+            Timber.d("$id doWork() failed after $runAttemptCount attempts. Rescheduling")
 
             BackgroundWorkHelper.sendDebugNotification(
                 "TestResult Executing: Failure", "TestResult failed with $runAttemptCount attempts"
             )
-
             BackgroundWorkScheduler.scheduleDiagnosisKeyPeriodicWork()
+            Timber.d("$id Rescheduled background worker")
+
             return Result.failure()
         }
         var result = Result.success()
@@ -66,10 +66,13 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
                     System.currentTimeMillis()
                 ) < BackgroundConstants.POLLING_VALIDITY_MAX_DAYS
             ) {
+                Timber.d(" $id maximum days not exceeded")
                 val testResult = SubmissionService.asyncRequestTestResult()
                 initiateNotification(testResult)
+                Timber.d(" $id Test Result Notification Initiated")
             } else {
                 stopWorker()
+                Timber.d(" $id worker stopped")
             }
         } catch (e: Exception) {
             result = Result.retry()
@@ -78,6 +81,7 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
         BackgroundWorkHelper.sendDebugNotification(
             "TestResult Executing: End", "TestResult result: $result "
         )
+        Timber.d("$id: doWork() finished with %s", result)
 
         return result
     }
@@ -95,9 +99,10 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
      */
     private fun initiateNotification(testResult: TestResult) {
         if (LocalData.isTestResultNotificationSent() || LocalData.submissionWasSuccessful()) {
+            Timber.d("$id: Notification already sent or there was a successful submission")
             return
         }
-
+        Timber.d("$id: Test Result retried is $testResult")
         if (testResult == TestResult.NEGATIVE || testResult == TestResult.POSITIVE ||
             testResult == TestResult.INVALID
         ) {
@@ -106,9 +111,9 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
                     CoronaWarnApplication.getAppContext()
                         .getString(R.string.notification_name),
                     CoronaWarnApplication.getAppContext()
-                        .getString(R.string.notification_body),
-                    NotificationCompat.PRIORITY_HIGH
+                        .getString(R.string.notification_body)
                 )
+                Timber.d("$id: Test Result available and notification is initiated")
             }
             LocalData.isTestResultNotificationSent(true)
             stopWorker()
@@ -124,7 +129,7 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
     private fun stopWorker() {
         LocalData.initialPollingForTestResultTimeStamp(0L)
         BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop()
-
+        Timber.d("$id: Background worker stopped")
         BackgroundWorkHelper.sendDebugNotification(
             "TestResult Stopped", "TestResult Stopped"
         )
diff --git a/Corona-Warn-App/src/main/res/layout/include_risk_card_content.xml b/Corona-Warn-App/src/main/res/layout/include_risk_card_content.xml
index bcbad4136a1425ebd1944300bc65b678a808e89b..4d3f7ab8344ee894a5054d61f9707791cf14f687 100644
--- a/Corona-Warn-App/src/main/res/layout/include_risk_card_content.xml
+++ b/Corona-Warn-App/src/main/res/layout/include_risk_card_content.xml
@@ -229,33 +229,6 @@
                 app:layout_constraintStart_toStartOf="parent"
                 app:layout_constraintTop_toBottomOf="@+id/risk_card_row_saved_days" />
 
-            <include
-                android:id="@+id/risk_card_next_update_divider"
-                gone="@{tracingCard.getNextUpdate(context).empty}"
-                layout="@layout/include_divider"
-                android:layout_width="@dimen/match_constraint"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/spacing_normal"
-                app:dividerColor="@{tracingCard.getStableDividerColor(context)}"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@+id/risk_card_row_time_fetched" />
-
-            <TextView
-                android:id="@+id/risk_card_next_update_test"
-                style="@style/body2"
-                gone="@{tracingCard.getNextUpdate(context).empty}"
-                android:layout_width="@dimen/match_constraint"
-                android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/spacing_small"
-                android:text="@{tracingCard.getNextUpdate(context)}"
-                android:textColor="@{tracingCard.getStableTextColor(context)}"
-                app:layout_constraintEnd_toEndOf="parent"
-                app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@+id/risk_card_next_update_divider"
-                tools:text="@string/risk_card_body_next_update"
-                tools:textColor="@color/colorStableLight" />
-
             <Button
                 android:id="@+id/risk_card_button_enable_tracing"
                 style="@style/buttonPrimary"
@@ -266,7 +239,7 @@
                 android:text="@string/risk_details_button_enable_tracing"
                 app:layout_constraintEnd_toEndOf="parent"
                 app:layout_constraintStart_toStartOf="parent"
-                app:layout_constraintTop_toBottomOf="@+id/risk_card_next_update_test" />
+                app:layout_constraintTop_toBottomOf="@+id/risk_card_row_time_fetched" />
 
             <Button
                 android:id="@+id/risk_card_button_update"
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 6856b0f41124fe28851d80b913a5e61e3b017c3c..e4c773c8c238ca47b667f88f9ecdcf0b4ebba675 100644
--- a/Corona-Warn-App/src/main/res/values-de/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-de/strings.xml
@@ -158,8 +158,6 @@
     <string name="risk_card_body_not_yet_fetched">"Begegnungen wurden noch nicht überprüft."</string>
     <!-- XTXT: risk card - last successful update -->
     <string name="risk_card_body_time_fetched">"Aktualisiert: %1$s"</string>
-    <!-- XTXT: risk card - next update -->
-    <string name="risk_card_body_next_update">"Tägliche Aktualisierung"</string>
     <!-- XTXT: risk card - hint to open the app daily -->
     <string name="risk_card_body_open_daily">"Hinweis: Bitte öffnen Sie die App täglich, um den Risikostatus zu aktualisieren."</string>
     <!-- XBUT: risk card - update risk -->
@@ -341,9 +339,9 @@
     <!-- XHED: risk details - infection period logged headling, below behaviors -->
     <string name="risk_details_headline_period_logged">"Ermittlungszeitraum"</string>
     <!-- XHED: risk details - infection period logged headling, below behaviors -->
-    <string name="risk_details_subtitle_period_logged">"Dieser Zeitraum wird berücksichtigt"</string>
+    <string name="risk_details_subtitle_period_logged">"Dieser Zeitraum wird berücksichtigt."</string>
     <!-- XHED: risk details - infection period logged information body, below behaviors -->
-    <string name="risk_details_information_body_period_logged">"Die Berechnung des Infektionsrisikos kann nur für die Zeiträume erfolgen, an denen die Risiko-Ermittlung aktiv war. Die Risiko-Ermittlung sollte daher dauerhaft aktiv sein"</string>
+    <string name="risk_details_information_body_period_logged">"Die Berechnung des Infektionsrisikos kann nur für die Zeiträume erfolgen, an denen die Risiko-Ermittlung aktiv war. Die Risiko-Ermittlung sollte daher dauerhaft aktiv sein."</string>
     <!-- XHED: risk details - infection period logged information body, below behaviors -->
     <string name="risk_details_information_body_period_logged_assessment">"Für Ihre Risiko-Ermittlung wird nur der Zeitraum der letzten 14 Tage betrachtet. In diesem Zeitraum war Ihre Risiko-Ermittlung für eine Gesamtdauer von %1$s Tagen aktiv. Ältere Tage werden automatisch gelöscht, da sie aus Sicht des Infektionsschutzes nicht mehr relevant sind."</string>
     <!-- XHED: risk details - how your risk level was calculated, below behaviors -->
@@ -1213,7 +1211,10 @@
     <string name="errors_google_update_needed">"Ihre Corona-Warn-App ist korrekt installiert. Leider fehlt dem Betriebssystem Ihres Smartphones der Dienst „COVID-19-Benachrichtigungen“ und Sie können die Corona-Warn-App nicht nutzen. Weitere Informationen finden Sie in unseren FAQ: https://www.coronawarn.app/de/faq/"</string>
     <!-- XTXT: error dialog - either Google API Error (10) or reached request limit per day -->
     <string name="errors_google_api_error">"Ihre Corona-Warn-App läuft fehlerfrei. Leider können Sie Ihren Risikostatus im Moment nicht aktualisieren. Ihre Risiko-Ermittlung ist weiterhin aktiv und funktioniert. Weitere Informationen finden Sie in unseren FAQ: https://www.coronawarn.app/de/faq/"</string>
-
+    <!-- XTXT: error dialog - Error title when the provideDiagnosisKeys quota limit was reached. -->
+    <string name="errors_risk_detection_limit_reached_title">"Limit bereits erreicht"</string>
+    <!-- XTXT: error dialog - Error description when the provideDiagnosisKeys quota limit was reached. -->
+    <string name="errors_risk_detection_limit_reached_description">"Heute sind keine weiteren Risiko-Überprüfungen möglich, weil das von Ihrem Betriebssystem festgelegte Limit von Risiko-Überprüfungen pro Tag bereits erreicht ist. Bitte überprüfen Sie Ihren Risikostatus morgen wieder."</string>
     <!-- ####################################
                Generic Error Messages
         ###################################### -->
diff --git a/Corona-Warn-App/src/main/res/values/strings.xml b/Corona-Warn-App/src/main/res/values/strings.xml
index d135fef1639ced57593c5caa26055e71dc66be0c..266628ab2b4af25556ecb991a8911cff5a40eea2 100644
--- a/Corona-Warn-App/src/main/res/values/strings.xml
+++ b/Corona-Warn-App/src/main/res/values/strings.xml
@@ -1218,6 +1218,10 @@
     <string name="errors_google_update_needed">"Your Corona-Warn-App is correctly installed, but the \"COVID-19 Exposure Notifications System\" is not available on your smartphone\'s operating system. This means that you cannot use the Corona-Warn-App. For further information, please see our FAQ page: https://www.coronawarn.app/en/faq/"</string>
     <!-- XTXT: error dialog - either Google API Error (10) or reached request limit per day -->
     <string name="errors_google_api_error">"The Corona-Warn-App is running correctly, but we cannot update your current risk status. Exposure logging remains active and is working correctly. For further information, please see our FAQ page: https://www.coronawarn.app/en/faq/"</string>
+    <!-- XTXT: error dialog - Error title when the provideDiagnosisKeys quota limit was reached. -->
+    <string name="errors_risk_detection_limit_reached_title" />
+    <!-- XTXT: error dialog - Error description when the provideDiagnosisKeys quota limit was reached. -->
+    <string name="errors_risk_detection_limit_reached_description" />
 
     <!-- ####################################
                Generic Error Messages
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
index 00fcd329fc1e5456231e032cdfe1788531ba5f6f..8f5817cf7441619cc98c5d9296f7bafa0bbf8718 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
@@ -6,12 +6,12 @@ import io.mockk.MockKAnnotations
 import io.mockk.Runs
 import io.mockk.clearAllMocks
 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 kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.flow.first
 import org.joda.time.Duration
 import org.joda.time.Instant
 import org.junit.jupiter.api.AfterEach
@@ -43,7 +43,8 @@ class AppConfigProviderTest : BaseIOTest() {
             serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
             localOffset = Duration.ZERO,
             mappedConfig = configData,
-            isFallback = false
+            identifier = "identifier",
+            configType = ConfigData.Type.FROM_SERVER
         )
         coEvery { source.clear() } just Runs
         coEvery { source.retrieveConfig() } returns testConfigDownload
@@ -65,44 +66,73 @@ class AppConfigProviderTest : BaseIOTest() {
 
     @Test
     fun `appConfig is observable`() = runBlockingTest2(ignoreActive = true) {
+        var counter = 0
+        coEvery { source.retrieveConfig() } answers {
+            DefaultConfigData(
+                serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
+                localOffset = Duration.ZERO,
+                mappedConfig = configData,
+                identifier = "${++counter}",
+                configType = ConfigData.Type.FROM_SERVER
+            )
+        }
+
         val instance = createInstance(this)
 
         val testCollector = instance.currentConfig.test(startOnScope = this)
 
         instance.getAppConfig()
-        instance.clear()
+        instance.getAppConfig()
         instance.getAppConfig()
 
-        advanceUntilIdle()
+        testCollector.cancel()
 
-        testCollector.latestValues shouldBe listOf(
-            null,
-            testConfigDownload,
-            null,
-            testConfigDownload
-        )
+        advanceUntilIdle()
 
         coVerifySequence {
             source.retrieveConfig()
-            source.clear()
+            source.retrieveConfig()
+            source.retrieveConfig()
             source.retrieveConfig()
         }
     }
 
     @Test
-    fun `clear clears storage and current config`() = runBlockingTest2(ignoreActive = true) {
+    fun `appConfig uses WHILE_SUBSCRIBED mode`() = runBlockingTest2(ignoreActive = true) {
         val instance = createInstance(this)
 
-        instance.getAppConfig() shouldBe testConfigDownload
-        instance.currentConfig.first() shouldBe testConfigDownload
+        val testCollector1 = instance.currentConfig.test(startOnScope = this)
+        coVerify(exactly = 1) { source.retrieveConfig() }
 
-        instance.clear()
+        // Was still active
+        val testCollector2 = instance.currentConfig.test(startOnScope = this)
+        advanceUntilIdle()
+        testCollector2.cancel()
+
+        // Was still active
+        val testCollector3 = instance.currentConfig.test(startOnScope = this)
+        advanceUntilIdle()
+        testCollector3.cancel()
 
-        instance.currentConfig.first() shouldBe null
+        coVerify(exactly = 1) { source.retrieveConfig() }
+        testCollector1.cancel() // Last subscriber
+        advanceUntilIdle()
 
-        coVerifySequence {
-            source.retrieveConfig()
+        // Restarts the HotDataFlow
+        val testCollector4 = instance.currentConfig.test(startOnScope = this)
+        advanceUntilIdle()
+        testCollector4.cancel()
+
+        coVerify(exactly = 2) { source.retrieveConfig() }
+    }
 
+    @Test
+    fun `clear clears storage and current config`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance(this)
+
+        instance.clear()
+
+        coVerifySequence {
             source.clear()
         }
     }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt
index a4ba54e99f2a9ad6630597cbfcec4f3601a91021..2a3bf75048881cb9f6acb22bcb84e7ee8511abcc 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt
@@ -3,6 +3,7 @@ package de.rki.coronawarnapp.appconfig
 import de.rki.coronawarnapp.appconfig.download.AppConfigServer
 import de.rki.coronawarnapp.appconfig.download.AppConfigStorage
 import de.rki.coronawarnapp.appconfig.download.ConfigDownload
+import de.rki.coronawarnapp.appconfig.download.DefaultAppConfigSource
 import de.rki.coronawarnapp.appconfig.mapping.ConfigParser
 import de.rki.coronawarnapp.util.TimeStamper
 import io.kotest.matchers.shouldBe
@@ -15,6 +16,8 @@ import io.mockk.coVerifyOrder
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
 import io.mockk.just
+import io.mockk.verify
+import kotlinx.coroutines.test.runBlockingTest
 import okio.ByteString.Companion.decodeHex
 import org.joda.time.Duration
 import org.joda.time.Instant
@@ -34,13 +37,15 @@ class AppConfigSourceTest : BaseIOTest() {
     @MockK lateinit var configParser: ConfigParser
     @MockK lateinit var configData: ConfigData
     @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var appConfigDefaultFallback: DefaultAppConfigSource
 
     private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
 
     private var testConfigDownload = ConfigDownload(
         rawData = APPCONFIG_RAW,
         serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
-        localOffset = Duration.standardHours(1)
+        localOffset = Duration.standardHours(1),
+        etag = "etag"
     )
 
     private var mockConfigStorage: ConfigDownload? = null
@@ -74,6 +79,7 @@ class AppConfigSourceTest : BaseIOTest() {
         server = configServer,
         storage = configStorage,
         parser = configParser,
+        defaultAppConfig = appConfigDefaultFallback,
         dispatcherProvider = TestDispatcherProvider
     )
 
@@ -84,12 +90,14 @@ class AppConfigSourceTest : BaseIOTest() {
             serverTime = mockConfigStorage!!.serverTime,
             localOffset = mockConfigStorage!!.localOffset,
             mappedConfig = configData,
-            isFallback = false
+            configType = ConfigData.Type.FROM_SERVER,
+            identifier = "etag"
         )
 
         mockConfigStorage shouldBe testConfigDownload
 
         coVerify { configStorage.setStoredConfig(testConfigDownload) }
+        verify(exactly = 0) { appConfigDefaultFallback.getRawDefaultConfig() }
     }
 
     @Test
@@ -101,8 +109,11 @@ class AppConfigSourceTest : BaseIOTest() {
             serverTime = mockConfigStorage!!.serverTime,
             localOffset = mockConfigStorage!!.localOffset,
             mappedConfig = configData,
-            isFallback = true
+            configType = ConfigData.Type.LAST_RETRIEVED,
+            identifier = "etag"
         )
+
+        verify(exactly = 0) { appConfigDefaultFallback.getRawDefaultConfig() }
     }
 
     @Test
@@ -131,6 +142,25 @@ class AppConfigSourceTest : BaseIOTest() {
         }
     }
 
+    @Test
+    fun `local default config is used as last resort`() = runBlockingTest {
+        coEvery { configServer.downloadAppConfig() } throws IOException()
+        coEvery { configStorage.getStoredConfig() } returns null
+        every { appConfigDefaultFallback.getRawDefaultConfig() } returns APPCONFIG_RAW
+
+        val instance = createInstance()
+
+        instance.retrieveConfig() shouldBe DefaultConfigData(
+            serverTime = Instant.EPOCH,
+            localOffset = Duration.standardHours(12),
+            mappedConfig = configData,
+            configType = ConfigData.Type.LOCAL_DEFAULT,
+            identifier = "fallback.local"
+        )
+
+        verify { appConfigDefaultFallback.getRawDefaultConfig() }
+    }
+
     companion object {
         private val APPCONFIG_RAW = (
             "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt
index eda32b98ef41d9a00ff05020c9e24f3551e5cbd8..db28c6d6d7c8456eac64fc06250071511e1df0d8 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt
@@ -63,7 +63,10 @@ class AppConfigServerTest : BaseIOTest() {
     fun `application config download`() = runBlockingTest {
         coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
             APPCONFIG_BUNDLE.toResponseBody(),
-            Headers.headersOf("Date", "Tue, 03 Nov 2020 08:46:03 GMT")
+            Headers.headersOf(
+                "Date", "Tue, 03 Nov 2020 08:46:03 GMT",
+                "ETag", "I am an ETag :)!"
+            )
         )
 
         val downloadServer = createInstance()
@@ -75,7 +78,8 @@ class AppConfigServerTest : BaseIOTest() {
             localOffset = Duration(
                 Instant.parse("2020-11-03T08:46:03.000Z"),
                 Instant.ofEpochMilli(123456789)
-            )
+            ),
+            etag = "I am an ETag :)!"
         )
 
         verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) }
@@ -111,7 +115,10 @@ class AppConfigServerTest : BaseIOTest() {
     @Test
     fun `missing server date leads to local time fallback`() = runBlockingTest {
         coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
-            APPCONFIG_BUNDLE.toResponseBody()
+            APPCONFIG_BUNDLE.toResponseBody(),
+            Headers.headersOf(
+                "ETag", "I am an ETag :)!"
+            )
         )
 
         val downloadServer = createInstance()
@@ -120,15 +127,32 @@ class AppConfigServerTest : BaseIOTest() {
         configDownload shouldBe ConfigDownload(
             rawData = APPCONFIG_RAW,
             serverTime = Instant.ofEpochMilli(123456789),
-            localOffset = Duration.ZERO
+            localOffset = Duration.ZERO,
+            etag = "I am an ETag :)!"
+        )
+    }
+
+    @Test
+    fun `missing server etag leads to exception`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody()
         )
+
+        val downloadServer = createInstance()
+
+        shouldThrow<ApplicationConfigurationInvalidException> {
+            downloadServer.downloadAppConfig()
+        }
     }
 
     @Test
     fun `local offset is the difference between server time and local time`() = runBlockingTest {
         coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
             APPCONFIG_BUNDLE.toResponseBody(),
-            Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT")
+            Headers.headersOf(
+                "Date", "Tue, 03 Nov 2020 06:35:16 GMT",
+                "ETag", "I am an ETag :)!"
+            )
         )
         every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
 
@@ -137,7 +161,8 @@ class AppConfigServerTest : BaseIOTest() {
         downloadServer.downloadAppConfig() shouldBe ConfigDownload(
             rawData = APPCONFIG_RAW,
             serverTime = Instant.parse("2020-11-03T06:35:16.000Z"),
-            localOffset = Duration.standardHours(-1)
+            localOffset = Duration.standardHours(-1),
+            etag = "I am an ETag :)!"
         )
     }
 
@@ -146,7 +171,10 @@ class AppConfigServerTest : BaseIOTest() {
         val response = spyk(
             Response.success(
                 APPCONFIG_BUNDLE.toResponseBody(),
-                Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT")
+                Headers.headersOf(
+                    "Date", "Tue, 03 Nov 2020 06:35:16 GMT",
+                    "ETag", "I am an ETag :)!"
+                )
             )
         )
 
@@ -163,7 +191,8 @@ class AppConfigServerTest : BaseIOTest() {
         downloadServer.downloadAppConfig() shouldBe ConfigDownload(
             rawData = APPCONFIG_RAW,
             serverTime = Instant.parse("2020-11-03T06:35:16.000Z"),
-            localOffset = Duration.standardHours(-2)
+            localOffset = Duration.standardHours(-2),
+            etag = "I am an ETag :)!"
         )
     }
 
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt
index fa15cab904896ee99211904a97fbf2411fbe4621..20ddd9ef77f9e81fc8c08cec1a47d0acc9f23bc2 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt
@@ -34,7 +34,8 @@ class AppConfigStorageTest : BaseIOTest() {
     private val testConfigDownload = ConfigDownload(
         rawData = APPCONFIG_RAW,
         serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
-        localOffset = Duration.standardHours(1)
+        localOffset = Duration.standardHours(1),
+        etag = "I am an ETag :)!"
     )
 
     @BeforeEach
@@ -69,6 +70,7 @@ class AppConfigStorageTest : BaseIOTest() {
         configPath.readText().toComparableJson() shouldBe """
             {
                 "rawData": "$APPCONFIG_BASE64",
+                "etag": "I am an ETag :)!",
                 "serverTime": 1604381716000,
                 "localOffset": 3600000
             }
@@ -94,6 +96,7 @@ class AppConfigStorageTest : BaseIOTest() {
         configPath.readText().toComparableJson() shouldBe """
             {
                 "rawData": "$APPCONFIG_BASE64",
+                "etag": "I am an ETag :)!",
                 "serverTime": 1604381716000,
                 "localOffset": 3600000
             }
@@ -117,7 +120,8 @@ class AppConfigStorageTest : BaseIOTest() {
         storage.getStoredConfig() shouldBe ConfigDownload(
             rawData = APPCONFIG_RAW,
             serverTime = Instant.ofEpochMilli(1234),
-            localOffset = Duration.ZERO
+            localOffset = Duration.ZERO,
+            etag = "I am an ETag :)!"
         )
     }
 
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSanityCheck.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSanityCheck.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0266983e8e99167afc3d1984ab6fa48ec469d8e8
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSanityCheck.kt
@@ -0,0 +1,57 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import android.content.Context
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.util.HashExtensions.toSHA256
+import io.kotest.assertions.throwables.shouldNotThrowAny
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import testhelpers.BaseTest
+import testhelpers.EmptyApplication
+
+@Config(sdk = [Build.VERSION_CODES.P], application = EmptyApplication::class)
+@RunWith(RobolectricTestRunner::class)
+class DefaultAppConfigSanityCheck : BaseTest() {
+
+    private val configName = "default_app_config.bin"
+    private val checkSumName = "default_app_config.sha256"
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this)
+    }
+
+    @After
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    val context: Context
+        get() = ApplicationProvider.getApplicationContext()
+
+    @Test
+    fun `current default matches checksum`() {
+        val config = context.assets.open(configName).readBytes()
+        val sha256 = context.assets.open(checkSumName).readBytes().toString(Charsets.UTF_8)
+        sha256 shouldBe "a562bf5940b8c149138634d313db69a298a50e8c52c0b42d18ddf608c385b598"
+        config.toSHA256() shouldBe sha256
+    }
+
+    @Test
+    fun `current default config can be parsed`() {
+        shouldNotThrowAny {
+            val config = context.assets.open(configName).readBytes()
+            AppConfig.ApplicationConfiguration.parseFrom(config) shouldNotBe null
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSourceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSourceTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2ddfad5332b90522c39a2f0848db1b3eb6342463
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSourceTest.kt
@@ -0,0 +1,55 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import android.content.Context
+import android.content.res.AssetManager
+import de.rki.coronawarnapp.util.HashExtensions.toSHA256
+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.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+import java.io.File
+
+class DefaultAppConfigSourceTest : BaseIOTest() {
+    @MockK private lateinit var context: Context
+    @MockK private lateinit var assetManager: AssetManager
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val configFile = File(testDir, "default_app_config.bin")
+    private val checksumFile = File(testDir, "default_app_config.sha256")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { context.assets } returns assetManager
+
+        every { assetManager.open("default_app_config.bin") } answers { configFile.inputStream() }
+        every { assetManager.open("default_app_config.sha256") } answers { checksumFile.inputStream() }
+
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createInstance() = DefaultAppConfigSource(context = context)
+
+    @Test
+    fun `config loaded from asset`() {
+        val testData = "The Cake Is A Lie"
+        configFile.writeText(testData)
+        checksumFile.writeText(testData.toSHA256())
+
+        val instance = createInstance()
+        instance.getRawDefaultConfig() shouldBe testData.toByteArray()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt
index d8ce3fd278925df0151f609f257866f3db3371b9..2d0ab66e16af911165826ea1d14c99e651483b62 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt
@@ -1,19 +1,62 @@
 package de.rki.coronawarnapp.appconfig.mapping
 
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
 import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters
 import io.kotest.matchers.shouldBe
+import org.joda.time.LocalDate
+import org.joda.time.LocalTime
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 
 class DownloadConfigMapperTest : BaseTest() {
-    private fun createInstance() = DownloadConfigMapper()
+    private fun createInstance() = KeyDownloadParametersMapper()
 
     @Test
-    fun `simple creation`() {
+    fun `parse etag missmatch for days`() {
+        val builder = KeyDownloadParameters.KeyDownloadParametersAndroid.newBuilder().apply {
+            KeyDownloadParameters.DayPackageMetadata.newBuilder().apply {
+                etag = "\"GoodMorningEtag\""
+                region = "EUR"
+                date = "2020-11-09"
+            }.let { addRevokedDayPackages(it) }
+        }
+
         val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setAndroidKeyDownloadParameters(builder)
             .build()
+
+        createInstance().map(rawConfig).apply {
+            revokedDayPackages.first().apply {
+                etag shouldBe "\"GoodMorningEtag\""
+                region shouldBe LocationCode("EUR")
+                day shouldBe LocalDate.parse("2020-11-09")
+            }
+        }
+    }
+
+    @Test
+    fun `parse etag missmatch for hours`() {
+        val builder = KeyDownloadParameters.KeyDownloadParametersAndroid.newBuilder().apply {
+            KeyDownloadParameters.HourPackageMetadata.newBuilder().apply {
+                etag = "\"GoodMorningEtag\""
+                region = "EUR"
+                date = "2020-11-09"
+                hour = 8
+            }.let { addRevokedHourPackages(it) }
+        }
+
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setAndroidKeyDownloadParameters(builder)
+            .build()
+
         createInstance().map(rawConfig).apply {
-            keyDownloadParameters shouldBe rawConfig.androidKeyDownloadParameters
+            revokedHourPackages.first().apply {
+                etag shouldBe "\"GoodMorningEtag\""
+                region shouldBe LocationCode("EUR")
+                day shouldBe LocalDate.parse("2020-11-09")
+                hour shouldBe LocalTime.parse("08:00")
+            }
         }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt
index 2552a72dc3554e0879ad28a1bfaf2da77d2dca15..7812c23aba103ae8b16d0b5cbb9d799764fb3776 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt
@@ -1,7 +1,9 @@
 package de.rki.coronawarnapp.appconfig.mapping
 
 import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.ExposureDetectionParameters.ExposureDetectionParametersAndroid
 import io.kotest.matchers.shouldBe
+import org.joda.time.Duration
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 
@@ -19,4 +21,60 @@ class ExposureDetectionConfigMapperTest : BaseTest() {
             exposureDetectionParameters shouldBe rawConfig.androidExposureDetectionParameters
         }
     }
+
+    @Test
+    fun `detection interval 0 defaults to almost infinite delay`() {
+        val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder()
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setMinRiskScore(1)
+            .setAndroidExposureDetectionParameters(exposureDetectionParameters)
+            .build()
+        createInstance().map(rawConfig).apply {
+            minTimeBetweenDetections shouldBe Duration.standardDays(99)
+            maxExposureDetectionsPerUTCDay shouldBe 0
+        }
+    }
+
+    @Test
+    fun `detection interval is mapped correctly`() {
+        val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder().apply {
+            maxExposureDetectionsPerInterval = 3
+        }
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setMinRiskScore(1)
+            .setAndroidExposureDetectionParameters(exposureDetectionParameters)
+            .build()
+        createInstance().map(rawConfig).apply {
+            minTimeBetweenDetections shouldBe Duration.standardHours(24 / 3)
+            maxExposureDetectionsPerUTCDay shouldBe 3
+        }
+    }
+
+    @Test
+    fun `detection timeout is mapped correctly`() {
+        val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder().apply {
+            overallTimeoutInSeconds = 10 * 60
+        }
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setMinRiskScore(1)
+            .setAndroidExposureDetectionParameters(exposureDetectionParameters)
+            .build()
+        createInstance().map(rawConfig).apply {
+            overallDetectionTimeout shouldBe Duration.standardMinutes(10)
+        }
+    }
+
+    @Test
+    fun `detection timeout can not be 0`() {
+        val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder().apply {
+            overallTimeoutInSeconds = 0
+        }
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setMinRiskScore(1)
+            .setAndroidExposureDetectionParameters(exposureDetectionParameters)
+            .build()
+        createInstance().map(rawConfig).apply {
+            overallDetectionTimeout shouldBe Duration.standardMinutes(15)
+        }
+    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3455d8b824e70954665f16e270e3caec8b09ce60
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt
@@ -0,0 +1,65 @@
+package de.rki.coronawarnapp.deadman
+
+import android.content.Context
+import androidx.work.WorkerParameters
+import de.rki.coronawarnapp.worker.BackgroundConstants
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.RelaxedMockK
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DeadmanNotificationOneTimeWorkerTest : BaseTest() {
+
+    @MockK lateinit var sender: DeadmanNotificationSender
+    @MockK lateinit var context: Context
+    @RelaxedMockK lateinit var workerParams: WorkerParameters
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createWorker() = DeadmanNotificationOneTimeWorker(
+        context = context,
+        workerParams = workerParams,
+        sender = sender
+    )
+
+    @Test
+    fun `create worker`() {
+        createWorker()
+    }
+
+    @Test
+    fun `run worker success`() = runBlockingTest {
+        createWorker().doWork()
+
+        coVerify(exactly = 1) { sender.sendNotification() }
+    }
+
+    @Test
+    fun `run worker fail`() = runBlockingTest {
+        val worker = createWorker()
+
+        worker.runAttemptCount shouldBe 0
+
+        every { worker.runAttemptCount } returns BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 1
+
+        worker.doWork()
+
+        coVerify(exactly = 0) { sender.sendNotification() }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bc53f70fe193dcdf601b01cfd131231f57c6f6d1
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt
@@ -0,0 +1,65 @@
+package de.rki.coronawarnapp.deadman
+
+import android.content.Context
+import androidx.work.WorkerParameters
+import de.rki.coronawarnapp.worker.BackgroundConstants
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.RelaxedMockK
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DeadmanNotificationPeriodicWorkerTest : BaseTest() {
+
+    @MockK lateinit var scheduler: DeadmanNotificationScheduler
+    @MockK lateinit var context: Context
+    @RelaxedMockK lateinit var workerParams: WorkerParameters
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createWorker() = DeadmanNotificationPeriodicWorker(
+        context = context,
+        workerParams = workerParams,
+        scheduler = scheduler
+    )
+
+    @Test
+    fun `create worker`() {
+        createWorker()
+    }
+
+    @Test
+    fun `run worker success`() = runBlockingTest {
+        createWorker().doWork()
+
+        coVerify(exactly = 1) { scheduler.scheduleOneTime() }
+    }
+
+    @Test
+    fun `run worker fail`() = runBlockingTest {
+        val worker = createWorker()
+
+        worker.runAttemptCount shouldBe 0
+
+        every { worker.runAttemptCount } returns BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 1
+
+        worker.doWork()
+
+        coVerify(exactly = 0) { scheduler.scheduleOneTime() }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..26e1708493bb8f3c56d47141493aba8c3a54384f
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt
@@ -0,0 +1,112 @@
+package de.rki.coronawarnapp.deadman
+
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.ExistingWorkPolicy
+import androidx.work.OneTimeWorkRequest
+import androidx.work.Operation
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkManager
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.verify
+import io.mockk.verifySequence
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DeadmanNotificationSchedulerTest : BaseTest() {
+
+    @MockK lateinit var timeCalculation: DeadmanNotificationTimeCalculation
+    @MockK lateinit var workManager: WorkManager
+    @MockK lateinit var operation: Operation
+    @MockK lateinit var workBuilder: DeadmanNotificationWorkBuilder
+    @MockK lateinit var periodicWorkRequest: PeriodicWorkRequest
+    @MockK lateinit var oneTimeWorkRequest: OneTimeWorkRequest
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { workBuilder.buildPeriodicWork() } returns periodicWorkRequest
+        every { workBuilder.buildOneTimeWork(any()) } returns oneTimeWorkRequest
+        every {
+            workManager.enqueueUniquePeriodicWork(
+                DeadmanNotificationScheduler.PERIODIC_WORK_NAME,
+                ExistingPeriodicWorkPolicy.REPLACE,
+                any()
+            )
+        } returns operation
+
+        every {
+            workManager.enqueueUniqueWork(
+                DeadmanNotificationScheduler.ONE_TIME_WORK_NAME,
+                ExistingWorkPolicy.REPLACE,
+                oneTimeWorkRequest
+            )
+        } returns operation
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createScheduler() = DeadmanNotificationScheduler(
+        timeCalculation = timeCalculation,
+        workManager = workManager,
+        workBuilder = workBuilder
+    )
+
+    @Test
+    fun `one time work was scheduled`() = runBlockingTest {
+        coEvery { timeCalculation.getDelay() } returns 10L
+
+        createScheduler().scheduleOneTime()
+
+        verifySequence {
+            workManager.enqueueUniqueWork(
+                DeadmanNotificationScheduler.ONE_TIME_WORK_NAME,
+                ExistingWorkPolicy.REPLACE,
+                oneTimeWorkRequest
+            )
+        }
+    }
+
+    @Test
+    fun `one time work was not scheduled`() = runBlockingTest {
+        coEvery { timeCalculation.getDelay() } returns -10L
+
+        createScheduler().scheduleOneTime()
+
+        verify(exactly = 0) {
+            workManager.enqueueUniqueWork(
+                DeadmanNotificationScheduler.ONE_TIME_WORK_NAME,
+                ExistingWorkPolicy.REPLACE,
+                oneTimeWorkRequest
+            )
+        }
+
+        verify(exactly = 0) {
+            workManager.enqueueUniquePeriodicWork(
+                any(), any(), any()
+            )
+        }
+    }
+
+    @Test
+    fun `test periodic work was scheduled`() {
+        createScheduler().schedulePeriodic()
+
+        verifySequence {
+            workManager.enqueueUniquePeriodicWork(
+                DeadmanNotificationScheduler.PERIODIC_WORK_NAME,
+                ExistingPeriodicWorkPolicy.REPLACE,
+                periodicWorkRequest
+            )
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..35bae170457dcd40fd29593cfadfc54b91d3f98a
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt
@@ -0,0 +1,45 @@
+package de.rki.coronawarnapp.deadman
+
+import android.content.Context
+import androidx.core.app.NotificationManagerCompat
+import de.rki.coronawarnapp.notification.NotificationConstants
+import de.rki.coronawarnapp.util.ForegroundState
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DeadmanNotificationSenderTest : BaseTest() {
+
+    @MockK lateinit var context: Context
+    @MockK lateinit var foregroundState: ForegroundState
+    @MockK lateinit var notificationManagerCompat: NotificationManagerCompat
+
+    private val channelId = "de.rki.coronawarnapp.notification.exposureNotificationChannelId"
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { context.getString(NotificationConstants.NOTIFICATION_CHANNEL_ID) } returns channelId
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createSender() = DeadmanNotificationSender(
+        context = context,
+        foregroundState = foregroundState,
+        notificationManagerCompat = notificationManagerCompat
+    )
+
+    @Test
+    fun `sender creation`() {
+        createSender()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a446d6e317bf97f5cd42e8d234447c8bfc651bb9
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt
@@ -0,0 +1,103 @@
+package de.rki.coronawarnapp.deadman
+
+import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
+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 io.mockk.verify
+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 DeadmanNotificationTimeCalculationTest : BaseTest() {
+
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var enfClient: ENFClient
+    @MockK lateinit var mockExposureDetection: TrackedExposureDetection
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-01T23:00:00.000Z")
+        every { enfClient.lastSuccessfulTrackedExposureDetection() } returns flowOf(mockExposureDetection)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createTimeCalculator() = DeadmanNotificationTimeCalculation(
+        timeStamper = timeStamper,
+        enfClient = enfClient
+    )
+
+    @Test
+    fun `12 hours difference`() {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-28T14:00:00.000Z")
+
+        createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T14:00:00.000Z")) shouldBe 720
+    }
+
+    @Test
+    fun `negative time difference`() {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-30T14:00:00.000Z")
+
+        createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T14:00:00.000Z")) shouldBe -2160
+    }
+
+    @Test
+    fun `success in future case`() {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z")
+
+        createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T15:00:00.000Z")) shouldBe 2220
+    }
+
+    @Test
+    fun `12 hours delay`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-28T14:00:00.000Z")
+        every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z")
+
+        createTimeCalculator().getDelay() shouldBe 720
+
+        verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() }
+    }
+
+    @Test
+    fun `negative delay`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-30T14:00:00.000Z")
+        every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z")
+
+        createTimeCalculator().getDelay() shouldBe -2160
+
+        verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() }
+    }
+
+    @Test
+    fun `success in future delay`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z")
+        every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T15:00:00.000Z")
+
+        createTimeCalculator().getDelay() shouldBe 2220
+
+        verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() }
+    }
+
+    @Test
+    fun `initial delay - no successful calculations yet`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z")
+        every { enfClient.lastSuccessfulTrackedExposureDetection() } returns flowOf(null)
+
+        createTimeCalculator().getDelay() shouldBe 2160
+
+        verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d7ca037ee3c5ea6416020ae6dd9c7a035e8c71b6
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt
@@ -0,0 +1,56 @@
+package de.rki.coronawarnapp.deadman
+
+import androidx.work.BackoffPolicy
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DeadmanNotificationWorkBuilderTest : BaseTest() {
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    @Test
+    fun `onetime work test`() {
+        testOneTimeWork(10L)
+        testOneTimeWork(-10L)
+        testOneTimeWork(0)
+    }
+
+    /**
+     * Delay time in minutes
+     * Backoff delay 8 minutes
+     */
+    private fun testOneTimeWork(delay: Long) {
+        val periodicWork = DeadmanNotificationWorkBuilder().buildOneTimeWork(delay)
+
+        periodicWork.workSpec.backoffPolicy shouldBe BackoffPolicy.EXPONENTIAL
+        periodicWork.workSpec.backoffDelayDuration shouldBe 8 * 60 * 1000
+        periodicWork.workSpec.initialDelay shouldBe delay * 60 * 1000
+    }
+
+    /**
+     * Delay time in minutes
+     * Backoff delay 8 minutes
+     * Interval duration 1 hour
+     */
+    @Test
+    fun `periodic work test`() {
+        val periodicWork = DeadmanNotificationWorkBuilder().buildPeriodicWork()
+
+        periodicWork.workSpec.backoffPolicy shouldBe BackoffPolicy.EXPONENTIAL
+        periodicWork.workSpec.backoffDelayDuration shouldBe 8 * 60 * 1000
+        periodicWork.workSpec.intervalDuration shouldBe 60 * 60 * 1000
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncToolTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0e8a55046adcd4317ced155237004c7f03b1c64c
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncToolTest.kt
@@ -0,0 +1,315 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.storage.DeviceStorage
+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 io.mockk.mockk
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.joda.time.LocalDate
+import org.joda.time.LocalTime
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+import java.io.File
+
+class BaseKeyPackageSyncToolTest : BaseIOTest() {
+
+    @MockK lateinit var keyCache: KeyCacheRepository
+    @MockK lateinit var deviceStorage: DeviceStorage
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+
+    private val String.loc get() = LocationCode(this)
+    private val String.day get() = LocalDate.parse(this)
+    private val String.hour get() = LocalTime.parse(this)
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        coEvery { deviceStorage.requireSpacePrivateStorage(any()) } returns mockk()
+        coEvery { keyCache.delete(any()) } just Runs
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    class TestSyncTool(
+        keyCache: KeyCacheRepository,
+        deviceStorage: DeviceStorage
+    ) : BaseKeyPackageSyncTool(
+        keyCache = keyCache,
+        deviceStorage = deviceStorage,
+        "tag"
+    ) {
+        fun findStaleData(keys: List<CachedKey>, available: List<LocationData>): List<CachedKey> =
+            keys.findStaleData(available)
+    }
+
+    fun createInstance() = TestSyncTool(
+        keyCache = keyCache,
+        deviceStorage = deviceStorage
+    )
+
+    @Test
+    fun `key invalidation based on ETags`() = runBlockingTest {
+        val invalidatedDay = mockk<KeyDownloadConfig.RevokedKeyPackage>().apply {
+            every { etag } returns "etag-badday"
+        }
+        val invalidatedHour = mockk<KeyDownloadConfig.RevokedKeyPackage>().apply {
+            every { etag } returns "etag-badhour"
+        }
+
+        val badDayInfo = mockk<CachedKeyInfo>().apply {
+            every { etag } returns "etag-badday"
+        }
+        val badDay = mockk<CachedKey>().apply {
+            every { info } returns badDayInfo
+        }
+        val goodDay = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { etag } returns "etag-goodday"
+            }
+        }
+
+        val badHourInfo = mockk<CachedKeyInfo>().apply {
+            every { etag } returns "etag-badhour"
+        }
+        val badHour = mockk<CachedKey>().apply {
+            every { info } returns badHourInfo
+        }
+        val goodHour = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { etag } returns "etag-goodhour"
+            }
+        }
+
+        coEvery { keyCache.getAllCachedKeys() } returns listOf(badDay, goodDay, badHour, goodHour)
+
+        val instance = createInstance()
+        instance.revokeCachedKeys(listOf(invalidatedDay, invalidatedHour))
+
+        coVerify { keyCache.delete(listOf(badDayInfo, badHourInfo)) }
+    }
+
+    @Test
+    fun `filtering out stale day data`() {
+        val staleKey = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_DAY,
+                location = "EUR".loc,
+                day = "2020-09-01".day,
+                hour = null,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+
+        val freshKey = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_DAY,
+                location = "EUR".loc,
+                day = "2020-09-02".day,
+                hour = null,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+
+        val availableCountryDay = LocationDays(
+            LocationCode("EUR"),
+            listOf("2020-09-02".day)
+        )
+
+        val toFilter = listOf(staleKey, freshKey)
+        val availableData = listOf(availableCountryDay)
+
+        val instance = createInstance()
+        instance.findStaleData(toFilter, availableData) shouldBe listOf(staleKey)
+    }
+
+    @Test
+    fun `filtering out stale hour data`() {
+        val staleHour = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_HOUR,
+                location = "EUR".loc,
+                day = "2020-09-01".day,
+                hour = "01".hour,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+        val freshHour = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_HOUR,
+                location = "EUR".loc,
+                day = "2020-09-02".day,
+                hour = "02".hour,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+        val availableCountryDay = LocationHours(
+            LocationCode("EUR"),
+            mapOf("2020-09-02".day to listOf("02".hour))
+        )
+
+        val toFilter = listOf(freshHour, staleHour)
+        val availableData = listOf(availableCountryDay)
+
+        val instance = createInstance()
+        instance.findStaleData(toFilter, availableData) shouldBe listOf(staleHour)
+    }
+
+    @Test
+    fun `filtering out stale mixed data`() {
+        val staleHour = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_HOUR,
+                location = "EUR".loc,
+                day = "2020-09-01".day,
+                hour = "01".hour,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+        val staleHourReplacedByDay = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_HOUR,
+                location = "EUR".loc,
+                day = "2020-09-02".day,
+                hour = "01".hour,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+        val freshHour = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_HOUR,
+                location = "EUR".loc,
+                day = "2020-09-01".day,
+                hour = "02".hour,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+        val availableHour = LocationHours(
+            LocationCode("EUR"),
+            mapOf(
+                "2020-09-01".day to listOf("02".hour),
+                "2020-09-02".day to listOf("01".hour)
+            )
+        )
+
+        val staleDay = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_DAY,
+                location = "EUR".loc,
+                day = "2020-09-01".day,
+                hour = null,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+        val freshDay = CachedKey(
+            info = CachedKeyInfo(
+                type = CachedKeyInfo.Type.LOCATION_DAY,
+                location = "EUR".loc,
+                day = "2020-09-02".day,
+                hour = null,
+                createdAt = Instant.EPOCH
+            ),
+            path = File("")
+        )
+        val availableDay = LocationDays(
+            LocationCode("EUR"),
+            listOf("2020-09-02".day)
+        )
+
+        val toFilter = listOf(freshDay, staleDay, freshHour, staleHour, staleHourReplacedByDay)
+        val availableData = listOf(availableDay, availableHour)
+
+        val instance = createInstance()
+        instance.findStaleData(toFilter, availableData) shouldBe listOf(staleDay, staleHour, staleHourReplacedByDay)
+    }
+
+    @Test
+    fun `required storage check`() = runBlockingTest {
+        val instance = createInstance()
+        val countryDay = mockk<LocationDays>().apply {
+            every { approximateSizeInBytes } returns 9000L
+        }
+        val countryHour = mockk<LocationHours>().apply {
+            every { approximateSizeInBytes } returns 1337L
+        }
+        instance.requireStorageSpace(listOf(countryDay, countryHour))
+
+        coVerify { deviceStorage.requireSpacePrivateStorage(10337L) }
+    }
+
+    @Test
+    fun `getting completed keys`() = runBlockingTest {
+        val key1 = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { isDownloadComplete } returns false
+                every { location } returns LocationCode("EUR")
+            }
+            every { path } returns mockk<File>().apply { every { exists() } returns true }
+        }
+        val key2 = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { isDownloadComplete } returns true
+                every { location } returns LocationCode("EUR")
+            }
+            every { path } returns mockk<File>().apply { every { exists() } returns false }
+        }
+        val key3 = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { isDownloadComplete } returns true
+                every { location } returns LocationCode("EUR")
+            }
+            every { path } returns mockk<File>().apply { every { exists() } returns true }
+        }
+        val key4 = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { isDownloadComplete } returns true
+                every { location } returns LocationCode("DE")
+            }
+            every { path } returns mockk<File>().apply { every { exists() } returns true }
+        }
+        coEvery { keyCache.getEntriesForType(any()) } returns listOf(key1, key2, key3, key4)
+
+        val instance = createInstance()
+        instance.getDownloadedCachedKeys(
+            LocationCode("EUR"),
+            CachedKeyInfo.Type.LOCATION_DAY
+        ) shouldBe listOf(key3)
+        coVerify { keyCache.getEntriesForType(CachedKeyInfo.Type.LOCATION_DAY) }
+
+        instance.getDownloadedCachedKeys(
+            LocationCode("EUR"),
+            CachedKeyInfo.Type.LOCATION_HOUR
+        ) shouldBe listOf(key3)
+        coVerify { keyCache.getEntriesForType(CachedKeyInfo.Type.LOCATION_HOUR) }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CommonSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CommonSyncToolTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5cb2f02548a9899777337aceb65b4e4397705575
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CommonSyncToolTest.kt
@@ -0,0 +1,154 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.ConfigData
+import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.storage.DeviceStorage
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import org.joda.time.DateTimeZone
+import org.joda.time.Instant
+import org.joda.time.LocalDate
+import org.joda.time.LocalTime
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import testhelpers.BaseIOTest
+import timber.log.Timber
+import java.io.File
+
+abstract class CommonSyncToolTest : BaseIOTest() {
+
+    @MockK lateinit var deviceStorage: DeviceStorage
+    @MockK lateinit var keyCache: KeyCacheRepository
+    @MockK lateinit var keyServer: DiagnosisKeyServer
+    @MockK lateinit var downloadTool: KeyDownloadTool
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var configProvider: AppConfigProvider
+
+    @MockK lateinit var downloadConfig: ConfigData
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+
+    internal val String.loc get() = LocationCode(this)
+    internal val String.day get() = LocalDate.parse(this)
+    internal val String.hour get() = LocalTime.parse(this)
+    val keyRepoData = mutableMapOf<String, CachedKey>()
+
+    @BeforeEach
+    open fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        coEvery { configProvider.getAppConfig() } returns downloadConfig
+
+        coEvery { keyCache.getEntriesForType(any()) } answers {
+            keyRepoData
+                .filter { it.value.info.type == arg(0) }
+                .map { it.value }
+        }
+
+        coEvery { keyServer.getDayIndex(any()) } returns listOf(
+            "2020-01-01".day, "2020-01-02".day, "2020-01-03".day
+        )
+        coEvery { keyServer.getHourIndex(any(), "2020-01-04".day) } returns listOf(
+            "00:00".hour, "01:00".hour, "02:00".hour
+        )
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T03:15:00.000Z")
+
+        coEvery { deviceStorage.requireSpacePrivateStorage(any()) } returns mockk()
+
+        coEvery {
+            keyCache.createCacheEntry(CachedKeyInfo.Type.LOCATION_DAY, any(), any(), null)
+        } answers {
+            mockCachedDay(arg(1), arg(2))
+        }
+        coEvery {
+            keyCache.createCacheEntry(CachedKeyInfo.Type.LOCATION_HOUR, any(), any(), any())
+        } answers {
+            mockCachedHour(arg(1), arg(2), arg(3))
+        }
+        coEvery { keyCache.getAllCachedKeys() } answers { keyRepoData.values.toList() }
+        coEvery { keyCache.delete(any()) } answers {
+            val toDelete: List<CachedKeyInfo> = arg(0)
+            toDelete.forEach {
+                keyRepoData.remove(it.id)
+            }
+            Unit
+        }
+
+        coEvery { downloadTool.downloadKeyFile(any(), any()) } answers {
+            arg(0)
+        }
+    }
+
+    @AfterEach
+    open fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    internal fun mockCachedDay(
+        location: LocationCode,
+        dayIdentifier: LocalDate,
+        isComplete: Boolean = true
+    ): CachedKey = mockCacheEntry(
+        location, dayIdentifier, null, isComplete
+    )
+
+    internal fun mockCachedHour(
+        location: LocationCode,
+        dayIdentifier: LocalDate,
+        hourIdentifier: LocalTime,
+        isComplete: Boolean = true
+    ): CachedKey = mockCacheEntry(
+        location, dayIdentifier, hourIdentifier, isComplete
+    )
+
+    private fun mockCacheEntry(
+        location: LocationCode,
+        dayIdentifier: LocalDate,
+        hourIdentifier: LocalTime?,
+        isComplete: Boolean = true
+    ): CachedKey {
+        var keyInfo = CachedKeyInfo(
+            type = when (hourIdentifier) {
+                null -> CachedKeyInfo.Type.LOCATION_DAY
+                else -> CachedKeyInfo.Type.LOCATION_HOUR
+            },
+            location = location,
+            day = dayIdentifier,
+            hour = hourIdentifier,
+            createdAt = when (hourIdentifier) {
+                null -> dayIdentifier.toLocalDateTime(LocalTime.MIDNIGHT).toDateTime(DateTimeZone.UTC).toInstant()
+                else -> dayIdentifier.toLocalDateTime(hourIdentifier).toDateTime(DateTimeZone.UTC).toInstant()
+            }
+        )
+        if (isComplete) {
+            keyInfo = keyInfo.copy(
+                etag = when (hourIdentifier) {
+                    null -> "$location-$dayIdentifier"
+                    else -> "$location-$dayIdentifier-$hourIdentifier"
+                },
+                isDownloadComplete = true
+            )
+        }
+        Timber.i("mockKeyCacheCreateEntry(...): %s", keyInfo)
+        val file = File(testDir, keyInfo.id)
+        file.createNewFile()
+        return CachedKey(keyInfo, file).also {
+            keyRepoData[it.info.id] = it
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryDataTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryDataTest.kt
index 52f54f51b584993994e1ec40df2d8aca36cfd147..434e3a7db13f668c3cca4b9b9a7fb74307c31bfa 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryDataTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryDataTest.kt
@@ -1,6 +1,7 @@
 package de.rki.coronawarnapp.diagnosiskeys.download
 
 import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
 import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
 import io.kotest.matchers.shouldBe
 import io.mockk.every
@@ -13,11 +14,13 @@ import testhelpers.BaseTest
 class CountryDataTest : BaseTest() {
     private val locationCode = LocationCode("DE")
 
-    private fun createCachedKey(dayString: String, hourString: String? = null): CachedKeyInfo {
-        return mockk<CachedKeyInfo>().apply {
-            every { location } returns locationCode
-            every { day } returns LocalDate.parse(dayString)
-            every { hour } returns hourString?.let { LocalTime.parse(it) }
+    private fun createCachedKey(dayString: String, hourString: String? = null): CachedKey {
+        return mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { location } returns locationCode
+                every { day } returns LocalDate.parse(dayString)
+                every { hour } returns hourString?.let { LocalTime.parse(it) }
+            }
         }
     }
 
@@ -26,7 +29,7 @@ class CountryDataTest : BaseTest() {
         val availableDates = listOf(
             "2222-12-30", "2222-12-31"
         ).map { LocalDate.parse(it) }
-        val cd = CountryDays(locationCode, availableDates)
+        val cd = LocationDays(locationCode, availableDates)
 
         cd.dayData shouldBe availableDates
 
@@ -43,7 +46,7 @@ class CountryDataTest : BaseTest() {
     @Test
     fun `missing days empty day data`() {
         val availableDates = emptyList<LocalDate>()
-        val cd = CountryDays(locationCode, availableDates)
+        val cd = LocationDays(locationCode, availableDates)
 
         cd.dayData shouldBe availableDates
 
@@ -61,11 +64,11 @@ class CountryDataTest : BaseTest() {
         val availableDates = listOf(
             "2222-11-28", "2222-11-29"
         ).map { LocalDate.parse(it) }
-        val cd = CountryDays(locationCode, availableDates)
+        val cd = LocationDays(locationCode, availableDates)
 
         cd.dayData shouldBe availableDates
 
-        val cachedDays = emptyList<CachedKeyInfo>()
+        val cachedDays = emptyList<CachedKey>()
 
         cd.getMissingDays(cachedDays) shouldBe availableDates
         cd.toMissingDays(cachedDays) shouldBe cd
@@ -76,7 +79,7 @@ class CountryDataTest : BaseTest() {
         val availableDates = listOf(
             "2222-11-28", "2222-11-29"
         ).map { LocalDate.parse(it) }
-        val cd = CountryDays(locationCode, availableDates)
+        val cd = LocationDays(locationCode, availableDates)
 
         cd.dayData shouldBe availableDates
 
@@ -94,7 +97,7 @@ class CountryDataTest : BaseTest() {
         val availableDates = listOf(
             "2222-12-30", "2222-12-31"
         ).map { LocalDate.parse(it) }
-        val cd = CountryDays(locationCode, availableDates)
+        val cd = LocationDays(locationCode, availableDates)
 
         cd.dayData shouldBe availableDates
 
@@ -117,7 +120,7 @@ class CountryDataTest : BaseTest() {
                 LocalTime.parse("22:00"), LocalTime.parse("23:00")
             )
         )
-        val cd = CountryHours(locationCode, availableHours)
+        val cd = LocationHours(locationCode, availableHours)
 
         cd.hourData shouldBe availableHours
 
@@ -138,7 +141,7 @@ class CountryDataTest : BaseTest() {
     @Test
     fun `missing hours empty available hour data`() {
         val availableHours: Map<LocalDate, List<LocalTime>> = emptyMap()
-        val cd = CountryHours(locationCode, availableHours)
+        val cd = LocationHours(locationCode, availableHours)
 
         cd.hourData shouldBe availableHours
 
@@ -156,7 +159,7 @@ class CountryDataTest : BaseTest() {
         val availableHours = mapOf(
             LocalDate.parse("2222-12-30") to emptyList<LocalTime>()
         )
-        val cd = CountryHours(locationCode, availableHours)
+        val cd = LocationHours(locationCode, availableHours)
 
         cd.hourData shouldBe availableHours
 
@@ -179,11 +182,11 @@ class CountryDataTest : BaseTest() {
                 LocalTime.parse("22:00"), LocalTime.parse("23:00")
             )
         )
-        val cd = CountryHours(locationCode, availableHours)
+        val cd = LocationHours(locationCode, availableHours)
 
         cd.hourData shouldBe availableHours
 
-        val cachedHours = emptyList<CachedKeyInfo>()
+        val cachedHours = emptyList<CachedKey>()
 
         cd.getMissingHours(cachedHours) shouldBe availableHours
         cd.toMissingHours(cachedHours) shouldBe cd.copy(hourData = availableHours)
@@ -199,7 +202,7 @@ class CountryDataTest : BaseTest() {
                 LocalTime.parse("22:00"), LocalTime.parse("23:00")
             )
         )
-        val cd = CountryHours(locationCode, availableHours)
+        val cd = LocationHours(locationCode, availableHours)
 
         cd.hourData shouldBe availableHours
 
@@ -222,7 +225,7 @@ class CountryDataTest : BaseTest() {
                 LocalTime.parse("22:00"), LocalTime.parse("23:00")
             )
         )
-        val cd = CountryHours(locationCode, availableHours)
+        val cd = LocationHours(locationCode, availableHours)
 
         cd.hourData shouldBe availableHours
 
@@ -239,12 +242,12 @@ class CountryDataTest : BaseTest() {
 
     @Test
     fun `calculate approximate required space for day data`() {
-        CountryDays(LocationCode("DE"), emptyList()).approximateSizeInBytes shouldBe 0
-        CountryDays(
+        LocationDays(LocationCode("DE"), emptyList()).approximateSizeInBytes shouldBe 0
+        LocationDays(
             LocationCode("DE"),
             listOf(LocalDate.parse("2222-12-30"))
         ).approximateSizeInBytes shouldBe 512 * 1024L
-        CountryDays(
+        LocationDays(
             LocationCode("DE"),
             listOf(LocalDate.parse("2222-12-30"), LocalDate.parse("2222-12-31"))
         ).approximateSizeInBytes shouldBe 2 * 512 * 1024L
@@ -252,12 +255,12 @@ class CountryDataTest : BaseTest() {
 
     @Test
     fun `calculate approximate required space for day hour`() {
-        CountryHours(LocationCode("DE"), emptyMap()).approximateSizeInBytes shouldBe 0
-        CountryHours(
+        LocationHours(LocationCode("DE"), emptyMap()).approximateSizeInBytes shouldBe 0
+        LocationHours(
             LocationCode("DE"),
             mapOf(LocalDate.parse("2222-12-30") to listOf(LocalTime.parse("23:00")))
         ).approximateSizeInBytes shouldBe 22 * 1024L
-        CountryHours(
+        LocationHours(
             LocationCode("DE"),
             mapOf(
                 LocalDate.parse("2222-12-30") to listOf(LocalTime.parse("23:00")),
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..598e8408c1e8e0aad0fd5f78c4940b13723fa664
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt
@@ -0,0 +1,225 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.mapping.RevokedKeyPackage
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifySequence
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.DateTimeZone
+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.TestDispatcherProvider
+import java.io.IOException
+
+class DayPackageSyncToolTest : CommonSyncToolTest() {
+
+    @BeforeEach
+    override fun setup() {
+        super.setup()
+
+        every { downloadConfig.revokedDayPackages } returns emptyList()
+    }
+
+    @AfterEach
+    override fun teardown() {
+        super.teardown()
+    }
+
+    fun createInstance() = DayPackageSyncTool(
+        deviceStorage = deviceStorage,
+        keyServer = keyServer,
+        keyCache = keyCache,
+        downloadTool = downloadTool,
+        timeStamper = timeStamper,
+        dispatcherProvider = TestDispatcherProvider,
+        configProvider = configProvider
+    )
+
+    @Test
+    fun `successful sync`() = runBlockingTest {
+        // Today is the 4th
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+
+        val instance = createInstance()
+        instance.syncMissingDayPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult(
+            successful = true,
+            newPackages = keyRepoData.values.filterNot { it.info.day == "2020-01-01".day }
+        )
+
+        coVerifySequence {
+            configProvider.getAppConfig()
+            keyCache.getEntriesForType(Type.LOCATION_DAY)
+            timeStamper.nowUTC
+            keyServer.getDayIndex("EUR".loc)
+            keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-02".day, null)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+            keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-03".day, null)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+        }
+    }
+
+    @Test
+    fun `determine missing days checks EXPECT NEW DAYS`() = runBlockingTest {
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+        mockCachedDay("EUR".loc, "2020-01-02".day)
+
+        val instance = createInstance()
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-03T12:12:12.000Z")
+        instance.determineMissingDayPackages("EUR".loc, false) shouldBe null
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T12:12:12.000Z")
+        instance.determineMissingDayPackages("EUR".loc, false) shouldBe LocationDays(
+            location = "EUR".loc,
+            dayData = listOf("2020-01-03".day)
+        )
+    }
+
+    @Test
+    fun `determine missing days with forcesync ignores EXPECT NEW DAYS`() = runBlockingTest {
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+        mockCachedDay("EUR".loc, "2020-01-02".day)
+
+        val instance = createInstance()
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-02T12:12:12.000Z")
+        instance.determineMissingDayPackages("EUR".loc, true) shouldBe LocationDays(
+            location = "EUR".loc,
+            dayData = listOf("2020-01-03".day)
+        )
+    }
+
+    @Test
+    fun `EXPECT_NEW_DAY_PACKAGES evaluation`() = runBlockingTest {
+        val cachedKey1 = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { toDateTime() } returns Instant.parse("2020-10-30T01:02:03.000Z").toDateTime(DateTimeZone.UTC)
+            }
+        }
+        val cachedKey2 = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { toDateTime() } returns Instant.parse("2020-10-31T01:02:03.000Z").toDateTime(DateTimeZone.UTC)
+            }
+        }
+
+        val instance = createInstance()
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-01T01:02:03.000Z")
+        instance.expectNewDayPackages(listOf(cachedKey1)) shouldBe true
+        instance.expectNewDayPackages(listOf(cachedKey1, cachedKey2)) shouldBe false
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-10-31T01:02:03.000Z")
+        instance.expectNewDayPackages(listOf(cachedKey1, cachedKey2)) shouldBe true
+    }
+
+    @Test
+    fun `download errors do not abort the whole sync`() = runBlockingTest {
+        var counter = 0
+        coEvery { downloadTool.downloadKeyFile(any(), any()) } answers {
+            if (++counter == 2) throw IOException()
+            arg(0)
+        }
+
+        val instance = createInstance()
+        instance.syncMissingDayPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult(
+            successful = false,
+            newPackages = keyRepoData.values.filterNot { it.info.day == "2020-01-02".day }
+        )
+
+        coVerifySequence {
+            configProvider.getAppConfig()
+            keyCache.getEntriesForType(Type.LOCATION_DAY)
+            timeStamper.nowUTC
+            keyServer.getDayIndex("EUR".loc)
+            keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-01".day, null)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+            keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-02".day, null)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+            keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-03".day, null)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+        }
+    }
+
+    @Test
+    fun `app config can invalidate cached days`() = runBlockingTest {
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+        mockCachedDay("EUR".loc, "2020-01-02".day)
+        val invalidDay = mockCachedDay("EUR".loc, "2020-01-03".day)
+
+        every { downloadConfig.revokedDayPackages } returns listOf(
+            RevokedKeyPackage.Day(
+                day = invalidDay.info.day,
+                region = invalidDay.info.location,
+                etag = invalidDay.info.etag!!
+            )
+        )
+
+        val instance = createInstance()
+        instance.syncMissingDayPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult(
+            successful = true,
+            newPackages = keyRepoData.values.filter { it.info.day == "2020-01-03".day }
+        )
+
+        coVerifySequence {
+            configProvider.getAppConfig()
+
+            keyCache.getAllCachedKeys()
+            keyCache.delete(listOf(invalidDay.info))
+
+            keyCache.getEntriesForType(Type.LOCATION_DAY)
+            keyServer.getDayIndex("EUR".loc)
+
+            keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-03".day, null)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+        }
+    }
+
+    @Test
+    fun `if keys were revoked skip the EXPECT packages check`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T12:12:12.000Z")
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+        mockCachedDay("EUR".loc, "2020-01-02".day)
+        mockCachedDay("EUR".loc, "2020-01-03".day).apply {
+            every { downloadConfig.revokedDayPackages } returns listOf(
+                RevokedKeyPackage.Day(
+                    day = info.day,
+                    region = info.location,
+                    etag = info.etag!!
+                )
+            )
+        }
+
+        createInstance().syncMissingDayPackages(listOf("EUR".loc), false)
+
+        coVerify(exactly = 1) { keyServer.getDayIndex("EUR".loc) }
+    }
+
+    @Test
+    fun `if force-sync is set we skip the EXPECT packages check`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T12:12:12.000Z")
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+        mockCachedDay("EUR".loc, "2020-01-02".day)
+        mockCachedDay("EUR".loc, "2020-01-03".day)
+        createInstance().syncMissingDayPackages(listOf("EUR".loc), true)
+
+        coVerify(exactly = 1) { keyServer.getDayIndex("EUR".loc) }
+    }
+
+    @Test
+    fun `if neither force-sync is set and keys were revoked we check EXPECT NEW PKGS`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T12:12:12.000Z")
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+        mockCachedDay("EUR".loc, "2020-01-02".day)
+        mockCachedDay("EUR".loc, "2020-01-03".day)
+        createInstance().syncMissingDayPackages(listOf("EUR".loc), false)
+
+        coVerify(exactly = 0) { keyServer.getDayIndex("EUR".loc) }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4aec1691f060b27ae4a8ff466967840a4c80475c
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt
@@ -0,0 +1,39 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.environment.EnvironmentSetup
+import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.util.TimeStamper
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.impl.annotations.MockK
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import testhelpers.BaseTest
+
+class DownloadDiagnosisKeysTaskTest : BaseTest() {
+
+    @MockK lateinit var enfClient: ENFClient
+    @MockK lateinit var environmentSetup: EnvironmentSetup
+    @MockK lateinit var appConfigProvider: AppConfigProvider
+    @MockK lateinit var keyPackageSyncTool: KeyPackageSyncTool
+    @MockK lateinit var timeStamper: TimeStamper
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    fun createInstance() = DownloadDiagnosisKeysTask(
+        enfClient = enfClient,
+        environmentSetup = environmentSetup,
+        appConfigProvider = appConfigProvider,
+        keyPackageSyncTool = keyPackageSyncTool,
+        timeStamper = timeStamper
+    )
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..062d1bfb4c1fbf45e9fb29d274fbd97c0c7ee93a
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt
@@ -0,0 +1,247 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.mapping.RevokedKeyPackage
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type
+import io.kotest.matchers.shouldBe
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifySequence
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.DateTimeZone
+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.TestDispatcherProvider
+import java.io.IOException
+
+class HourPackageSyncToolTest : CommonSyncToolTest() {
+
+    @BeforeEach
+    override fun setup() {
+        super.setup()
+
+        every { downloadConfig.revokedHourPackages } returns emptyList()
+    }
+
+    @AfterEach
+    override fun teardown() {
+        super.teardown()
+    }
+
+    fun createInstance() = HourPackageSyncTool(
+        deviceStorage = deviceStorage,
+        keyServer = keyServer,
+        keyCache = keyCache,
+        downloadTool = downloadTool,
+        timeStamper = timeStamper,
+        configProvider = configProvider,
+        dispatcherProvider = TestDispatcherProvider
+    )
+
+    @Test
+    fun `successful sync`() = runBlockingTest {
+        // Today is the 4th, 02:15:00
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+        mockCachedDay("EUR".loc, "2020-01-02".day)
+        mockCachedDay("EUR".loc, "2020-01-03".day)
+
+        val staleHour = mockCachedHour("EUR".loc, "2020-01-03".day, "01:00".hour)
+        mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour)
+
+        val instance = createInstance()
+        instance.syncMissingHourPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult(
+            successful = true,
+            newPackages = keyRepoData.values.filter { it.info.type == Type.LOCATION_HOUR && it.info.hour != "01:00".hour }
+        )
+
+        coVerifySequence {
+            configProvider.getAppConfig()
+            keyCache.getEntriesForType(Type.LOCATION_HOUR) // Get all cached hours
+            timeStamper.nowUTC // Timestamp for `expectNewHourPackages` and server index
+            keyServer.getHourIndex("EUR".loc, "2020-01-04".day)
+
+            keyCache.getEntriesForType(Type.LOCATION_DAY) // Which hours are covered by days already
+
+            keyCache.delete(listOf(staleHour.info))
+
+            keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "00:00".hour)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+            keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "02:00".hour)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+        }
+    }
+
+    @Test
+    fun `app config can invalidate cached hours`() = runBlockingTest {
+        // Today is the 4th, 02:15:00
+        mockCachedDay("EUR".loc, "2020-01-01".day)
+        mockCachedDay("EUR".loc, "2020-01-02".day)
+        mockCachedDay("EUR".loc, "2020-01-03".day)
+
+        val invalidHour = mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour)
+        mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour)
+
+        every { downloadConfig.revokedHourPackages } returns listOf(
+            RevokedKeyPackage.Hour(
+                day = invalidHour.info.day,
+                hour = invalidHour.info.hour!!,
+                region = invalidHour.info.location,
+                etag = invalidHour.info.etag!!
+            )
+        )
+
+        val instance = createInstance()
+        instance.syncMissingHourPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult(
+            successful = true,
+            newPackages = keyRepoData.values.filter { it.info.type == Type.LOCATION_HOUR && it.info.hour != "01:00".hour }
+        )
+
+        coVerifySequence {
+            configProvider.getAppConfig()
+
+            keyCache.getAllCachedKeys()
+            keyCache.delete(listOf(invalidHour.info))
+
+            keyCache.getEntriesForType(Type.LOCATION_HOUR) // Get all cached hours
+            timeStamper.nowUTC // Timestamp for `expectNewHourPackages` and server index
+            keyServer.getHourIndex("EUR".loc, "2020-01-04".day)
+
+            keyCache.getEntriesForType(Type.LOCATION_DAY) // Which hours are covered by days already
+
+            keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "00:00".hour)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+            keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "02:00".hour)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+        }
+    }
+
+    @Test
+    fun `determine missing hours checks EXPECT NEW HOURS`() = runBlockingTest {
+        mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour)
+        mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour)
+
+        val instance = createInstance()
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z")
+        instance.determineMissingHours("EUR".loc, false) shouldBe null
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T03:00:00.000Z")
+        instance.determineMissingHours("EUR".loc, false) shouldBe LocationHours(
+            location = "EUR".loc,
+            hourData = mapOf("2020-01-04".day to listOf("02:00".hour))
+        )
+    }
+
+    @Test
+    fun `determine missing hours with forcesync ignores EXPECT NEW HOURS`() = runBlockingTest {
+        mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour)
+        mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour)
+
+        val instance = createInstance()
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z")
+        instance.determineMissingHours("EUR".loc, forceIndexLookup = true) shouldBe LocationHours(
+            location = "EUR".loc,
+            hourData = mapOf("2020-01-04".day to listOf("02:00".hour))
+        )
+    }
+
+    @Test
+    fun `download errors do not abort the whole sync`() = runBlockingTest {
+        var counter = 0
+        coEvery { downloadTool.downloadKeyFile(any(), any()) } answers {
+            if (++counter == 2) throw IOException()
+            arg(0)
+        }
+
+        val instance = createInstance()
+        instance.syncMissingHourPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult(
+            successful = false,
+            newPackages = keyRepoData.values.filter { it.info.type == Type.LOCATION_HOUR && it.info.hour != "01:00".hour }
+        )
+
+        coVerifySequence {
+            configProvider.getAppConfig()
+            keyCache.getEntriesForType(Type.LOCATION_HOUR)
+            timeStamper.nowUTC
+            keyServer.getHourIndex("EUR".loc, "2020-01-04".day)
+
+            keyCache.getEntriesForType(Type.LOCATION_DAY)
+
+            keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "00:00".hour)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+            keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "01:00".hour)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+            keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "02:00".hour)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+        }
+    }
+
+    @Test
+    fun `EXPECT_NEW_HOUR_PACKAGES evaluation`() = runBlockingTest {
+        val cachedKey1 = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { toDateTime() } returns Instant.parse("2020-01-01T00:00:03.000Z").toDateTime(DateTimeZone.UTC)
+            }
+        }
+        val cachedKey2 = mockk<CachedKey>().apply {
+            every { info } returns mockk<CachedKeyInfo>().apply {
+                every { toDateTime() } returns Instant.parse("2020-01-01T01:00:03.000Z").toDateTime(DateTimeZone.UTC)
+            }
+        }
+
+        val instance = createInstance()
+
+        var now = Instant.parse("2020-01-01T02:00:03.000Z")
+        instance.expectNewHourPackages(listOf(cachedKey1), now) shouldBe true
+        instance.expectNewHourPackages(listOf(cachedKey1, cachedKey2), now) shouldBe false
+
+        now = Instant.parse("2020-01-01T03:00:03.000Z")
+        instance.expectNewHourPackages(listOf(cachedKey1, cachedKey2), now) shouldBe true
+    }
+
+    @Test
+    fun `if keys were revoked skip the EXPECT packages check`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z")
+        mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour)
+        mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour)
+        mockCachedHour("EUR".loc, "2020-01-04".day, "02:00".hour).apply {
+            every { downloadConfig.revokedHourPackages } returns listOf(
+                RevokedKeyPackage.Hour(
+                    region = info.location,
+                    etag = info.etag!!,
+                    day = info.day,
+                    hour = info.hour!!
+                )
+            )
+        }
+
+        createInstance().syncMissingHourPackages(listOf("EUR".loc), false)
+
+        coVerify(exactly = 1) { keyServer.getHourIndex("EUR".loc, "2020-01-04".day) }
+    }
+
+    @Test
+    fun `if force-sync is set we skip the EXPECT packages check`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z")
+        mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour)
+        mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour)
+        createInstance().syncMissingHourPackages(listOf("EUR".loc), true)
+
+        coVerify(exactly = 1) { keyServer.getHourIndex("EUR".loc, "2020-01-04".day) }
+    }
+
+    @Test
+    fun `if neither force-sync is set and keys were revoked we check EXPECT NEW PKGS`() = runBlockingTest {
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z")
+        mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour)
+        mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour)
+        createInstance().syncMissingHourPackages(listOf("EUR".loc), false)
+
+        coVerify(exactly = 0) { keyServer.getHourIndex("EUR".loc, "2020-01-04".day) }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadToolTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a5d3b18682e4b3bbc471ccfc7380870002c7ed72
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadToolTest.kt
@@ -0,0 +1,142 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer
+import de.rki.coronawarnapp.diagnosiskeys.server.DownloadInfo
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration
+import io.kotest.assertions.throwables.shouldThrow
+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 io.mockk.mockk
+import kotlinx.coroutines.TimeoutCancellationException
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.test.runBlockingTest
+import okhttp3.Headers
+import org.joda.time.Duration
+import org.joda.time.Instant
+import org.joda.time.LocalDate
+import org.joda.time.LocalTime
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+import java.io.File
+import java.io.IOException
+
+class KeyDownloadToolTest : BaseIOTest() {
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+    private val testFile = File(testDir, "testfile")
+
+    @MockK private lateinit var legacyKeyCache: LegacyKeyCacheMigration
+    @MockK private lateinit var keyServer: DiagnosisKeyServer
+    @MockK private lateinit var keyCache: KeyCacheRepository
+    @MockK private lateinit var downloadConfig: KeyDownloadConfig
+    @MockK private lateinit var cachedKey: CachedKey
+
+    private val cachedKeyInfo = CachedKeyInfo(
+        type = CachedKeyInfo.Type.LOCATION_DAY,
+        location = LocationCode("EUR"),
+        day = LocalDate.parse("2000-01-01"),
+        hour = LocalTime.parse("20:00"),
+        createdAt = Instant.EPOCH
+    )
+    private val downloadInfo = DownloadInfo(
+        headers = Headers.headersOf("ETag", "I'm an ETag :).")
+    )
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        coEvery { keyServer.downloadKeyFile(any(), any(), any(), any(), any()) } returns downloadInfo
+
+        every { cachedKey.path } returns testFile
+        every { cachedKey.info } returns cachedKeyInfo
+
+        every { downloadConfig.individualDownloadTimeout } returns Duration.millis(9000L)
+
+        coEvery { keyCache.markKeyComplete(any(), any()) } just Runs
+        coEvery { keyCache.delete(any()) } just Runs
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createInstance() = KeyDownloadTool(
+        legacyKeyCache = legacyKeyCache,
+        keyServer = keyServer,
+        keyCache = keyCache
+    )
+
+    @Test
+    fun `etag from header is stored`() = runBlockingTest {
+        val instance = createInstance()
+
+        instance.downloadKeyFile(cachedKey, downloadConfig)
+
+        coVerify { keyCache.markKeyComplete(cachedKeyInfo, "I'm an ETag :).") }
+    }
+
+    @Test
+    fun `if the etag is missing we throw an exception`() = runBlockingTest {
+        coEvery { keyServer.downloadKeyFile(any(), any(), any(), any(), any()) } returns DownloadInfo(
+            headers = Headers.headersOf()
+        )
+
+        testFile.writeText("Good Morning")
+
+        val instance = createInstance()
+
+        shouldThrow<IllegalArgumentException> {
+            instance.downloadKeyFile(cachedKey, downloadConfig)
+        }
+    }
+
+    @Test
+    fun `invididual downloads timeout based on appconfig`() = runBlockingTest {
+        coEvery { keyServer.downloadKeyFile(any(), any(), any(), any(), any()) } coAnswers {
+            delay(10 * 1000)
+            mockk()
+        }
+
+        val instance = createInstance()
+
+        advanceUntilIdle()
+
+        shouldThrow<TimeoutCancellationException> {
+            instance.downloadKeyFile(cachedKey, downloadConfig)
+        }
+    }
+
+    @Test
+    fun `failed downloads are deleted`() = runBlockingTest {
+        coEvery { keyServer.downloadKeyFile(any(), any(), any(), any(), any()) } throws IOException()
+
+        val instance = createInstance()
+
+        advanceUntilIdle()
+
+        shouldThrow<IOException> {
+            instance.downloadKeyFile(cachedKey, downloadConfig)
+        }
+
+        coVerify { keyCache.delete(listOf(cachedKeyInfo)) }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloaderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloaderTest.kt
deleted file mode 100644
index b105a1b36e76664d7fcf518f640cc56bde8620b5..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloaderTest.kt
+++ /dev/null
@@ -1,757 +0,0 @@
-package de.rki.coronawarnapp.diagnosiskeys.download
-
-import android.database.SQLException
-import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer
-import de.rki.coronawarnapp.diagnosiskeys.server.DownloadInfo
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
-import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type
-import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
-import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration
-import de.rki.coronawarnapp.storage.DeviceStorage
-import de.rki.coronawarnapp.storage.InsufficientStorageException
-import de.rki.coronawarnapp.storage.TestSettings
-import io.kotest.assertions.throwables.shouldThrow
-import io.kotest.matchers.shouldBe
-import io.mockk.MockKAnnotations
-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.mockk
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.runBlocking
-import org.joda.time.Instant
-import org.joda.time.LocalDate
-import org.joda.time.LocalTime
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
-import testhelpers.BaseIOTest
-import testhelpers.TestDispatcherProvider
-import timber.log.Timber
-import java.io.File
-import java.io.IOException
-import kotlin.time.ExperimentalTime
-
-/**
- * CachedKeyFileHolder test.
- */
-@ExperimentalTime
-@ExperimentalCoroutinesApi
-class KeyFileDownloaderTest : BaseIOTest() {
-
-    @MockK
-    private lateinit var keyCache: KeyCacheRepository
-
-    @MockK
-    private lateinit var legacyMigration: LegacyKeyCacheMigration
-
-    @MockK
-    private lateinit var server: DiagnosisKeyServer
-
-    @MockK
-    private lateinit var deviceStorage: DeviceStorage
-
-    @MockK
-    private lateinit var testSettings: TestSettings
-
-    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
-    private val keyRepoData = mutableMapOf<String, CachedKeyInfo>()
-
-    private val String.loc get() = LocationCode(this)
-    private val String.day get() = LocalDate.parse(this)
-    private val String.hour get() = LocalTime.parse(this)
-
-    @BeforeEach
-    fun setup() {
-        MockKAnnotations.init(this)
-        testDir.mkdirs()
-        testDir.exists() shouldBe true
-
-        every { testSettings.isHourKeyPkgMode } returns false
-
-        coEvery { server.getCountryIndex() } returns listOf("DE".loc, "NL".loc)
-        coEvery { deviceStorage.requireSpacePrivateStorage(any()) } returns mockk<DeviceStorage.CheckResult>().apply {
-            every { isSpaceAvailable } returns true
-        }
-
-        coEvery { server.getDayIndex("DE".loc) } returns listOf(
-            "2020-09-01".day, "2020-09-02".day
-        )
-        coEvery {
-            server.getHourIndex("DE".loc, "2020-09-01".day)
-        } returns (0..23).map { "$it".hour }
-        coEvery {
-            server.getHourIndex("DE".loc, "2020-09-02".day)
-        } returns (0..23).map { "$it".hour }
-        coEvery {
-            server.getHourIndex("DE".loc, "2020-09-03".day)
-        } returns (0..12).map { "$it".hour }
-
-        coEvery { server.getDayIndex("NL".loc) } returns listOf(
-            "2020-09-01".day, "2020-09-02".day
-        )
-        coEvery {
-            server.getHourIndex("NL".loc, "2020-09-01".day)
-        } returns (0..23).map { "$it".hour }
-        coEvery {
-            server.getHourIndex("NL".loc, "2020-09-02".day)
-        } returns (0..23).map { "$it".hour }
-        coEvery {
-            server.getHourIndex("NL".loc, "2020-09-03".day)
-        } returns (0..12).map { "$it".hour }
-
-        coEvery { server.downloadKeyFile(any(), any(), any(), any(), any()) } answers {
-            mockDownloadServerDownload(
-                locationCode = arg(0),
-                day = arg(1),
-                hour = arg(2),
-                saveTo = arg(3)
-            )
-        }
-
-        coEvery { keyCache.createCacheEntry(any(), any(), any(), any()) } answers {
-            mockKeyCacheCreateEntry(arg(0), arg(1), arg(2), arg(3))
-        }
-        coEvery { keyCache.markKeyComplete(any(), any()) } answers {
-            mockKeyCacheUpdateComplete(arg(0), arg(1))
-        }
-        coEvery { keyCache.getEntriesForType(any()) } answers {
-            val type = arg<Type>(0)
-            keyRepoData.values.filter { it.type == type }.map { it to File(testDir, it.id) }
-        }
-        coEvery { keyCache.getAllCachedKeys() } answers {
-            keyRepoData.values.map {
-                it to File(testDir, it.id)
-            }
-        }
-        coEvery { keyCache.delete(any()) } answers {
-            val keyInfos = arg<List<CachedKeyInfo>>(0)
-            keyInfos.forEach {
-                keyRepoData.remove(it.id)
-            }
-        }
-    }
-
-    @AfterEach
-    fun teardown() {
-        clearAllMocks()
-        keyRepoData.clear()
-        testDir.deleteRecursively()
-    }
-
-    private fun mockKeyCacheCreateEntry(
-        type: Type,
-        location: LocationCode,
-        dayIdentifier: LocalDate,
-        hourIdentifier: LocalTime?
-    ): Pair<CachedKeyInfo, File> {
-        val keyInfo = CachedKeyInfo(
-            type = type,
-            location = location,
-            day = dayIdentifier,
-            hour = hourIdentifier,
-            createdAt = Instant.now()
-        )
-        Timber.i("mockKeyCacheCreateEntry(...): %s", keyInfo)
-        val file = File(testDir, keyInfo.id)
-        keyRepoData[keyInfo.id] = keyInfo
-        return keyInfo to file
-    }
-
-    private fun mockKeyCacheUpdateComplete(
-        keyInfo: CachedKeyInfo,
-        checksum: String
-    ) {
-        keyRepoData[keyInfo.id] = keyInfo.copy(
-            isDownloadComplete = true,
-            checksumMD5 = checksum
-        )
-    }
-
-    private fun mockDownloadServerDownload(
-        locationCode: LocationCode,
-        day: LocalDate,
-        hour: LocalTime? = null,
-        saveTo: File,
-        checksumServerMD5: String? = "serverMD5",
-        checksumLocalMD5: String? = "localMD5"
-    ): DownloadInfo {
-        saveTo.writeText("$locationCode.$day.$hour")
-        return mockk<DownloadInfo>().apply {
-            every { serverMD5 } returns checksumServerMD5
-            every { localMD5 } returns checksumLocalMD5
-        }
-    }
-
-    private fun mockAddData(
-        type: Type,
-        location: LocationCode,
-        day: LocalDate,
-        hour: LocalTime?,
-        isCompleted: Boolean
-    ): Pair<CachedKeyInfo, File> {
-        val (keyInfo, file) = mockKeyCacheCreateEntry(type, location, day, hour)
-        if (isCompleted) {
-            mockDownloadServerDownload(
-                locationCode = location,
-                day = day,
-                hour = hour,
-                saveTo = file
-            )
-            mockKeyCacheUpdateComplete(keyInfo, "serverMD5")
-        }
-        return keyRepoData[keyInfo.id]!! to file
-    }
-
-    private fun createDownloader(): KeyFileDownloader {
-        val downloader = KeyFileDownloader(
-            deviceStorage = deviceStorage,
-            keyServer = server,
-            keyCache = keyCache,
-            legacyKeyCache = legacyMigration,
-            testSettings = testSettings,
-            dispatcherProvider = TestDispatcherProvider
-        )
-        Timber.i("createDownloader(): %s", downloader)
-        return downloader
-    }
-
-    @Test
-    fun `wanted country list is empty, day mode`() {
-        val downloader = createDownloader()
-        runBlocking {
-            downloader.asyncFetchKeyFiles(emptyList()) shouldBe emptyList()
-        }
-    }
-
-    @Test
-    fun `wanted country list is empty, hour mode`() {
-        every { testSettings.isHourKeyPkgMode } returns true
-
-        val downloader = createDownloader()
-        runBlocking {
-            downloader.asyncFetchKeyFiles(emptyList()) shouldBe emptyList()
-        }
-    }
-
-    @Test
-    fun `fetching is aborted in day if not enough free storage`() {
-        coEvery { deviceStorage.requireSpacePrivateStorage(1048576L) } throws InsufficientStorageException(
-            mockk(relaxed = true)
-        )
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            shouldThrow<InsufficientStorageException> {
-                downloader.asyncFetchKeyFiles(listOf("DE".loc))
-            }
-        }
-    }
-
-    @Test
-    fun `fetching is aborted in hour if not enough free storage`() {
-        every { testSettings.isHourKeyPkgMode } returns true
-
-        coEvery { deviceStorage.requireSpacePrivateStorage(540672L) } throws InsufficientStorageException(
-            mockk(relaxed = true)
-        )
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            shouldThrow<InsufficientStorageException> {
-                downloader.asyncFetchKeyFiles(listOf("DE".loc))
-            }
-        }
-    }
-
-    @Test
-    fun `error during country index fetch`() {
-        coEvery { server.getCountryIndex() } throws IOException()
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            shouldThrow<IOException> {
-                downloader.asyncFetchKeyFiles(listOf("DE".loc))
-            }
-        }
-    }
-
-    @Test
-    fun `day fetch without prior data`() {
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 4
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-01".day,
-                hourIdentifier = null
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-02".day,
-                hourIdentifier = null
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-01".day,
-                hourIdentifier = null
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-02".day,
-                hourIdentifier = null
-            )
-        }
-        keyRepoData.size shouldBe 4
-        keyRepoData.values.forEach { it.isDownloadComplete shouldBe true }
-        coVerify { deviceStorage.requireSpacePrivateStorage(2097152L) }
-    }
-
-    @Test
-    fun `day fetch with existing data`() {
-        mockAddData(
-            type = Type.COUNTRY_DAY,
-            location = "DE".loc,
-            day = "2020-09-01".day,
-            hour = null,
-            isCompleted = true
-        )
-        mockAddData(
-            type = Type.COUNTRY_DAY,
-            location = "NL".loc,
-            day = "2020-09-02".day,
-            hour = null,
-            isCompleted = true
-        )
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 4
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-02".day,
-                hourIdentifier = null
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-01".day,
-                hourIdentifier = null
-            )
-        }
-
-        coVerify(exactly = 2) { keyCache.createCacheEntry(any(), any(), any(), any()) }
-        coVerify(exactly = 2) { keyCache.markKeyComplete(any(), any()) }
-
-        coVerify { deviceStorage.requireSpacePrivateStorage(1048576L) }
-    }
-
-    @Test
-    fun `day fetch deletes stale data`() {
-        coEvery { server.getDayIndex("DE".loc) } returns listOf("2020-09-02".day)
-        val (staleKeyInfo, _) = mockAddData(
-            type = Type.COUNTRY_DAY,
-            location = "DE".loc,
-            day = "2020-09-01".day,
-            hour = null,
-            isCompleted = true
-        )
-
-        mockAddData(
-            type = Type.COUNTRY_DAY,
-            location = "NL".loc,
-            day = "2020-09-02".day,
-            hour = null,
-            isCompleted = true
-        )
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 3
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-02".day,
-                hourIdentifier = null
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-01".day,
-                hourIdentifier = null
-            )
-        }
-        coVerify(exactly = 1) { keyCache.delete(listOf(staleKeyInfo)) }
-        coVerify(exactly = 2) { keyCache.createCacheEntry(any(), any(), any(), any()) }
-        coVerify(exactly = 2) { keyCache.markKeyComplete(any(), any()) }
-    }
-
-    @Test
-    fun `day fetch skips single download failures`() {
-        var dlCounter = 0
-        coEvery { server.downloadKeyFile(any(), any(), any(), any(), any()) } answers {
-            dlCounter++
-            if (dlCounter == 2) throw IOException("Timeout")
-            mockDownloadServerDownload(
-                locationCode = arg(0),
-                day = arg(1),
-                hour = arg(2),
-                saveTo = arg(3)
-            )
-        }
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 3
-        }
-
-        // We delete the entry for the failed download
-        coVerify(exactly = 1) { keyCache.delete(any()) }
-    }
-
-    @Test
-    fun `last3Hours fetch without prior data`() {
-        every { testSettings.isHourKeyPkgMode } returns true
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 48
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "12".hour
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "11".hour
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "10".hour
-            )
-
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "12".hour
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "11".hour
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "10".hour
-            )
-        }
-        coVerify(exactly = 48) { keyCache.markKeyComplete(any(), any()) }
-
-        keyRepoData.size shouldBe 48
-        keyRepoData.values.forEach { it.isDownloadComplete shouldBe true }
-
-        coVerify { deviceStorage.requireSpacePrivateStorage(1081344L) }
-    }
-
-    @Test
-    fun `last3Hours fetch with prior data`() {
-        every { testSettings.isHourKeyPkgMode } returns true
-
-        mockAddData(
-            type = Type.COUNTRY_HOUR,
-            location = "DE".loc,
-            day = "2020-09-03".day,
-            hour = "11".hour,
-            isCompleted = true
-        )
-        mockAddData(
-            type = Type.COUNTRY_HOUR,
-            location = "NL".loc,
-            day = "2020-09-03".day,
-            hour = "11".hour,
-            isCompleted = true
-        )
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 48
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "12".hour
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-02".day,
-                hourIdentifier = "13".hour
-            )
-
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "12".hour
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-02".day,
-                hourIdentifier = "13".hour
-            )
-        }
-        coVerify(exactly = 46) {
-            server.downloadKeyFile(any(), any(), any(), any(), any())
-        }
-        coVerify { deviceStorage.requireSpacePrivateStorage(1036288L) }
-    }
-
-    @Test
-    fun `last3Hours fetch deletes stale data`() {
-        every { testSettings.isHourKeyPkgMode } returns true
-
-        val (staleKey1, _) = mockAddData(
-            type = Type.COUNTRY_HOUR,
-            location = "DE".loc,
-            day = "2020-09-02".day,
-            hour = "01".hour, // Stale hour
-            isCompleted = true
-        )
-
-        val (staleKey2, _) = mockAddData(
-            type = Type.COUNTRY_HOUR,
-            location = "NL".loc,
-            day = "2020-09-02".day, // Stale day
-            hour = "01".hour,
-            isCompleted = true
-        )
-
-        mockAddData(
-            type = Type.COUNTRY_HOUR,
-            location = "DE".loc,
-            day = "2020-09-03".day,
-            hour = "10".hour,
-            isCompleted = true
-        )
-        mockAddData(
-            type = Type.COUNTRY_HOUR,
-            location = "NL".loc,
-            day = "2020-09-03".day,
-            hour = "10".hour,
-            isCompleted = true
-        )
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 48
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "12".hour
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-02".day,
-                hourIdentifier = "13".hour
-            )
-
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-03".day,
-                hourIdentifier = "12".hour
-            )
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_HOUR,
-                location = "NL".loc,
-                dayIdentifier = "2020-09-02".day,
-                hourIdentifier = "13".hour
-            )
-        }
-        coVerify(exactly = 46) {
-            server.downloadKeyFile(any(), any(), any(), any(), any())
-        }
-        coVerify(exactly = 1) { keyCache.delete(listOf(staleKey1, staleKey2)) }
-    }
-
-    @Test
-    fun `last3Hours fetch skips single download failures`() {
-        every { testSettings.isHourKeyPkgMode } returns true
-
-        var dlCounter = 0
-        coEvery { server.downloadKeyFile(any(), any(), any(), any(), any()) } answers {
-            dlCounter++
-            if (dlCounter % 3 == 0) throw IOException("Timeout")
-            mockDownloadServerDownload(
-                locationCode = arg(0),
-                day = arg(1),
-                hour = arg(2),
-                saveTo = arg(3)
-            )
-        }
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 32
-        }
-
-        // We delete the entry for the failed download
-        coVerify(exactly = 16) { keyCache.delete(any()) }
-    }
-
-    @Test
-    fun `not completed cache entries are overwritten`() {
-        mockAddData(
-            type = Type.COUNTRY_DAY,
-            location = "DE".loc,
-            day = "2020-09-01".day,
-            hour = null,
-            isCompleted = false
-        )
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 4
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-01".day,
-                hourIdentifier = null
-            )
-        }
-    }
-
-    @Test
-    fun `database errors do not abort the whole process`() {
-        var completionCounter = 0
-        coEvery { keyCache.markKeyComplete(any(), any()) } answers {
-            completionCounter++
-            if (completionCounter == 2) throw SQLException(":)")
-            mockKeyCacheUpdateComplete(arg(0), arg(1))
-        }
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 3
-        }
-
-        coVerify(exactly = 4) {
-            server.downloadKeyFile(any(), any(), any(), any(), any())
-        }
-    }
-
-    @Test
-    fun `store server md5`() {
-        coEvery { server.getCountryIndex() } returns listOf("DE".loc)
-        coEvery { server.getDayIndex("DE".loc) } returns listOf("2020-09-01".day)
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc)).size shouldBe 1
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-01".day,
-                hourIdentifier = null
-            )
-        }
-        keyRepoData.size shouldBe 1
-        keyRepoData.values.forEach {
-            it.isDownloadComplete shouldBe true
-            it.checksumMD5 shouldBe "serverMD5"
-        }
-    }
-
-    @Test
-    fun `use local MD5 as fallback if there is none available from the server`() {
-        coEvery { server.getCountryIndex() } returns listOf("DE".loc)
-        coEvery { server.getDayIndex("DE".loc) } returns listOf("2020-09-01".day)
-        coEvery { server.downloadKeyFile(any(), any(), any(), any(), any()) } answers {
-            mockDownloadServerDownload(
-                locationCode = arg(0),
-                day = arg(1),
-                hour = arg(2),
-                saveTo = arg(3),
-                checksumServerMD5 = null
-            )
-        }
-
-        val downloader = createDownloader()
-
-        runBlocking {
-            downloader.asyncFetchKeyFiles(listOf("DE".loc)).size shouldBe 1
-        }
-
-        coVerify {
-            keyCache.createCacheEntry(
-                type = Type.COUNTRY_DAY,
-                location = "DE".loc,
-                dayIdentifier = "2020-09-01".day,
-                hourIdentifier = null
-            )
-        }
-        keyRepoData.size shouldBe 1
-        keyRepoData.values.forEach {
-            it.isDownloadComplete shouldBe true
-            it.checksumMD5 shouldBe "localMD5"
-        }
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncToolTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4579828228e565c51d5e07f3738ba8ca657e9b00
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncToolTest.kt
@@ -0,0 +1,316 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
+import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
+import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.network.NetworkStateProvider
+import de.rki.coronawarnapp.util.preferences.FlowPreference
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerifySequence
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Duration
+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.BaseIOTest
+import testhelpers.preferences.mockFlowPreference
+import java.io.File
+
+class KeyPackageSyncToolTest : BaseIOTest() {
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+
+    @MockK lateinit var keyCache: KeyCacheRepository
+    @MockK lateinit var dayPackageSyncTool: DayPackageSyncTool
+    @MockK lateinit var hourPackageSyncTool: HourPackageSyncTool
+    @MockK lateinit var syncSettings: KeyPackageSyncSettings
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var networkStateProvider: NetworkStateProvider
+    @MockK lateinit var networkState: NetworkStateProvider.State
+    private val lastDownloadDays: FlowPreference<KeyPackageSyncSettings.LastDownload?> = mockFlowPreference(
+        KeyPackageSyncSettings.LastDownload(
+            startedAt = Instant.EPOCH,
+            finishedAt = Instant.EPOCH,
+            successful = true
+        )
+    )
+    private val lastDownloadHours: FlowPreference<KeyPackageSyncSettings.LastDownload?> = mockFlowPreference(
+        KeyPackageSyncSettings.LastDownload(
+            startedAt = Instant.EPOCH,
+            finishedAt = Instant.EPOCH,
+            successful = true
+        )
+    )
+
+    private val cachedDayKey = CachedKey(
+        info = mockk(),
+        path = mockk()
+    )
+    private val cachedHourKey = CachedKey(
+        info = mockk(),
+        path = mockk()
+    )
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        coEvery { keyCache.getAllCachedKeys() } returns listOf()
+        coEvery { keyCache.delete(any()) } just Runs
+        coEvery { syncSettings.lastDownloadDays } returns lastDownloadDays
+        coEvery { syncSettings.lastDownloadHours } returns lastDownloadHours
+
+        coEvery { dayPackageSyncTool.syncMissingDayPackages(any(), any()) } returns BaseKeyPackageSyncTool.SyncResult(
+            successful = true,
+            newPackages = listOf(cachedDayKey)
+        )
+        coEvery { hourPackageSyncTool.syncMissingHourPackages(any(), any()) } returns BaseKeyPackageSyncTool.SyncResult(
+            successful = true,
+            newPackages = listOf(cachedHourKey)
+        )
+
+        every { timeStamper.nowUTC } returns Instant.EPOCH.plus(Duration.standardDays(1))
+
+        every { networkStateProvider.networkState } returns flowOf(networkState)
+        every { networkState.isMeteredConnection } returns false
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    fun createInstance(): KeyPackageSyncTool = KeyPackageSyncTool(
+        keyCache = keyCache,
+        dayPackageSyncTool = dayPackageSyncTool,
+        hourPackageSyncTool = hourPackageSyncTool,
+        syncSettings = syncSettings,
+        timeStamper = timeStamper,
+        networkStateProvider = networkStateProvider
+    )
+
+    @Test
+    fun `normal call sequence`() = runBlockingTest {
+        val instance = createInstance()
+
+        instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result(
+            availableKeys = emptyList(),
+            newKeys = listOf(cachedDayKey, cachedHourKey),
+            wasDaySyncSucccessful = true
+        )
+
+        coVerifySequence {
+            keyCache.getAllCachedKeys() // To clean up stale locations
+
+            lastDownloadDays.value
+            lastDownloadDays.update(any())
+            dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), false)
+            lastDownloadDays.update(any())
+
+            networkStateProvider.networkState // Check metered
+            lastDownloadHours.value
+            lastDownloadHours.update(any())
+            hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), false)
+            lastDownloadHours.update(any())
+
+            keyCache.getAllCachedKeys()
+        }
+    }
+
+    @Test
+    fun `failed day sync is reflected in results property`() = runBlockingTest {
+        coEvery { dayPackageSyncTool.syncMissingDayPackages(any(), any()) } returns BaseKeyPackageSyncTool.SyncResult(
+            successful = false,
+            newPackages = listOf(cachedDayKey)
+        )
+        val instance = createInstance()
+
+        instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result(
+            availableKeys = emptyList(),
+            newKeys = listOf(cachedDayKey, cachedHourKey),
+            wasDaySyncSucccessful = false
+        )
+
+        coVerifySequence {
+            keyCache.getAllCachedKeys() // To clean up stale locations
+
+            lastDownloadDays.value
+            lastDownloadDays.update(any())
+            dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), false)
+            lastDownloadDays.update(any())
+
+            networkStateProvider.networkState // Check metered
+            lastDownloadHours.value
+            lastDownloadHours.update(any())
+            hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), false)
+            lastDownloadHours.update(any())
+
+            keyCache.getAllCachedKeys()
+        }
+    }
+
+    @Test
+    fun `missing last download causes force sync`() = runBlockingTest {
+        lastDownloadDays.update { null }
+        lastDownloadHours.update { null }
+
+        val instance = createInstance()
+
+        instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result(
+            availableKeys = emptyList(),
+            newKeys = listOf(cachedDayKey, cachedHourKey),
+            wasDaySyncSucccessful = true
+        )
+
+        coVerifySequence {
+            // Initial reset
+            lastDownloadDays.update(any())
+            lastDownloadHours.update(any())
+
+            keyCache.getAllCachedKeys() // To clean up stale locations
+
+            lastDownloadDays.value
+            lastDownloadDays.update(any())
+            dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), true)
+            lastDownloadDays.update(any())
+
+            networkStateProvider.networkState // Check metered
+            lastDownloadHours.value
+            lastDownloadHours.update(any())
+            hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), true)
+            lastDownloadHours.update(any())
+
+            keyCache.getAllCachedKeys()
+        }
+    }
+
+    @Test
+    fun `failed last download causes force sync`() = runBlockingTest {
+        lastDownloadDays.update {
+            KeyPackageSyncSettings.LastDownload(
+                startedAt = Instant.EPOCH,
+                finishedAt = Instant.EPOCH,
+                successful = false
+            )
+        }
+        lastDownloadHours.update {
+            KeyPackageSyncSettings.LastDownload(
+                startedAt = Instant.EPOCH,
+                finishedAt = Instant.EPOCH,
+                successful = false
+            )
+        }
+        val instance = createInstance()
+
+        instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result(
+            availableKeys = emptyList(),
+            newKeys = listOf(cachedDayKey, cachedHourKey),
+            wasDaySyncSucccessful = true
+        )
+
+        coVerifySequence {
+            // Initial reset
+            lastDownloadDays.update(any())
+            lastDownloadHours.update(any())
+
+            keyCache.getAllCachedKeys() // To clean up stale locations
+
+            lastDownloadDays.value
+            lastDownloadDays.update(any())
+            dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), true)
+            lastDownloadDays.update(any())
+
+            networkStateProvider.networkState // Check metered
+            lastDownloadHours.value
+            lastDownloadHours.update(any())
+            hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), true)
+            lastDownloadHours.update(any())
+
+            keyCache.getAllCachedKeys()
+        }
+    }
+
+    @Test
+    fun `hourly download does not happen on metered connections`() = runBlockingTest {
+        every { networkState.isMeteredConnection } returns true
+        val instance = createInstance()
+
+        instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result(
+            availableKeys = emptyList(),
+            newKeys = listOf(cachedDayKey),
+            wasDaySyncSucccessful = true
+        )
+
+        coVerifySequence {
+            keyCache.getAllCachedKeys() // To clean up stale locations
+
+            lastDownloadDays.value
+            lastDownloadDays.update(any())
+            dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), false)
+            lastDownloadDays.update(any())
+
+            networkStateProvider.networkState // Check metered
+
+            keyCache.getAllCachedKeys()
+        }
+    }
+
+    @Test
+    fun `we clean up stale location data`() = runBlockingTest {
+        val badLocation = CachedKey(
+            info = mockk<CachedKeyInfo>().apply {
+                every { location } returns LocationCode("NOT-EUR")
+                every { isDownloadComplete } returns true
+            },
+            path = mockk<File>().apply {
+                every { exists() } returns true
+            }
+        )
+        val goodLocation = CachedKey(
+            info = mockk<CachedKeyInfo>().apply {
+                every { location } returns LocationCode("EUR")
+                every { isDownloadComplete } returns true
+            },
+            path = mockk<File>().apply {
+                every { exists() } returns true
+            }
+        )
+        coEvery { keyCache.getAllCachedKeys() } returns listOf(badLocation, goodLocation)
+        val instance = createInstance()
+
+        instance.syncKeyFiles()
+
+        coVerifySequence {
+            keyCache.getAllCachedKeys() // To clean up stale locations
+            keyCache.delete(listOf(badLocation.info))
+
+            lastDownloadDays.value
+            lastDownloadDays.update(any())
+            dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), false)
+            lastDownloadDays.update(any())
+
+            networkStateProvider.networkState // Check metered
+            lastDownloadHours.value
+            lastDownloadHours.update(any())
+            hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), false)
+            lastDownloadHours.update(any())
+
+            keyCache.getAllCachedKeys()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt
index a8ed796f69eb60e567d503d66f02018d6e43a7d2..85ab7828ae8c3c2fc05955410ebeba28c6d54a5d 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt
@@ -56,7 +56,7 @@ class DiagnosisKeyApiTest : BaseIOTest() {
         webServer.enqueue(MockResponse().setBody("[\"DE\",\"NL\"]"))
 
         runBlocking {
-            api.getCountryIndex() shouldBe listOf("DE", "NL")
+            api.getLocationIndex() shouldBe listOf("DE", "NL")
         }
 
         val request = webServer.takeRequest(5, TimeUnit.SECONDS)!!
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServerTest.kt
index 08aed5e5ad0fcfd395d52169b7c3b3d760e9b049..9f2fea42a597ba9bdfec45dca8a1e87a72b02de0 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServerTest.kt
@@ -50,15 +50,15 @@ class DiagnosisKeyServerTest : BaseIOTest() {
     @Test
     fun `download country index`() {
         val downloadServer = createDownloadServer()
-        coEvery { api.getCountryIndex() } returns listOf("DE", "NL")
+        coEvery { api.getLocationIndex() } returns listOf("DE", "NL")
 
         runBlocking {
-            downloadServer.getCountryIndex() shouldBe listOf(
+            downloadServer.getLocationIndex() shouldBe listOf(
                 LocationCode("DE"), LocationCode("NL")
             )
         }
 
-        coVerify { api.getCountryIndex() }
+        coVerify { api.getLocationIndex() }
     }
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfoTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfoTest.kt
index cceb7f2d5481cccdb7f1e6c3edd1afbaf38bf0e4..d240050872a8d3255222638c54eeac35115b54ae 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfoTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfoTest.kt
@@ -10,10 +10,8 @@ class DownloadInfoTest : BaseTest() {
     @Test
     fun `extract server MD5`() {
         val info = DownloadInfo(
-            headers = Headers.headersOf("ETAG", "serverMD5"),
-            localMD5 = "localMD5"
+            headers = Headers.headersOf("ETag", "\"etag\"")
         )
-        info.serverMD5 shouldBe "serverMD5"
-        info.localMD5 shouldBe "localMD5"
+        info.etag shouldBe "\"etag\""
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyFileTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyFileTest.kt
index cf1d1ebe08debced8dc4ccda7b90eed1e5d0da18..6b399b506179c140115f5a3dcedf7f0f726cf146 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyFileTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyFileTest.kt
@@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 
 class CachedKeyFileTest : BaseTest() {
-    private val type = CachedKeyInfo.Type.COUNTRY_DAY
+    private val type = CachedKeyInfo.Type.LOCATION_DAY
     private val location = LocationCode("DE")
     private val day = LocalDate.parse("2222-12-31")
     private val hour = LocalTime.parse("23:59")
@@ -20,7 +20,7 @@ class CachedKeyFileTest : BaseTest() {
         val key = CachedKeyInfo(type, location, day, hour, now)
 
         key.id shouldBe CachedKeyInfo.calcluateId(location, day, hour, type)
-        key.checksumMD5 shouldBe null
+        key.etag shouldBe null
         key.isDownloadComplete shouldBe false
     }
 
@@ -42,21 +42,13 @@ class CachedKeyFileTest : BaseTest() {
         downloadCompleteUpdate shouldBe CachedKeyInfo.DownloadUpdate(
             id = downloadCompleteUpdate.id,
             isDownloadComplete = true,
-            checksumMD5 = testChecksum
-        )
-
-        val resetDownloadUpdate = key.toDownloadUpdate(null)
-
-        resetDownloadUpdate shouldBe CachedKeyInfo.DownloadUpdate(
-            id = downloadCompleteUpdate.id,
-            isDownloadComplete = false,
-            checksumMD5 = null
+            etag = testChecksum
         )
     }
 
     @Test
     fun `trip changed typing`() {
-        CachedKeyInfo.Type.COUNTRY_DAY.typeValue shouldBe "country_day"
-        CachedKeyInfo.Type.COUNTRY_HOUR.typeValue shouldBe "country_hour"
+        CachedKeyInfo.Type.LOCATION_DAY.typeValue shouldBe "country_day"
+        CachedKeyInfo.Type.LOCATION_HOUR.typeValue shouldBe "country_hour"
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepositoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepositoryTest.kt
index 95fe5150464b5906f94d5555fd0ec10785712d56..2b1b205ee3cf4cf210243710d8d0cb3c200a3b71 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepositoryTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepositoryTest.kt
@@ -10,6 +10,7 @@ import io.mockk.coEvery
 import io.mockk.coVerify
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.runBlocking
 import org.joda.time.Instant
 import org.joda.time.LocalDate
@@ -50,7 +51,7 @@ class KeyCacheRepositoryTest : BaseIOTest() {
         every { databaseFactory.create() } returns database
         every { database.cachedKeyFiles() } returns keyfileDAO
 
-        coEvery { keyfileDAO.getAllEntries() } returns emptyList()
+        coEvery { keyfileDAO.allEntries() } returns flowOf(emptyList())
     }
 
     @AfterEach
@@ -71,18 +72,18 @@ class KeyCacheRepositoryTest : BaseIOTest() {
             location = LocationCode("DE"),
             day = LocalDate.now(),
             hour = LocalTime.now(),
-            type = CachedKeyInfo.Type.COUNTRY_HOUR,
+            type = CachedKeyInfo.Type.LOCATION_HOUR,
             createdAt = Instant.now()
         ).copy(
             isDownloadComplete = true,
-            checksumMD5 = "checksum"
+            etag = "checksum"
         )
 
         val existingKey = CachedKeyInfo(
             location = LocationCode("NL"),
             day = LocalDate.now(),
             hour = LocalTime.now(),
-            type = CachedKeyInfo.Type.COUNTRY_HOUR,
+            type = CachedKeyInfo.Type.LOCATION_HOUR,
             createdAt = Instant.now()
         )
 
@@ -91,7 +92,7 @@ class KeyCacheRepositoryTest : BaseIOTest() {
             createNewFile()
         }
 
-        coEvery { keyfileDAO.getAllEntries() } returns listOf(lostKey, existingKey)
+        coEvery { keyfileDAO.allEntries() } returns flowOf(listOf(lostKey, existingKey))
         coEvery { keyfileDAO.updateDownloadState(any()) } returns Unit
         coEvery { keyfileDAO.deleteEntry(lostKey) } returns Unit
 
@@ -101,7 +102,7 @@ class KeyCacheRepositoryTest : BaseIOTest() {
 
         runBlocking {
             repo.getAllCachedKeys()
-            coVerify(exactly = 2) { keyfileDAO.getAllEntries() }
+            coVerify(exactly = 2) { keyfileDAO.allEntries() }
             coVerify { keyfileDAO.deleteEntry(lostKey) }
         }
     }
@@ -117,7 +118,7 @@ class KeyCacheRepositoryTest : BaseIOTest() {
                 location = LocationCode("NL"),
                 dayIdentifier = LocalDate.parse("2020-09-09"),
                 hourIdentifier = LocalTime.parse("23:00"),
-                type = CachedKeyInfo.Type.COUNTRY_HOUR
+                type = CachedKeyInfo.Type.LOCATION_HOUR
             )
 
             path shouldBe File(context.cacheDir, "diagnosis_keys/${keyFile.id}.zip")
@@ -138,7 +139,7 @@ class KeyCacheRepositoryTest : BaseIOTest() {
                 location = LocationCode("NL"),
                 dayIdentifier = LocalDate.parse("2020-09-09"),
                 hourIdentifier = LocalTime.parse("23:00"),
-                type = CachedKeyInfo.Type.COUNTRY_HOUR
+                type = CachedKeyInfo.Type.LOCATION_HOUR
             )
 
             repo.markKeyComplete(keyFile, "checksum")
@@ -162,7 +163,7 @@ class KeyCacheRepositoryTest : BaseIOTest() {
                 location = LocationCode("NL"),
                 dayIdentifier = LocalDate.parse("2020-09-09"),
                 hourIdentifier = LocalTime.parse("23:00"),
-                type = CachedKeyInfo.Type.COUNTRY_HOUR
+                type = CachedKeyInfo.Type.LOCATION_HOUR
             )
 
             path.createNewFile() shouldBe true
@@ -184,11 +185,11 @@ class KeyCacheRepositoryTest : BaseIOTest() {
             location = LocationCode("DE"),
             day = LocalDate.now(),
             hour = LocalTime.now(),
-            type = CachedKeyInfo.Type.COUNTRY_HOUR,
+            type = CachedKeyInfo.Type.LOCATION_HOUR,
             createdAt = Instant.now()
         )
 
-        coEvery { keyfileDAO.getAllEntries() } returns listOf(keyFileToClear)
+        coEvery { keyfileDAO.allEntries() } returns flowOf(listOf(keyFileToClear))
         coEvery { keyfileDAO.deleteEntry(any()) } returns Unit
 
         val keyFilePath = repo.getPathForKey(keyFileToClear)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
index 91e19f01dea1acbf3fe3d8c71e97398ecdcc1a2b..44a4980d7826e632fd792e724dc1c8d8c807d308 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
@@ -1,6 +1,7 @@
 package de.rki.coronawarnapp.main.home
 
 import android.content.Context
+import de.rki.coronawarnapp.notification.TestResultNotificationService
 import de.rki.coronawarnapp.storage.TracingRepository
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus.Status
@@ -8,9 +9,12 @@ import de.rki.coronawarnapp.ui.main.home.HomeFragmentViewModel
 import de.rki.coronawarnapp.ui.main.home.SubmissionCardState
 import de.rki.coronawarnapp.ui.main.home.SubmissionCardsStateProvider
 import de.rki.coronawarnapp.ui.main.home.TracingHeaderState
+import de.rki.coronawarnapp.ui.submission.ApiRequestState.SUCCESS
 import de.rki.coronawarnapp.ui.tracing.card.TracingCardState
 import de.rki.coronawarnapp.ui.tracing.card.TracingCardStateProvider
 import de.rki.coronawarnapp.ui.viewmodel.SettingsViewModel
+import de.rki.coronawarnapp.util.DeviceUIState.PAIRED_POSITIVE
+import de.rki.coronawarnapp.util.DeviceUIState.PAIRED_POSITIVE_TELETAN
 import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
@@ -21,6 +25,7 @@ import io.mockk.mockk
 import io.mockk.verify
 import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.runBlocking
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
@@ -41,6 +46,7 @@ class HomeFragmentViewModelTest : BaseTest() {
     @MockK lateinit var tracingCardStateProvider: TracingCardStateProvider
     @MockK lateinit var submissionCardsStateProvider: SubmissionCardsStateProvider
     @MockK lateinit var tracingRepository: TracingRepository
+    @MockK lateinit var testResultNotificationService: TestResultNotificationService
 
     @BeforeEach
     fun setup() {
@@ -63,7 +69,8 @@ class HomeFragmentViewModelTest : BaseTest() {
         tracingStatus = generalTracingStatus,
         tracingCardStateProvider = tracingCardStateProvider,
         submissionCardsStateProvider = submissionCardsStateProvider,
-        tracingRepository = tracingRepository
+        tracingRepository = tracingRepository,
+        testResultNotificationService = testResultNotificationService
     )
 
     @Test
@@ -118,4 +125,32 @@ class HomeFragmentViewModelTest : BaseTest() {
             verify { submissionCardsStateProvider.state }
         }
     }
+
+    @Test
+    fun `positive test result notification is triggered on positive QR code result`() {
+        val state = SubmissionCardState(PAIRED_POSITIVE, true, SUCCESS)
+        every { submissionCardsStateProvider.state } returns flowOf(state)
+        every { testResultNotificationService.schedulePositiveTestResultReminder() } returns Unit
+
+        runBlocking {
+            createInstance().apply {
+                observeTestResultToSchedulePositiveTestResultReminder()
+                verify { testResultNotificationService.schedulePositiveTestResultReminder() }
+            }
+        }
+    }
+
+    @Test
+    fun `positive test result notification is triggered on positive TeleTan code result`() {
+        val state = SubmissionCardState(PAIRED_POSITIVE_TELETAN, true, SUCCESS)
+        every { submissionCardsStateProvider.state } returns flowOf(state)
+        every { testResultNotificationService.schedulePositiveTestResultReminder() } returns Unit
+
+        runBlocking {
+            createInstance().apply {
+                observeTestResultToSchedulePositiveTestResultReminder()
+                verify { testResultNotificationService.schedulePositiveTestResultReminder() }
+            }
+        }
+    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
index 541eedf627670fbf71a60a819a15cf71e661771d..4880b66aa8e79d52d909cc7e2ecdc9fc47a5ea96 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
@@ -2,8 +2,8 @@ package de.rki.coronawarnapp.nearby
 
 import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
 import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
 import de.rki.coronawarnapp.nearby.modules.locationless.ScanningSupport
 import de.rki.coronawarnapp.nearby.modules.tracing.TracingStatus
@@ -37,13 +37,13 @@ class ENFClientTest : BaseTest() {
     @MockK lateinit var diagnosisKeyProvider: DiagnosisKeyProvider
     @MockK lateinit var tracingStatus: TracingStatus
     @MockK lateinit var scanningSupport: ScanningSupport
-    @MockK lateinit var calculationTracker: CalculationTracker
+    @MockK lateinit var exposureDetectionTracker: ExposureDetectionTracker
 
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
         coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns true
-        every { calculationTracker.trackNewCalaculation(any()) } just Runs
+        every { exposureDetectionTracker.trackNewExposureDetection(any()) } just Runs
     }
 
     @AfterEach
@@ -56,7 +56,7 @@ class ENFClientTest : BaseTest() {
         diagnosisKeyProvider = diagnosisKeyProvider,
         tracingStatus = tracingStatus,
         scanningSupport = scanningSupport,
-        calculationTracker = calculationTracker
+        exposureDetectionTracker = exposureDetectionTracker
     )
 
     @Test
@@ -135,14 +135,14 @@ class ENFClientTest : BaseTest() {
     @Test
     fun `calculation state depends on the last started calculation`() {
         runBlocking {
-            every { calculationTracker.calculations } returns flowOf(
+            every { exposureDetectionTracker.calculations } returns flowOf(
                 mapOf(
-                    "1" to Calculation(
+                    "1" to TrackedExposureDetection(
                         identifier = "1",
                         startedAt = Instant.EPOCH,
                         finishedAt = Instant.EPOCH
                     ),
-                    "2" to Calculation(
+                    "2" to TrackedExposureDetection(
                         identifier = "2",
                         startedAt = Instant.EPOCH,
                         finishedAt = Instant.EPOCH.plus(1)
@@ -150,17 +150,17 @@ class ENFClientTest : BaseTest() {
                 )
             )
 
-            createClient().isCurrentlyCalculating().first() shouldBe false
+            createClient().isPerformingExposureDetection().first() shouldBe false
         }
 
         runBlocking {
-            every { calculationTracker.calculations } returns flowOf(
+            every { exposureDetectionTracker.calculations } returns flowOf(
                 mapOf(
-                    "1" to Calculation(
+                    "1" to TrackedExposureDetection(
                         identifier = "1",
                         startedAt = Instant.EPOCH.plus(5)
                     ),
-                    "2" to Calculation(
+                    "2" to TrackedExposureDetection(
                         identifier = "2",
                         startedAt = Instant.EPOCH.plus(4),
                         finishedAt = Instant.EPOCH.plus(1)
@@ -168,101 +168,101 @@ class ENFClientTest : BaseTest() {
                 )
             )
 
-            createClient().isCurrentlyCalculating().first() shouldBe true
+            createClient().isPerformingExposureDetection().first() shouldBe true
         }
 
         runBlocking {
-            every { calculationTracker.calculations } returns flowOf(
+            every { exposureDetectionTracker.calculations } returns flowOf(
                 mapOf(
-                    "1" to Calculation(
+                    "1" to TrackedExposureDetection(
                         identifier = "1",
                         startedAt = Instant.EPOCH
                     ),
-                    "2" to Calculation(
+                    "2" to TrackedExposureDetection(
                         identifier = "2",
                         startedAt = Instant.EPOCH,
                         finishedAt = Instant.EPOCH.plus(2)
                     ),
-                    "3" to Calculation(
+                    "3" to TrackedExposureDetection(
                         identifier = "3",
                         startedAt = Instant.EPOCH.plus(1)
                     )
                 )
             )
 
-            createClient().isCurrentlyCalculating().first() shouldBe true
+            createClient().isPerformingExposureDetection().first() shouldBe true
         }
     }
 
     @Test
     fun `validate that we only get the last finished calcluation`() {
         runBlocking {
-            every { calculationTracker.calculations } returns flowOf(
+            every { exposureDetectionTracker.calculations } returns flowOf(
                 mapOf(
-                    "1" to Calculation(
+                    "1" to TrackedExposureDetection(
                         identifier = "1",
                         startedAt = Instant.EPOCH,
-                        result = Calculation.Result.UPDATED_STATE,
+                        result = TrackedExposureDetection.Result.UPDATED_STATE,
                         finishedAt = Instant.EPOCH
                     ),
-                    "2" to Calculation(
+                    "2" to TrackedExposureDetection(
                         identifier = "2",
                         startedAt = Instant.EPOCH,
-                        result = Calculation.Result.UPDATED_STATE,
+                        result = TrackedExposureDetection.Result.UPDATED_STATE,
                         finishedAt = Instant.EPOCH.plus(1)
                     ),
-                    "2-timeout" to Calculation(
+                    "2-timeout" to TrackedExposureDetection(
                         identifier = "2-timeout",
                         startedAt = Instant.EPOCH,
-                        result = Calculation.Result.TIMEOUT,
+                        result = TrackedExposureDetection.Result.TIMEOUT,
                         finishedAt = Instant.EPOCH.plus(2)
                     ),
-                    "3" to Calculation(
+                    "3" to TrackedExposureDetection(
                         identifier = "3",
-                        result = Calculation.Result.UPDATED_STATE,
+                        result = TrackedExposureDetection.Result.UPDATED_STATE,
                         startedAt = Instant.EPOCH.plus(2)
                     )
                 )
             )
 
-            createClient().latestFinishedCalculation().first()!!.identifier shouldBe "2"
+            createClient().lastSuccessfulTrackedExposureDetection().first()!!.identifier shouldBe "2"
         }
 
         runBlocking {
-            every { calculationTracker.calculations } returns flowOf(
+            every { exposureDetectionTracker.calculations } returns flowOf(
                 mapOf(
-                    "0" to Calculation(
+                    "0" to TrackedExposureDetection(
                         identifier = "1",
-                        result = Calculation.Result.UPDATED_STATE,
+                        result = TrackedExposureDetection.Result.UPDATED_STATE,
                         startedAt = Instant.EPOCH.plus(3)
                     ),
-                    "1-timeout" to Calculation(
+                    "1-timeout" to TrackedExposureDetection(
                         identifier = "1-timeout",
                         startedAt = Instant.EPOCH,
-                        result = Calculation.Result.TIMEOUT,
+                        result = TrackedExposureDetection.Result.TIMEOUT,
                         finishedAt = Instant.EPOCH.plus(3)
                     ),
-                    "1" to Calculation(
+                    "1" to TrackedExposureDetection(
                         identifier = "1",
                         startedAt = Instant.EPOCH,
-                        result = Calculation.Result.UPDATED_STATE,
+                        result = TrackedExposureDetection.Result.UPDATED_STATE,
                         finishedAt = Instant.EPOCH.plus(2)
                     ),
-                    "2" to Calculation(
+                    "2" to TrackedExposureDetection(
                         identifier = "2",
-                        result = Calculation.Result.UPDATED_STATE,
+                        result = TrackedExposureDetection.Result.UPDATED_STATE,
                         startedAt = Instant.EPOCH
                     ),
-                    "3" to Calculation(
+                    "3" to TrackedExposureDetection(
                         identifier = "3",
                         startedAt = Instant.EPOCH,
-                        result = Calculation.Result.UPDATED_STATE,
+                        result = TrackedExposureDetection.Result.UPDATED_STATE,
                         finishedAt = Instant.EPOCH
                     )
                 )
             )
 
-            createClient().latestFinishedCalculation().first()!!.identifier shouldBe "1"
+            createClient().lastSuccessfulTrackedExposureDetection().first()!!.identifier shouldBe "1"
         }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTrackerTest.kt
similarity index 77%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTrackerTest.kt
index 976ba6a4b0cc0015cc0636c19c747fac90921fee..0521bdb63cc6523f953df8dd9cf190428c27f25b 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTrackerTest.kt
@@ -1,5 +1,7 @@
-package de.rki.coronawarnapp.nearby.modules.calculationtracker
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
 
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.ConfigData
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.mutate
 import io.kotest.matchers.shouldBe
@@ -16,6 +18,7 @@ import io.mockk.just
 import io.mockk.verify
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.test.runBlockingTest
 import org.joda.time.Duration
 import org.joda.time.Instant
@@ -27,10 +30,12 @@ import testhelpers.TestDispatcherProvider
 import testhelpers.coroutines.runBlockingTest2
 import java.util.UUID
 
-class DefaultCalculationTrackerTest : BaseTest() {
+class DefaultExposureDetectionTrackerTest : BaseTest() {
 
-    @MockK lateinit var storage: CalculationTrackerStorage
+    @MockK lateinit var storage: ExposureDetectionTrackerStorage
     @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var configProvider: AppConfigProvider
+    @MockK lateinit var appConfigData: ConfigData
 
     @BeforeEach
     fun setup() {
@@ -39,6 +44,9 @@ class DefaultCalculationTrackerTest : BaseTest() {
         every { timeStamper.nowUTC } returns Instant.EPOCH
         coEvery { storage.load() } returns emptyMap()
         coEvery { storage.save(any()) } just Runs
+
+        coEvery { configProvider.currentConfig } returns flowOf(appConfigData)
+        every { appConfigData.overallDetectionTimeout } returns Duration.standardMinutes(15)
     }
 
     @AfterEach
@@ -46,11 +54,12 @@ class DefaultCalculationTrackerTest : BaseTest() {
         clearAllMocks()
     }
 
-    private fun createInstance(scope: CoroutineScope) = DefaultCalculationTracker(
+    private fun createInstance(scope: CoroutineScope) = DefaultExposureDetectionTracker(
         scope = scope,
         dispatcherProvider = TestDispatcherProvider,
         storage = storage,
-        timeStamper = timeStamper
+        timeStamper = timeStamper,
+        appConfigProvider = configProvider
     )
 
     @Test
@@ -62,7 +71,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
 
     @Test
     fun `data is restored from storage`() = runBlockingTest2(ignoreActive = true) {
-        val calcData = Calculation(
+        val calcData = TrackedExposureDetection(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH
         )
@@ -76,7 +85,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     fun `tracking a new calculation`() = runBlockingTest2(ignoreActive = true) {
         createInstance(scope = this).apply {
             val expectedIdentifier = UUID.randomUUID().toString()
-            trackNewCalaculation(expectedIdentifier)
+            trackNewExposureDetection(expectedIdentifier)
 
             advanceUntilIdle()
 
@@ -84,7 +93,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
 
             calculationData.entries.single().apply {
                 key shouldBe expectedIdentifier
-                value shouldBe Calculation(
+                value shouldBe TrackedExposureDetection(
                     identifier = expectedIdentifier,
                     startedAt = Instant.EPOCH
                 )
@@ -98,11 +107,13 @@ class DefaultCalculationTrackerTest : BaseTest() {
             }
             advanceUntilIdle()
         }
+
+        coVerify { configProvider.currentConfig }
     }
 
     @Test
     fun `finish an existing calcluation`() = runBlockingTest2(ignoreActive = true) {
-        val calcData = Calculation(
+        val calcData = TrackedExposureDetection(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH
         )
@@ -112,14 +123,14 @@ class DefaultCalculationTrackerTest : BaseTest() {
         val expectedData = initialData.mutate {
             this[calcData.identifier] = this[calcData.identifier]!!.copy(
                 finishedAt = Instant.EPOCH.plus(1),
-                result = Calculation.Result.UPDATED_STATE
+                result = TrackedExposureDetection.Result.UPDATED_STATE
             )
         }
 
         every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1)
 
         createInstance(scope = this).apply {
-            finishCalculation(calcData.identifier, Calculation.Result.UPDATED_STATE)
+            finishExposureDetection(calcData.identifier, TrackedExposureDetection.Result.UPDATED_STATE)
 
             advanceUntilIdle()
 
@@ -137,11 +148,11 @@ class DefaultCalculationTrackerTest : BaseTest() {
 
     @Test
     fun `a late calculation overwrites timeout state`() = runBlockingTest2(ignoreActive = true) {
-        val calcData = Calculation(
+        val calcData = TrackedExposureDetection(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH,
             finishedAt = Instant.EPOCH.plus(1),
-            result = Calculation.Result.TIMEOUT
+            result = TrackedExposureDetection.Result.TIMEOUT
         )
         val initialData = mapOf(calcData.identifier to calcData)
         coEvery { storage.load() } returns initialData
@@ -151,12 +162,12 @@ class DefaultCalculationTrackerTest : BaseTest() {
         val expectedData = initialData.mutate {
             this[calcData.identifier] = this[calcData.identifier]!!.copy(
                 finishedAt = Instant.EPOCH.plus(2),
-                result = Calculation.Result.UPDATED_STATE
+                result = TrackedExposureDetection.Result.UPDATED_STATE
             )
         }
 
         createInstance(scope = this).apply {
-            finishCalculation(calcData.identifier, Calculation.Result.UPDATED_STATE)
+            finishExposureDetection(calcData.identifier, TrackedExposureDetection.Result.UPDATED_STATE)
 
             advanceUntilIdle()
 
@@ -167,7 +178,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     @Test
     fun `no more than 10 calcluations are tracked`() = runBlockingTest2(ignoreActive = true) {
         val calcData = (1..15L).map {
-            val calcData = Calculation(
+            val calcData = TrackedExposureDetection(
                 identifier = "$it",
                 startedAt = Instant.EPOCH.plus(it)
             )
@@ -178,7 +189,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
 
         every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1)
         createInstance(scope = this).apply {
-            finishCalculation("7", Calculation.Result.UPDATED_STATE)
+            finishExposureDetection("7", TrackedExposureDetection.Result.UPDATED_STATE)
 
             advanceUntilIdle()
 
@@ -195,32 +206,32 @@ class DefaultCalculationTrackerTest : BaseTest() {
             .plus(2)
 
         // First half will be in the timeout, last half will be ok
-        val timeoutOnRunningCalc = Calculation(
+        val timeoutOnRunningCalc = TrackedExposureDetection(
             identifier = "0",
             startedAt = Instant.EPOCH
         )
-        val timeoutonRunningCalc2 = Calculation(
+        val timeoutonRunningCalc2 = TrackedExposureDetection(
             identifier = "1",
             startedAt = Instant.EPOCH.plus(1)
         )
         // We shouldn't care for timeouts on finished calculations
-        val timeoutIgnoresFinishedCalcs = Calculation(
+        val timeoutIgnoresFinishedCalcs = TrackedExposureDetection(
             identifier = "2",
             startedAt = Instant.EPOCH.plus(1),
             finishedAt = Instant.EPOCH.plus(15)
         )
 
         // This one is right on the edge, testing <= behavior
-        val timeoutRunningOnEdge = Calculation(
+        val timeoutRunningOnEdge = TrackedExposureDetection(
             identifier = "3",
             startedAt = Instant.EPOCH.plus(2)
         )
 
-        val noTimeoutCalcRunning = Calculation(
+        val noTimeoutCalcRunning = TrackedExposureDetection(
             identifier = "4",
             startedAt = Instant.EPOCH.plus(4)
         )
-        val noTimeOutCalcFinished = Calculation(
+        val noTimeOutCalcFinished = TrackedExposureDetection(
             identifier = "5",
             startedAt = Instant.EPOCH.plus(5),
             finishedAt = Instant.EPOCH.plus(15)
@@ -245,11 +256,11 @@ class DefaultCalculationTrackerTest : BaseTest() {
 
                 this["0"] shouldBe timeoutOnRunningCalc.copy(
                     finishedAt = timeStamper.nowUTC,
-                    result = Calculation.Result.TIMEOUT
+                    result = TrackedExposureDetection.Result.TIMEOUT
                 )
                 this["1"] shouldBe timeoutonRunningCalc2.copy(
                     finishedAt = timeStamper.nowUTC,
-                    result = Calculation.Result.TIMEOUT
+                    result = TrackedExposureDetection.Result.TIMEOUT
                 )
                 this["2"] shouldBe timeoutIgnoresFinishedCalcs
 
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorageTest.kt
similarity index 87%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorageTest.kt
index 4a7c2a019b42c627cf506154457c74be5bf5b1b4..fee9f63fa4177ddbb0fc6819d6788fe67535c514 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorageTest.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.nearby.modules.calculationtracker
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
 
 import android.content.Context
 import com.google.gson.GsonBuilder
@@ -17,7 +17,7 @@ import org.junit.jupiter.api.Test
 import testhelpers.BaseIOTest
 import java.io.File
 
-class CalculationTrackerStorageTest : BaseIOTest() {
+class ExposureDetectionTrackerStorageTest : BaseIOTest() {
 
     @MockK private lateinit var context: Context
 
@@ -50,15 +50,15 @@ class CalculationTrackerStorageTest : BaseIOTest() {
         """.trimIndent()
 
     private val demoData = run {
-        val calculation1 = Calculation(
+        val calculation1 = TrackedExposureDetection(
             identifier = "b2b98400-058d-43e6-b952-529a5255248b",
             startedAt = Instant.ofEpochMilli(1234)
         )
-        val calculation2 = Calculation(
+        val calculation2 = TrackedExposureDetection(
             identifier = "aeb15509-fb34-42ce-8795-7a9ae0c2f389",
             startedAt = Instant.ofEpochMilli(5678),
             finishedAt = Instant.ofEpochMilli(1603473968125),
-            result = Calculation.Result.UPDATED_STATE
+            result = TrackedExposureDetection.Result.UPDATED_STATE
         )
         mapOf(
             calculation1.identifier to calculation1,
@@ -78,7 +78,7 @@ class CalculationTrackerStorageTest : BaseIOTest() {
         testDir.deleteRecursively()
     }
 
-    private fun createStorage() = CalculationTrackerStorage(
+    private fun createStorage() = ExposureDetectionTrackerStorage(
         context = context,
         gson = SerializationModule().baseGson()
     )
@@ -117,7 +117,7 @@ class CalculationTrackerStorageTest : BaseIOTest() {
         createStorage().save(demoData)
         storageFile.exists() shouldBe true
 
-        val storedData: Map<String, Calculation> = gson.fromJson(storageFile)
+        val storedData: Map<String, TrackedExposureDetection> = gson.fromJson(storageFile)
 
         storedData shouldBe demoData
         gson.toJson(storedData) shouldBe demoJsonString
@@ -126,7 +126,7 @@ class CalculationTrackerStorageTest : BaseIOTest() {
     @Test
     fun `gson does weird things to property initialization`() {
         // This makes sure we are using val-getters, otherwise gson inits our @Ŧransient properties to false
-        val storedData: Map<String, Calculation> = gson.fromJson(demoJsonString)
+        val storedData: Map<String, TrackedExposureDetection> = gson.fromJson(demoJsonString)
         storedData.getValue("b2b98400-058d-43e6-b952-529a5255248b").isCalculating shouldBe true
         storedData.getValue("aeb15509-fb34-42ce-8795-7a9ae0c2f389").isCalculating shouldBe false
     }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetectionTest.kt
similarity index 81%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetectionTest.kt
index 7afb7e551f7e437343d5fcb00a6fef71db8bd69b..d30014e3ca84e5d42ca202c4f4468c9ddf5025d7 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetectionTest.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.nearby.modules.calculationtracker
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
 
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
@@ -9,7 +9,7 @@ import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 
-class CalculationTest : BaseTest() {
+class TrackedExposureDetectionTest : BaseTest() {
 
     @BeforeEach
     fun setup() {
@@ -23,7 +23,7 @@ class CalculationTest : BaseTest() {
 
     @Test
     fun `isCalculating flag depends on finishedAt`() {
-        val initial = Calculation(
+        val initial = TrackedExposureDetection(
             identifier = "123",
             startedAt = Instant.EPOCH
         )
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt
index f0802e4c311c65a1b3d2ea44b276e25b609a93a2..fbc170105e025901200d40c8dc3e40d92a95e3c8 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt
@@ -8,8 +8,8 @@ import androidx.work.WorkRequest
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
 import dagger.android.AndroidInjector
 import dagger.android.HasAndroidInjector
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
 import de.rki.coronawarnapp.util.di.AppInjector
 import io.mockk.MockKAnnotations
 import io.mockk.clearAllMocks
@@ -35,7 +35,7 @@ class ExposureStateUpdateReceiverTest : BaseTest() {
 
     @MockK private lateinit var intent: Intent
     @MockK private lateinit var workManager: WorkManager
-    @MockK private lateinit var calculationTracker: CalculationTracker
+    @MockK private lateinit var exposureDetectionTracker: ExposureDetectionTracker
     private val scope = TestCoroutineScope()
 
     class TestApp : Application(), HasAndroidInjector {
@@ -57,7 +57,7 @@ class ExposureStateUpdateReceiverTest : BaseTest() {
         every { context.applicationContext } returns application
         val broadcastReceiverInjector = AndroidInjector<Any> {
             it as ExposureStateUpdateReceiver
-            it.calculationTracker = calculationTracker
+            it.exposureDetectionTracker = exposureDetectionTracker
             it.dispatcherProvider = TestDispatcherProvider
             it.scope = scope
         }
@@ -79,7 +79,7 @@ class ExposureStateUpdateReceiverTest : BaseTest() {
 
         verifySequence {
             workManager.enqueue(any<WorkRequest>())
-            calculationTracker.finishCalculation("token", Calculation.Result.UPDATED_STATE)
+            exposureDetectionTracker.finishExposureDetection("token", TrackedExposureDetection.Result.UPDATED_STATE)
         }
     }
 
@@ -89,7 +89,7 @@ class ExposureStateUpdateReceiverTest : BaseTest() {
         ExposureStateUpdateReceiver().onReceive(context, intent)
 
         verifySequence {
-            calculationTracker.finishCalculation("token", Calculation.Result.NO_MATCHES)
+            exposureDetectionTracker.finishExposureDetection("token", TrackedExposureDetection.Result.NO_MATCHES)
         }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/TestSettingsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/TestSettingsTest.kt
index 6468dda956d4974478fb320e5ec5587ccf77e880..f904964bfddde385950bac6c95efad7247fda59b 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/TestSettingsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/TestSettingsTest.kt
@@ -2,7 +2,6 @@ package de.rki.coronawarnapp.storage
 
 import android.content.Context
 import de.rki.coronawarnapp.util.CWADebug
-import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
 import io.mockk.clearAllMocks
 import io.mockk.every
@@ -10,7 +9,6 @@ import io.mockk.impl.annotations.MockK
 import io.mockk.mockkObject
 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
 
@@ -39,31 +37,4 @@ class TestSettingsTest : BaseTest() {
     private fun buildInstance(): TestSettings = TestSettings(
         context = context
     )
-
-    @Test
-    fun `hourly keypkg testing mode`() {
-        buildInstance().apply {
-            every { CWADebug.isDeviceForTestersBuild } returns true
-
-            isHourKeyPkgMode shouldBe false
-            isHourKeyPkgMode = true
-            isHourKeyPkgMode shouldBe true
-            mockPreferences.dataMapPeek.values.single() shouldBe true
-
-            isHourKeyPkgMode = false
-            isHourKeyPkgMode shouldBe false
-            mockPreferences.dataMapPeek.values.single() shouldBe false
-
-            isHourKeyPkgMode = true
-        }
-
-        buildInstance().apply {
-            isHourKeyPkgMode shouldBe true
-
-            // In normal builds this should default to false
-            every { CWADebug.isDeviceForTestersBuild } returns false
-
-            isHourKeyPkgMode shouldBe false
-        }
-    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
index 1b8b83751b885da0467e9b6ffc869eb08a8d4eef..581eab4dc2df49394919ab0679391f98f230333f 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
@@ -16,11 +16,11 @@ import io.kotest.matchers.shouldNotBe
 import io.kotest.matchers.types.instanceOf
 import io.mockk.MockKAnnotations
 import io.mockk.clearAllMocks
+import io.mockk.coVerifySequence
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
 import io.mockk.mockk
 import io.mockk.spyk
-import io.mockk.verifySequence
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.TimeoutCancellationException
 import kotlinx.coroutines.delay
@@ -173,8 +173,8 @@ class TaskControllerTest : BaseIOTest() {
             }
         }
 
-        verifySequence {
-            queueingFactory.config
+        coVerifySequence {
+            queueingFactory.createConfig()
             queueingFactory.taskProvider
         }
 
@@ -379,10 +379,10 @@ class TaskControllerTest : BaseIOTest() {
 
         arguments.path.length() shouldBe 720L
 
-        verifySequence {
-            queueingFactory.config
+        coVerifySequence {
+            queueingFactory.createConfig()
             queueingFactory.taskProvider
-            skippingFactory.config
+            skippingFactory.createConfig()
             skippingFactory.taskProvider
         }
 
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt
index f04714e342280f267dfd7a55d7f34365b6b43c90..cb0848c9fbbb770cbb8baa2f7c3ee8b4b073b65e 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt
@@ -21,8 +21,8 @@ class SkippingTask : QueueingTask() {
         private val taskByDagger: Provider<QueueingTask>
     ) : TaskFactory<DefaultProgress, Result> {
 
-        override val config: TaskFactory.Config =
-            Config()
+        override suspend fun createConfig(): Config = Config()
+
         override val taskProvider: () -> Task<DefaultProgress, Result> = {
             taskByDagger.get()
         }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt
index d5e513abb6def320fef21f7ebd13c607b82bb1e8..bafa28a2cb134a3c02fe723dd90eb2b9139b1e49 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt
@@ -42,7 +42,7 @@ abstract class BaseTimeoutTask : Task<DefaultProgress, TimeoutTaskResult> {
         private val taskByDagger: Provider<BaseTimeoutTask>
     ) : TaskFactory<DefaultProgress, TimeoutTaskResult> {
 
-        override val config: TaskFactory.Config = TimeoutTaskConfig()
+        override suspend fun createConfig(): TaskFactory.Config = TimeoutTaskConfig()
         override val taskProvider: () -> Task<DefaultProgress, TimeoutTaskResult> =
             { taskByDagger.get() }
     }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/TimeoutTaskArguments.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/TimeoutTaskArguments.kt
index 2195009183fe5794f72afdce27098104b13c8512..1eec8030b8ef5cf18d196ee5f610106054d0d8bd 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/TimeoutTaskArguments.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/TimeoutTaskArguments.kt
@@ -2,6 +2,5 @@ package de.rki.coronawarnapp.task.testtasks.timeout
 
 import de.rki.coronawarnapp.task.Task
 
-@Suppress("MagicNumber")
 data class TimeoutTaskArguments(val delay: Long = 15 * 1000L) :
     Task.Arguments
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt
index fe2bd9043d442472d3a2b8739ffa07a583545f85..ee57585af900af56835c3bf36089e72e85ece59b 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt
@@ -28,15 +28,19 @@ class SubmissionTanViewModelTest : BaseTest() {
 
         viewModel.onTanChanged("ZWFPC7NG47")
         viewModel.state.value!!.isTanValid shouldBe true
+        viewModel.state.value!!.isCorrectLength shouldBe true
 
         viewModel.onTanChanged("ABC")
         viewModel.state.value!!.isTanValid shouldBe false
+        viewModel.state.value!!.isCorrectLength shouldBe false
 
         viewModel.onTanChanged("ZWFPC7NG48")
         viewModel.state.value!!.isTanValid shouldBe false
+        viewModel.state.value!!.isCorrectLength shouldBe true
 
         viewModel.onTanChanged("ZWFPC7NG4A")
         viewModel.state.value!!.isTanValid shouldBe false
+        viewModel.state.value!!.isCorrectLength shouldBe true
     }
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt
index 4a011b972587c5bc14a74e2b0bf002a1e70259ce..a2a9be3c29f49e74b068374d3c3e9d8612186b02 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt
@@ -46,6 +46,26 @@ class TanTest : BaseTest() {
         }
     }
 
+    @Test
+    fun isTanValid() {
+        val validTans = arrayOf(
+            "9A3B578UMG", "DEU7TKSV3H", "PTPHM35RP4", "V923D59AT8", "H9NC5CQ34E"
+        )
+        for (tan in validTans) {
+            Tan.allCharactersValid(tan) shouldBe true
+            Tan.isChecksumValid(tan) shouldBe true
+            (tan.length == Tan.MAX_LENGTH) shouldBe true
+        }
+
+        // invalid tans due to length and/or invalid characters
+        val invalidTans = arrayOf(
+            "ABÖAA1", "-1234", "PTPHM15RP4", "aAASd A"
+        )
+        for (tan in invalidTans) {
+            Tan.allCharactersValid(tan) shouldBe false
+        }
+    }
+
     @Test
     fun isChecksumValid() {
         // valid
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 a6d95594651ced9e26de4315ad9ee6cdb87efe6c..ba00e34847588437cdfc964648d3a06e011fe2e7 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
@@ -146,6 +146,49 @@ class TracingCardStateTest : BaseTest() {
         }
     }
 
+    @Test
+    fun `risklevel affected by tracing status`() {
+        createInstance(
+            riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS,
+            tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE
+        ).apply {
+            getRiskBody(context)
+            verify { context.getString(R.string.risk_card_body_tracing_off) }
+        }
+
+        createInstance(
+            riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF,
+            tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE
+        ).apply {
+            getRiskBody(context)
+            verify { context.getString(R.string.risk_card_body_tracing_off) }
+        }
+
+        createInstance(
+            riskLevel = UNKNOWN_RISK_INITIAL,
+            tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE
+        ).apply {
+            getRiskBody(context)
+            verify { context.getString(R.string.risk_card_body_tracing_off) }
+        }
+
+        createInstance(
+            riskLevel = LOW_LEVEL_RISK,
+            tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE
+        ).apply {
+            getRiskBody(context)
+            verify { context.getString(R.string.risk_card_body_tracing_off) }
+        }
+
+        createInstance(
+            riskLevel = INCREASED_RISK,
+            tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE
+        ).apply {
+            getRiskBody(context)
+            verify { context.getString(R.string.risk_card_body_tracing_off) }
+        }
+    }
+
     @Test
     fun `saved risk body is affected by risklevel`() {
         createInstance(
@@ -636,82 +679,6 @@ class TracingCardStateTest : BaseTest() {
         }
     }
 
-    @Test
-    fun `text for next update time`() {
-        createInstance(
-            riskLevel = INCREASED_RISK,
-            isBackgroundJobEnabled = false
-        ).apply {
-            getNextUpdate(context) shouldBe ""
-        }
-
-        createInstance(
-            riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS,
-            isBackgroundJobEnabled = false
-        ).apply {
-            getNextUpdate(context) shouldBe ""
-        }
-
-        createInstance(
-            riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            isBackgroundJobEnabled = false
-        ).apply {
-            getNextUpdate(context) shouldBe ""
-        }
-
-        createInstance(
-            riskLevel = LOW_LEVEL_RISK,
-            isBackgroundJobEnabled = false
-        ).apply {
-            getNextUpdate(context) shouldBe ""
-        }
-
-        createInstance(
-            riskLevel = UNKNOWN_RISK_INITIAL,
-            isBackgroundJobEnabled = false
-        ).apply {
-            getNextUpdate(context) shouldBe ""
-        }
-
-        createInstance(
-            riskLevel = INCREASED_RISK,
-            isBackgroundJobEnabled = true
-        ).apply {
-            getNextUpdate(context)
-            verify { context.getString(R.string.risk_card_body_next_update) }
-        }
-
-        createInstance(
-            riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS,
-            isBackgroundJobEnabled = true
-        ).apply {
-            getNextUpdate(context) shouldBe ""
-        }
-
-        createInstance(
-            riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            isBackgroundJobEnabled = true
-        ).apply {
-            getNextUpdate(context) shouldBe ""
-        }
-
-        createInstance(
-            riskLevel = LOW_LEVEL_RISK,
-            isBackgroundJobEnabled = true
-        ).apply {
-            getNextUpdate(context)
-            verify { context.getString(R.string.risk_card_body_next_update) }
-        }
-
-        createInstance(
-            riskLevel = UNKNOWN_RISK_INITIAL,
-            isBackgroundJobEnabled = true
-        ).apply {
-            getNextUpdate(context)
-            verify { context.getString(R.string.risk_card_body_next_update) }
-        }
-    }
-
     @Test
     fun `task divider is formatted according to riskLevel`() {
         createInstance(riskLevel = INCREASED_RISK).apply {
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/network/NetworkStateProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/network/NetworkStateProviderTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2a9e92c6cc8e8a0aa21be016d28a400ea1165798
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/network/NetworkStateProviderTest.kt
@@ -0,0 +1,215 @@
+package de.rki.coronawarnapp.util.network
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.LinkProperties
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import de.rki.coronawarnapp.storage.TestSettings
+import io.kotest.assertions.throwables.shouldNotThrowAny
+import io.kotest.matchers.shouldBe
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifySequence
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.TestCoroutineScope
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.coroutines.runBlockingTest2
+import testhelpers.coroutines.test
+import testhelpers.preferences.mockFlowPreference
+
+class NetworkStateProviderTest : BaseTest() {
+
+    @MockK lateinit var context: Context
+    @MockK lateinit var conMan: ConnectivityManager
+    @MockK lateinit var testSettings: TestSettings
+
+    @MockK lateinit var network: Network
+    @MockK lateinit var networkRequest: NetworkRequest
+    @MockK lateinit var networkRequestBuilder: NetworkRequest.Builder
+    @MockK lateinit var networkRequestBuilderProvider: NetworkRequestBuilderProvider
+    @MockK lateinit var capabilities: NetworkCapabilities
+    @MockK lateinit var linkProperties: LinkProperties
+
+    private var lastRequest: NetworkRequest? = null
+    private var lastCallback: ConnectivityManager.NetworkCallback? = null
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every {
+            conMan.registerNetworkCallback(
+                any<NetworkRequest>(),
+                any<ConnectivityManager.NetworkCallback>()
+            )
+        } answers {
+            lastRequest = arg(0)
+            lastCallback = arg(1)
+            mockk()
+        }
+        every { conMan.unregisterNetworkCallback(any<ConnectivityManager.NetworkCallback>()) } just Runs
+
+        every { context.getSystemService(Context.CONNECTIVITY_SERVICE) } returns conMan
+
+        every { networkRequestBuilderProvider.get() } returns networkRequestBuilder
+        every { networkRequestBuilder.addCapability(any()) } returns networkRequestBuilder
+        every { networkRequestBuilder.build() } returns networkRequest
+
+        every { conMan.activeNetwork } returns network
+        every { conMan.getNetworkCapabilities(network) } returns capabilities
+        every { conMan.getLinkProperties(network) } returns linkProperties
+
+        every { testSettings.fakeMeteredConnection } returns mockFlowPreference(false)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance(scope: CoroutineScope) = NetworkStateProvider(
+        context = context,
+        appScope = scope,
+        networkRequestBuilderProvider = networkRequestBuilderProvider,
+        testSettings = testSettings
+    )
+
+    @Test
+    fun `init is sideeffect free and lazy`() {
+        shouldNotThrowAny {
+            createInstance(TestCoroutineScope())
+        }
+        verify { conMan wasNot Called }
+    }
+
+    @Test
+    fun `initial state is emitted correctly without callback`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance(this)
+
+        instance.networkState.first() shouldBe NetworkStateProvider.State(
+            activeNetwork = network,
+            capabilities = capabilities,
+            linkProperties = linkProperties
+        )
+
+        advanceUntilIdle()
+
+        verifySequence {
+            conMan.activeNetwork
+            conMan.getNetworkCapabilities(network)
+            conMan.getLinkProperties(network)
+            conMan.registerNetworkCallback(networkRequest, any<ConnectivityManager.NetworkCallback>())
+            conMan.unregisterNetworkCallback(lastCallback!!)
+        }
+    }
+
+    @Test
+    fun `we can handle null networks`() = runBlockingTest2(ignoreActive = true) {
+        every { conMan.activeNetwork } returns null
+        val instance = createInstance(this)
+
+        instance.networkState.first() shouldBe NetworkStateProvider.State(
+            activeNetwork = null,
+            capabilities = null,
+            linkProperties = null
+        )
+        verify { conMan.activeNetwork }
+    }
+
+    @Test
+    fun `system callbacks lead to new emissions with an updated state`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance(this)
+
+        val testCollector = instance.networkState.test(startOnScope = this)
+
+        lastCallback!!.onAvailable(mockk())
+
+        every { conMan.activeNetwork } returns null
+        lastCallback!!.onUnavailable()
+
+        every { conMan.activeNetwork } returns network
+        lastCallback!!.onAvailable(mockk())
+
+        advanceUntilIdle()
+
+        // 3 not 4 as first onAvailable call doesn't change the value (stateIn behavior)
+        testCollector.latestValues.size shouldBe 3
+
+        testCollector.awaitFinal(cancel = true)
+
+        verifySequence {
+            // Start value
+            conMan.activeNetwork
+            conMan.getNetworkCapabilities(network)
+            conMan.getLinkProperties(network)
+            conMan.registerNetworkCallback(networkRequest, any<ConnectivityManager.NetworkCallback>())
+
+            // onAvailable
+            conMan.activeNetwork
+            conMan.getNetworkCapabilities(network)
+            conMan.getLinkProperties(network)
+
+            // onUnavailable
+            conMan.activeNetwork
+
+            // onAvailable
+            conMan.activeNetwork
+            conMan.getNetworkCapabilities(network)
+            conMan.getLinkProperties(network)
+
+            conMan.unregisterNetworkCallback(lastCallback!!)
+        }
+    }
+
+    @Test
+    fun `metered connection state checks capabilities`() {
+        val capabilities = mockk<NetworkCapabilities>()
+
+        every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } returns true
+        NetworkStateProvider.State(
+            activeNetwork = null,
+            capabilities = capabilities,
+            linkProperties = null
+        ).isMeteredConnection shouldBe false
+
+        NetworkStateProvider.State(
+            activeNetwork = null,
+            capabilities = null,
+            linkProperties = null
+        ).isMeteredConnection shouldBe true
+
+        every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } returns false
+        NetworkStateProvider.State(
+            activeNetwork = null,
+            capabilities = capabilities,
+            linkProperties = null
+        ).isMeteredConnection shouldBe true
+    }
+
+    @Test
+    fun `metered connection state can be overriden via test settings`() = runBlockingTest2(ignoreActive = true) {
+        every { testSettings.fakeMeteredConnection } returns mockFlowPreference(true)
+        val instance = createInstance(this)
+
+        instance.networkState.first()
+
+        NetworkStateProvider.State(
+            activeNetwork = null,
+            capabilities = null,
+            linkProperties = null
+        ).isMeteredConnection shouldBe true
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/preferences/FlowPreferenceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/preferences/FlowPreferenceTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..58c53a7fa055cd1a6ce6f4318752046393a8255e
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/preferences/FlowPreferenceTest.kt
@@ -0,0 +1,209 @@
+package de.rki.coronawarnapp.util.preferences
+
+import com.google.gson.Gson
+import io.kotest.matchers.shouldBe
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.extensions.toComparableJson
+import testhelpers.preferences.MockSharedPreferences
+
+class FlowPreferenceTest : BaseTest() {
+
+    private val mockPreferences = MockSharedPreferences()
+
+    @Test
+    fun `reading and writing strings`() = runBlockingTest {
+        mockPreferences.createFlowPreference<String?>(
+            key = "testKey",
+            defaultValue = "default"
+        ).apply {
+            value shouldBe "default"
+            flow.first() shouldBe "default"
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+
+            update {
+                it shouldBe "default"
+                "newvalue"
+            }
+
+            value shouldBe "newvalue"
+            flow.first() shouldBe "newvalue"
+            mockPreferences.dataMapPeek.values.first() shouldBe "newvalue"
+
+            update {
+                it shouldBe "newvalue"
+                null
+            }
+            value shouldBe "default"
+            flow.first() shouldBe "default"
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+        }
+    }
+
+    @Test
+    fun `reading and writing boolean`() = runBlockingTest {
+        mockPreferences.createFlowPreference<Boolean?>(
+            key = "testKey",
+            defaultValue = true
+        ).apply {
+            value shouldBe true
+            flow.first() shouldBe true
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+
+            update {
+                it shouldBe true
+                false
+            }
+
+            value shouldBe false
+            flow.first() shouldBe false
+            mockPreferences.dataMapPeek.values.first() shouldBe false
+
+            update {
+                it shouldBe false
+                null
+            }
+            value shouldBe true
+            flow.first() shouldBe true
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+        }
+    }
+
+    @Test
+    fun `reading and writing long`() = runBlockingTest {
+        mockPreferences.createFlowPreference<Long?>(
+            key = "testKey",
+            defaultValue = 9000L
+        ).apply {
+            value shouldBe 9000L
+            flow.first() shouldBe 9000L
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+
+            update {
+                it shouldBe 9000L
+                9001L
+            }
+
+            value shouldBe 9001L
+            flow.first() shouldBe 9001L
+            mockPreferences.dataMapPeek.values.first() shouldBe 9001L
+
+            update {
+                it shouldBe 9001L
+                null
+            }
+            value shouldBe 9000L
+            flow.first() shouldBe 9000L
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+        }
+    }
+
+    @Test
+    fun `reading and writing integer`() = runBlockingTest {
+        mockPreferences.createFlowPreference<Long?>(
+            key = "testKey",
+            defaultValue = 123
+        ).apply {
+            value shouldBe 123
+            flow.first() shouldBe 123
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+
+            update {
+                it shouldBe 123
+                44
+            }
+
+            value shouldBe 44
+            flow.first() shouldBe 44
+            mockPreferences.dataMapPeek.values.first() shouldBe 44
+
+            update {
+                it shouldBe 44
+                null
+            }
+            value shouldBe 123
+            flow.first() shouldBe 123
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+        }
+    }
+
+    @Test
+    fun `reading and writing float`() = runBlockingTest {
+        mockPreferences.createFlowPreference<Float?>(
+            key = "testKey",
+            defaultValue = 3.6f
+        ).apply {
+            value shouldBe 3.6f
+            flow.first() shouldBe 3.6f
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+
+            update {
+                it shouldBe 3.6f
+                15000f
+            }
+
+            value shouldBe 15000f
+            flow.first() shouldBe 15000f
+            mockPreferences.dataMapPeek.values.first() shouldBe 15000f
+
+            update {
+                it shouldBe 15000f
+                null
+            }
+            value shouldBe 3.6f
+            flow.first() shouldBe 3.6f
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+        }
+    }
+
+    data class TestGson(
+        val string: String = "",
+        val boolean: Boolean = true,
+        val float: Float = 1.0f,
+        val int: Int = 1,
+        val long: Long = 1L
+    )
+
+    @Test
+    fun `reading and writing GSON`() = runBlockingTest {
+        val testData1 = TestGson(string = "teststring")
+        val testData2 = TestGson(string = "update")
+        FlowPreference<TestGson?>(
+            preferences = mockPreferences,
+            key = "testKey",
+            reader = FlowPreference.gsonReader(Gson(), testData1),
+            writer = FlowPreference.gsonWriter(Gson())
+        ).apply {
+            value shouldBe testData1
+            flow.first() shouldBe testData1
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+
+            update {
+                it shouldBe testData1
+                it!!.copy(string = "update")
+            }
+
+            value shouldBe testData2
+            flow.first() shouldBe testData2
+            (mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """
+                {
+                    "string":"update",
+                    "boolean":true,
+                    "float":1.0,
+                    "int":1,
+                    "long":1
+                }
+            """.toComparableJson()
+
+            update {
+                it shouldBe testData2
+                null
+            }
+            value shouldBe testData1
+            flow.first() shouldBe testData1
+            mockPreferences.dataMapPeek.values.isEmpty() shouldBe true
+        }
+    }
+}
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 37b956e809b2c776896f4f06f37b3f45952c08c4..468877bbaaa72b0dfc9f3177073a15d4b6e7024c 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
@@ -4,6 +4,8 @@ import androidx.work.ListenableWorker
 import dagger.Component
 import dagger.Module
 import dagger.Provides
+import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler
+import de.rki.coronawarnapp.deadman.DeadmanNotificationSender
 import de.rki.coronawarnapp.playbook.Playbook
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.util.di.AssistedInjectModule
@@ -75,6 +77,14 @@ class MockProvider {
     @Provides
     fun playbook(): Playbook = mockk()
 
+    // For DeadmanNotificationScheduler
+    @Provides
+    fun sender(): DeadmanNotificationSender = mockk()
+
+    // For DeadmanNotificationPeriodicWorker
+    @Provides
+    fun scheduler(): DeadmanNotificationScheduler = mockk()
+
     @Provides
     fun taskController(): TaskController = mockk()
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundConstantsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundConstantsTest.kt
index 161df1e1a35400cdd21eea306492d1dd04fa0e6d..a2247300dd4871e0a949b7bfaba310237c2862b3 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundConstantsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundConstantsTest.kt
@@ -8,8 +8,6 @@ class BackgroundConstantsTest {
     @Test
     fun allBackgroundConstants() {
         Assert.assertEquals(BackgroundConstants.MINUTES_IN_DAY, 1440)
-        Assert.assertEquals(BackgroundConstants.DIAGNOSIS_KEY_RETRIEVAL_TRIES_PER_DAY, 12)
-        Assert.assertEquals(BackgroundConstants.GOOGLE_API_MAX_CALLS_PER_DAY, 20)
         Assert.assertEquals(BackgroundConstants.DIAGNOSIS_TEST_RESULT_RETRIEVAL_TRIES_PER_DAY, 12)
         Assert.assertEquals(BackgroundConstants.KIND_DELAY, 1L)
         Assert.assertEquals(BackgroundConstants.DIAGNOSIS_TEST_RESULT_PERIODIC_INITIAL_DELAY, 10L)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f77fd6a7e8ebd20758a1949af5652484ac69ac48
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.worker
+
+import io.kotest.matchers.shouldBe
+import org.joda.time.Duration
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class BackgroundWorkBuilderTest : BaseTest() {
+
+    @Test
+    fun `worker interval for key retrieval is 60 minutes, once every hour`() {
+        buildDiagnosisKeyRetrievalPeriodicWork().apply {
+            workSpec.intervalDuration shouldBe Duration.standardMinutes(60).millis
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkHelperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkHelperTest.kt
index eddc5f4c1ccdb514af1cd942950315ecdefd880f..0ab0f5e23f942087b5356443b7d56cec27075be3 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkHelperTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkHelperTest.kt
@@ -5,13 +5,6 @@ import org.junit.Assert
 import org.junit.Test
 
 class BackgroundWorkHelperTest {
-    @Test
-    fun getDiagnosisKeyRetrievalPeriodicWorkTimeInterval() {
-        Assert.assertEquals(
-            BackgroundWorkHelper.getDiagnosisKeyRetrievalPeriodicWorkTimeInterval(),
-            120
-        )
-    }
 
     @Test
     fun getDiagnosisTestResultRetrievalPeriodicWorkTimeInterval() {
@@ -21,11 +14,6 @@ class BackgroundWorkHelperTest {
         )
     }
 
-    @Test
-    fun getDiagnosisKeyRetrievalMaximumCalls() {
-        Assert.assertEquals(BackgroundWorkHelper.getDiagnosisKeyRetrievalMaximumCalls(), 12)
-    }
-
     @Test
     fun getConstraintsForDiagnosisKeyOneTimeBackgroundWork() {
         val constraints = BackgroundWorkHelper.getConstraintsForDiagnosisKeyOneTimeBackgroundWork()
diff --git a/Corona-Warn-App/src/test/java/testhelpers/EmptyApplication.kt b/Corona-Warn-App/src/test/java/testhelpers/EmptyApplication.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5b9c7a4b7aaf8c9e2bc5a5538ebf016684bd44f9
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/testhelpers/EmptyApplication.kt
@@ -0,0 +1,5 @@
+package testhelpers
+
+import android.app.Application
+
+class EmptyApplication : Application()
diff --git a/Corona-Warn-App/src/test/java/testhelpers/IsAUnitTest.kt b/Corona-Warn-App/src/test/java/testhelpers/IsAUnitTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2af3e4cb78347fa7849576e9c3f36eac53fe3968
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/testhelpers/IsAUnitTest.kt
@@ -0,0 +1,3 @@
+package testhelpers
+
+class IsAUnitTest
diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt
index 975a8c48dd522f417915c601cca231fea22e0bf2..3fc56ce9a503dcd65c2afb6f91e657557776c2d8 100644
--- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt
+++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt
@@ -80,7 +80,8 @@ class TestCollector<T>(
         }
     }
 
-    suspend fun awaitFinal() = apply {
+    suspend fun awaitFinal(cancel: Boolean = false) = apply {
+        if (cancel) cancel()
         try {
             job.join()
         } catch (e: Exception) {
diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
index b5a62565aa2ee00e0fb214a9d1c6d53453d7199a..375adf36e462e42753b985160ba5062cbcdc3397 100644
--- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
+++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
@@ -5,6 +5,7 @@ import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.test.TestCoroutineScope
 import kotlinx.coroutines.test.UncompletedCoroutinesError
 import kotlinx.coroutines.test.runBlockingTest
+import timber.log.Timber
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
 
@@ -32,6 +33,7 @@ fun runBlockingTest2(
                 )
             } catch (e: UncompletedCoroutinesError) {
                 if (!ignoreActive) throw e
+                else Timber.v("Ignoring active job.")
             }
         }
     } catch (e: Exception) {
diff --git a/Corona-Warn-App/src/test/java/testhelpers/preferences/MockFlowPreference.kt b/Corona-Warn-App/src/test/java/testhelpers/preferences/MockFlowPreference.kt
new file mode 100644
index 0000000000000000000000000000000000000000..33d46578e1a956c645d549edc3f793d2fc9b27df
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/testhelpers/preferences/MockFlowPreference.kt
@@ -0,0 +1,20 @@
+package testhelpers.preferences
+
+import de.rki.coronawarnapp.util.preferences.FlowPreference
+import io.mockk.every
+import io.mockk.mockk
+import kotlinx.coroutines.flow.MutableStateFlow
+
+fun <T> mockFlowPreference(
+    defaultValue: T
+): FlowPreference<T> {
+    val instance = mockk<FlowPreference<T>>()
+    val flow = MutableStateFlow(defaultValue)
+    every { instance.flow } answers { flow }
+    every { instance.value } answers { flow.value }
+    every { instance.update(any()) } answers {
+        val updateCall = arg<(T) -> T>(0)
+        flow.value = updateCall(flow.value)
+    }
+    return instance
+}
diff --git a/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto b/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto
index 00c58f526b33cb2a6659cfe4939178d32142cf61..6a4518b71769b9ebc6db1610fada5d2cda8a64d7 100644
--- a/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto
+++ b/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto
@@ -5,14 +5,14 @@ package de.rki.coronawarnapp.server.protocols.internal;
 
 message KeyDownloadParametersIOS {
 
-  repeated DayPackageMetadata cachedDayPackagesToUpdateOnETagMismatch = 1;
-  repeated HourPackageMetadata cachedHourPackagesToUpdateOnETagMismatch = 2;
+  repeated DayPackageMetadata revokedDayPackages = 1;
+  repeated HourPackageMetadata revokedHourPackages = 2;
 }
 
 message KeyDownloadParametersAndroid {
 
-  repeated DayPackageMetadata cachedDayPackagesToUpdateOnETagMismatch = 1;
-  repeated HourPackageMetadata cachedHourPackagesToUpdateOnETagMismatch = 2;
+  repeated DayPackageMetadata revokedDayPackages = 1;
+  repeated HourPackageMetadata revokedHourPackages = 2;
 
   int32 downloadTimeoutInSeconds = 3;