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/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 03cbf73b44f75b266d237d212ee1587f4a463f1d..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
@@ -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/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/tasks/testtask/TestTask.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt
index 426c2c718613b640c8a2dedadbe59f72ff8b5cbc..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
@@ -64,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/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/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/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..e1a0198094aa81bb8c8eb1327dd3bba4153060d6 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 invalidDayETags: Collection<InvalidatedKeyFile.Day>
+
+    val invalidHourEtags: Collection<InvalidatedKeyFile.Hour>
+
+    interface InvalidatedKeyFile {
+        val etag: String
+        val region: LocationCode
+
+        interface Day : InvalidatedKeyFile {
+            val day: LocalDate
+        }
+
+        interface Hour : Day, InvalidatedKeyFile {
+            val hour: LocalTime
+        }
+    }
 
     interface Mapper : ConfigMapper<KeyDownloadConfig>
 }
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..e925a60521a9186549db17320029449639dc16da
--- /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(),
+            invalidDayETags = rawParameters.mapDayEtags(),
+            invalidHourEtags = 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<InvalidatedKeyFile.Day> =
+        this.cachedDayPackagesToUpdateOnETagMismatchList.mapNotNull {
+            try {
+                InvalidatedKeyFile.Day(
+                    etag = it.etag,
+                    region = LocationCode(it.region),
+                    day = LocalDate.parse(it.date, DAY_FORMATTER)
+                )
+            } catch (e: Exception) {
+                Timber.e(e, "Failed to parse invalidated day metadata: %s", it)
+                null
+            }
+        }
+
+    private fun KeyDownloadParametersAndroid.mapHourEtags(): List<InvalidatedKeyFile.Hour> =
+        this.cachedHourPackagesToUpdateOnETagMismatchList.mapNotNull {
+            try {
+                InvalidatedKeyFile.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 invalidated hour metadata: %s", it)
+                null
+            }
+        }
+
+    data class KeyDownloadConfigContainer(
+        override val individualDownloadTimeout: Duration,
+        override val overallDownloadTimeout: Duration,
+        override val invalidDayETags: Collection<KeyDownloadConfig.InvalidatedKeyFile.Day>,
+        override val invalidHourEtags: Collection<KeyDownloadConfig.InvalidatedKeyFile.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 InvalidatedKeyFile : KeyDownloadConfig.InvalidatedKeyFile {
+
+    data class Day(
+        override val etag: String,
+        override val region: LocationCode,
+        override val day: LocalDate
+    ) : InvalidatedKeyFile(), KeyDownloadConfig.InvalidatedKeyFile.Day
+
+    data class Hour(
+        override val etag: String,
+        override val region: LocationCode,
+        override val day: LocalDate,
+        override val hour: LocalTime
+    ) : InvalidatedKeyFile(), KeyDownloadConfig.InvalidatedKeyFile.Hour
+}
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
index 26018e3ddac8285143d3bdd26a795a2cd9a62d84..48e6d3a8eba598fb4121e761684c7595230f544b 100644
--- 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
@@ -28,7 +28,7 @@ class DeadmanNotificationTimeCalculation @Inject constructor(
      * If last success date time is null (eg: on application first start) - return [DEADMAN_NOTIFICATION_DELAY]
      */
     suspend fun getDelay(): Long {
-        val lastSuccess = enfClient.latestFinishedCalculation().first()?.finishedAt
+        val lastSuccess = enfClient.lastSuccessfulTrackedExposureDetection().first()?.finishedAt
         return if (lastSuccess != null) {
             getHoursDiff(lastSuccess).toLong()
         } else {
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..66a2ada543449fb939d2fbaf8ca1c906672d0dd2
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt
@@ -0,0 +1,99 @@
+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
+) {
+
+    internal suspend fun invalidateCachedKeys(invalidatedKeyFiles: Collection<KeyDownloadConfig.InvalidatedKeyFile>) {
+        if (invalidatedKeyFiles.isEmpty()) {
+            Timber.tag(tag).d("No invalid files to delete.")
+            return
+        }
+
+        val badEtags = invalidatedKeyFiles.map { it.etag }
+        val toDelete = keyCache.getAllCachedKeys()
+            .filter { badEtags.contains(it.info.etag) }
+
+        Timber.tag(tag).w("Deleting invalidated cached keys: %s", toDelete.joinToString("\n"))
+        keyCache.delete(toDelete.map { it.info })
+    }
+
+    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..4668c5660d794db9538d6795476631837b038afe
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt
@@ -0,0 +1,138 @@
+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()
+        invalidateCachedKeys(downloadConfig.invalidDayETags)
+
+        val missingDays = targetLocations.mapNotNull {
+            determineMissingDayPackages(it, forceIndexLookup)
+        }
+        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)) 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 3d1aa4fa3bf9b2c621a59e64dad15e3b26e9f56f..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()
     }
 
@@ -210,16 +256,19 @@ class DownloadDiagnosisKeysTask @Inject constructor(
     data class Config(
         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..d1ab74d34a99564734662403b7585c7cf950c9f9
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt
@@ -0,0 +1,166 @@
+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()
+        invalidateCachedKeys(downloadConfig.invalidHourEtags)
+
+        val missingHours = targetLocations.mapNotNull {
+            determineMissingHours(it, forceIndexLookup)
+        }
+        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)) 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/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/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 68%
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..d75e3d2c44f330665aa91f2b265c68c33d99a3b7 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
@@ -21,35 +22,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.getAppConfig().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 +78,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 +92,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 +109,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 +134,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/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/risk/RiskLevelTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt
index edc963957c7352ed5f654153fb8765926b14767c..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.")
@@ -161,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/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/SubmissionTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt
index b414b923a29daa97fbad90dacd28455a23318ed7..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
@@ -96,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/task/TaskController.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt
index 46b3964b9c81961b732ac03c8c5bdb4758b52211..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
@@ -89,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) {
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 f7af281fa7fb0c4627ccc061c68c3267692b8ee5..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
@@ -71,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/ui/main/home/HomeFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
index 7983ab3cd2e71d5d0d77aa6f4b1faf09fdf1dc4e..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
@@ -12,6 +12,7 @@ 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
@@ -37,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)
@@ -93,7 +95,9 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject {
         }
 
         vm.showLoweredRiskLevelDialog.observe2(this) {
-            if (it) { showRiskLevelLoweredDialog() }
+            if (it) {
+                showRiskLevelLoweredDialog()
+            }
         }
 
         lifecycleScope.launch { vm.observeTestResultToSchedulePositiveTestResultReminder() }
@@ -102,7 +106,6 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject {
     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/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/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 557f983414139619f1c844b79919fec6a35ec9d0..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,
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/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..d28fe47c678d6d8ca3bdf07da1e5cbb833575693 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 hours`() {
+        val builder = KeyDownloadParameters.KeyDownloadParametersAndroid.newBuilder().apply {
+            KeyDownloadParameters.DayPackageMetadata.newBuilder().apply {
+                etag = "\"GoodMorningEtag\""
+                region = "EUR"
+                date = "2020-11-09"
+            }.let { addCachedDayPackagesToUpdateOnETagMismatch(it) }
+        }
+
         val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setAndroidKeyDownloadParameters(builder)
             .build()
+
+        createInstance().map(rawConfig).apply {
+            invalidDayETags.first().apply {
+                etag shouldBe "\"GoodMorningEtag\""
+                region shouldBe LocationCode("EUR")
+                day shouldBe LocalDate.parse("2020-11-09")
+            }
+        }
+    }
+
+    @Test
+    fun `parse etag missmatch for days`() {
+        val builder = KeyDownloadParameters.KeyDownloadParametersAndroid.newBuilder().apply {
+            KeyDownloadParameters.HourPackageMetadata.newBuilder().apply {
+                etag = "\"GoodMorningEtag\""
+                region = "EUR"
+                date = "2020-11-09"
+                hour = 8
+            }.let { addCachedHourPackagesToUpdateOnETagMismatch(it) }
+        }
+
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setAndroidKeyDownloadParameters(builder)
+            .build()
+
         createInstance().map(rawConfig).apply {
-            keyDownloadParameters shouldBe rawConfig.androidKeyDownloadParameters
+            invalidHourEtags.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/DeadmanNotificationTimeCalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt
index ae93b2979d83e914daf427ccd87e237eaba4f20c..a446d6e317bf97f5cd42e8d234447c8bfc651bb9 100644
--- 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
@@ -1,7 +1,7 @@
 package de.rki.coronawarnapp.deadman
 
 import de.rki.coronawarnapp.nearby.ENFClient
-import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
 import de.rki.coronawarnapp.util.TimeStamper
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
@@ -21,13 +21,13 @@ class DeadmanNotificationTimeCalculationTest : BaseTest() {
 
     @MockK lateinit var timeStamper: TimeStamper
     @MockK lateinit var enfClient: ENFClient
-    @MockK lateinit var mockCalculation: Calculation
+    @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.latestFinishedCalculation() } returns flowOf(mockCalculation)
+        every { enfClient.lastSuccessfulTrackedExposureDetection() } returns flowOf(mockExposureDetection)
     }
 
     @AfterEach
@@ -64,40 +64,40 @@ class DeadmanNotificationTimeCalculationTest : BaseTest() {
     @Test
     fun `12 hours delay`() = runBlockingTest {
         every { timeStamper.nowUTC } returns Instant.parse("2020-08-28T14:00:00.000Z")
-        every { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z")
+        every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z")
 
         createTimeCalculator().getDelay() shouldBe 720
 
-        verify(exactly = 1) { enfClient.latestFinishedCalculation() }
+        verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() }
     }
 
     @Test
     fun `negative delay`() = runBlockingTest {
         every { timeStamper.nowUTC } returns Instant.parse("2020-08-30T14:00:00.000Z")
-        every { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z")
+        every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z")
 
         createTimeCalculator().getDelay() shouldBe -2160
 
-        verify(exactly = 1) { enfClient.latestFinishedCalculation() }
+        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 { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T15:00:00.000Z")
+        every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T15:00:00.000Z")
 
         createTimeCalculator().getDelay() shouldBe 2220
 
-        verify(exactly = 1) { enfClient.latestFinishedCalculation() }
+        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.latestFinishedCalculation() } returns flowOf(null)
+        every { enfClient.lastSuccessfulTrackedExposureDetection() } returns flowOf(null)
 
         createTimeCalculator().getDelay() shouldBe 2160
 
-        verify(exactly = 1) { enfClient.latestFinishedCalculation() }
+        verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() }
     }
 }
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..ebb49928fd99ff7269190bfdce8444397b8ae522
--- /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.InvalidatedKeyFile>().apply {
+            every { etag } returns "etag-badday"
+        }
+        val invalidatedHour = mockk<KeyDownloadConfig.InvalidatedKeyFile>().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.invalidateCachedKeys(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..cd31387efbc4daa6aff5ecae3ffc0a319a2d778b
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt
@@ -0,0 +1,183 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.mapping.InvalidatedKeyFile
+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.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.invalidDayETags } 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 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.invalidDayETags } returns listOf(
+            InvalidatedKeyFile.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)
+            timeStamper.nowUTC
+            keyServer.getDayIndex("EUR".loc)
+
+            keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-03".day, null)
+            downloadTool.downloadKeyFile(any(), downloadConfig)
+        }
+    }
+}
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..dbaa90ab023efe49f60b2a6a9818ccd17f37a818
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt
@@ -0,0 +1,205 @@
+package de.rki.coronawarnapp.diagnosiskeys.download
+
+import de.rki.coronawarnapp.appconfig.mapping.InvalidatedKeyFile
+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.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.invalidHourEtags } 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.invalidHourEtags } returns listOf(
+            InvalidatedKeyFile.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 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, 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
+    }
+}
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/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..f40ea545e66e9cf770273c5da907f2ae26f7632d 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
@@ -27,10 +29,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 +43,9 @@ class DefaultCalculationTrackerTest : BaseTest() {
         every { timeStamper.nowUTC } returns Instant.EPOCH
         coEvery { storage.load() } returns emptyMap()
         coEvery { storage.save(any()) } just Runs
+
+        coEvery { configProvider.getAppConfig() } returns appConfigData
+        every { appConfigData.overallDetectionTimeout } returns Duration.standardMinutes(15)
     }
 
     @AfterEach
@@ -46,11 +53,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 +70,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 +84,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 +92,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
 
             calculationData.entries.single().apply {
                 key shouldBe expectedIdentifier
-                value shouldBe Calculation(
+                value shouldBe TrackedExposureDetection(
                     identifier = expectedIdentifier,
                     startedAt = Instant.EPOCH
                 )
@@ -98,11 +106,13 @@ class DefaultCalculationTrackerTest : BaseTest() {
             }
             advanceUntilIdle()
         }
+
+        coVerify { configProvider.getAppConfig() }
     }
 
     @Test
     fun `finish an existing calcluation`() = runBlockingTest2(ignoreActive = true) {
-        val calcData = Calculation(
+        val calcData = TrackedExposureDetection(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH
         )
@@ -112,14 +122,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 +147,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 +161,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 +177,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 +188,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 +205,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 +255,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/ui/submission/tan/TanTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt
index 927c107ed5ebf2d5494a8fc5a6cfd43ceea030cd..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
@@ -52,7 +52,7 @@ class TanTest : BaseTest() {
             "9A3B578UMG", "DEU7TKSV3H", "PTPHM35RP4", "V923D59AT8", "H9NC5CQ34E"
         )
         for (tan in validTans) {
-            Tan.allCharactersValid(tan)  shouldBe true
+            Tan.allCharactersValid(tan) shouldBe true
             Tan.isChecksumValid(tan) shouldBe true
             (tan.length == Tan.MAX_LENGTH) shouldBe true
         }
@@ -62,7 +62,7 @@ class TanTest : BaseTest() {
             "ABÖAA1", "-1234", "PTPHM15RP4", "aAASd A"
         )
         for (tan in invalidTans) {
-           Tan.allCharactersValid(tan) shouldBe false
+            Tan.allCharactersValid(tan) shouldBe false
         }
     }
 
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/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
+}