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 6b8bf5b59a478b948e95f22704681ab8e82a3487..fbdd56b5352c37f695c0ffa31727a7074bc68af1 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
@@ -13,11 +13,7 @@ import android.view.ViewGroup
 import android.view.inputmethod.EditorInfo
 import android.view.inputmethod.InputMethodManager
 import android.widget.ImageView
-import android.widget.RadioButton
-import android.widget.RadioGroup
 import android.widget.Toast
-import androidx.core.view.ViewCompat.generateViewId
-import androidx.core.view.children
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.RecyclerView
@@ -45,7 +41,6 @@ import de.rki.coronawarnapp.nearby.InternalExposureNotificationPermissionHelper
 import de.rki.coronawarnapp.receiver.ExposureStateUpdateReceiver
 import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.sharing.ExposureSharingService
 import de.rki.coronawarnapp.storage.AppDatabase
 import de.rki.coronawarnapp.storage.ExposureSummaryRepository
@@ -57,7 +52,6 @@ import de.rki.coronawarnapp.util.KeyFileHelper
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.AutoInject
 import de.rki.coronawarnapp.util.ui.observe2
-import de.rki.coronawarnapp.util.ui.setGone
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
 import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
@@ -131,75 +125,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
         qrPagerAdapter = QRPagerAdapter()
         qrPager.adapter = qrPagerAdapter
 
-        // Debug card
-        binding.hourlyKeyPkgMode.apply {
-            setOnClickListener { vm.setHourlyKeyPkgMode(isChecked) }
-        }
-
-        binding.backgroundNotificationsToggle.apply {
-            setOnClickListener { vm.setBackgroundNotifications(isChecked) }
-        }
-        vm.backgroundNotificationsToggleEvent.observe2(this@TestForAPIFragment) {
-            showToast("Background Notifications are activated: $it")
-        }
-        vm.debugOptionsState.observe2(this) { state ->
-            binding.apply {
-                backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled
-                hourlyKeyPkgMode.isChecked = state.isHourlyTestingMode
-            }
-        }
-        binding.testLogfileToggle.apply {
-            setOnClickListener { vm.setLoggerEnabled(isChecked) }
-        }
-        vm.loggerState.observe2(this) { state ->
-            binding.apply {
-                testLogfileToggle.isChecked = state.isLogging
-                testLogfileShare.setGone(!state.isLogging)
-            }
-        }
-        binding.testLogfileShare.setOnClickListener { vm.shareLogFile() }
-        vm.logShareEvent.observe2(this) { showToast("Logfile copied to $it") }
-
-        // Server environment card
-        binding.environmentToggleGroup.apply {
-            setOnCheckedChangeListener { group, checkedId ->
-                val chip = group.findViewById<RadioButton>(checkedId)
-                if (!chip.isPressed) return@setOnCheckedChangeListener
-                vm.selectEnvironmentTytpe(chip.text.toString())
-            }
-        }
-
-        vm.environmentState.observe2(this) { state ->
-            binding.apply {
-                if (environmentToggleGroup.childCount != state.available.size) {
-                    environmentToggleGroup.removeAllViews()
-                    state.available.forEach { type ->
-                        RadioButton(requireContext()).apply {
-                            id = generateViewId()
-                            text = type.rawKey
-                            layoutParams = RadioGroup.LayoutParams(
-                                RadioGroup.LayoutParams.MATCH_PARENT,
-                                RadioGroup.LayoutParams.WRAP_CONTENT
-                            )
-                            environmentToggleGroup.addView(this)
-                        }
-                    }
-                }
-
-                environmentToggleGroup.children.forEach {
-                    it as RadioButton
-                    it.isChecked = it.text == state.current.rawKey
-                }
-
-                environmentCdnurlDownload.text = "Download CDN:\n${state.urlDownload}"
-                environmentCdnurlSubmission.text = "Submission CDN:\n${state.urlSubmission}"
-                environmentCdnurlVerification.text = "Verification CDN:\n${state.urlVerification}"
-            }
-        }
-        vm.environmentChangeEvent.observe2(this) {
-            showSnackBar("Environment changed to: $it\nForce stop & restart the app!")
-        }
-
         // GMS Info card
         vm.gmsState.observe2(this) { state ->
             binding.googlePlayServicesVersionInfo.text =
@@ -292,9 +217,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
         // Load countries from App config and update Country UI element states
         lifecycleScope.launch {
             lastSetCountries =
-                ApplicationConfigurationService.asyncRetrieveApplicationConfiguration()
-                    .supportedCountriesList
-
+                AppInjector.component.appConfigProvider.getAppConfig().supportedCountries
             binding.inputCountryCodesEditText.setText(
                 lastSetCountries?.joinToString(",")
             )
@@ -469,7 +392,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
                     // only testing implementation: this is used to wait for the broadcastreceiver of the OS / EN API
                     enfClient.provideDiagnosisKeys(
                         googleFileList,
-                        ApplicationConfigurationService.asyncRetrieveExposureConfiguration(),
+                        AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration,
                         token!!
                     )
                     showToast("Provided ${appleKeyList.size} keys to Google API with token $token")
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt
index 49c8127c47e179cfc87660ffdf8414b1a72665c7..f2ebfd26e4c413d2b3c80cb8cce605669310ee03 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt
@@ -2,106 +2,25 @@ package de.rki.coronawarnapp.test.api.ui
 
 import android.content.Context
 import androidx.core.content.pm.PackageInfoCompat
-import androidx.lifecycle.viewModelScope
 import com.google.android.gms.common.GoogleApiAvailability
 import com.squareup.inject.assisted.AssistedInject
-import de.rki.coronawarnapp.environment.EnvironmentSetup
-import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType
 import de.rki.coronawarnapp.risk.RiskLevelTask
-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.EnvironmentState.Companion.toEnvironmentState
-import de.rki.coronawarnapp.test.api.ui.LoggerState.Companion.toLoggerState
-import de.rki.coronawarnapp.util.CWADebug
 import de.rki.coronawarnapp.util.di.AppContext
-import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.ui.smartLiveData
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import java.io.File
 
 class TestForApiFragmentViewModel @AssistedInject constructor(
     @AppContext private val context: Context,
-    private val envSetup: EnvironmentSetup,
-    private val testSettings: TestSettings,
     private val taskController: TaskController
 ) : CWAViewModel() {
 
-    val debugOptionsState by smartLiveData {
-        DebugOptionsState(
-            areNotificationsEnabled = LocalData.backgroundNotification(),
-            isHourlyTestingMode = testSettings.isHourKeyPkgMode
-        )
-    }
-
-    fun setHourlyKeyPkgMode(enabled: Boolean) {
-        debugOptionsState.update {
-            testSettings.isHourKeyPkgMode = enabled
-            it.copy(isHourlyTestingMode = enabled)
-        }
-    }
-
-    val environmentState by smartLiveData {
-        envSetup.toEnvironmentState()
-    }
-    val environmentChangeEvent = SingleLiveEvent<EnvironmentSetup.Type>()
-
-    fun selectEnvironmentTytpe(type: String) {
-        environmentState.update {
-            envSetup.currentEnvironment = type.toEnvironmentType()
-            environmentChangeEvent.postValue(envSetup.currentEnvironment)
-            envSetup.toEnvironmentState()
-        }
-    }
-
-    val backgroundNotificationsToggleEvent = SingleLiveEvent<Boolean>()
-
-    fun setBackgroundNotifications(enabled: Boolean) {
-        debugOptionsState.update {
-            LocalData.backgroundNotification(enabled)
-            it.copy(areNotificationsEnabled = enabled)
-        }
-        backgroundNotificationsToggleEvent.postValue(enabled)
-    }
-
-    val loggerState by smartLiveData {
-        CWADebug.toLoggerState()
-    }
-
-    fun setLoggerEnabled(enable: Boolean) {
-        CWADebug.fileLogger?.let {
-            if (enable) it.start() else it.stop()
-        }
-        loggerState.update { CWADebug.toLoggerState() }
-    }
-
     fun calculateRiskLevelClicked() {
         taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
     }
 
-    val logShareEvent = SingleLiveEvent<File?>()
-
-    fun shareLogFile() {
-        CWADebug.fileLogger?.let {
-            viewModelScope.launch(context = Dispatchers.Default) {
-                if (!it.logFile.exists()) return@launch
-
-                val externalPath = File(
-                    context.getExternalFilesDir(null),
-                    "LogFile-${System.currentTimeMillis()}.log"
-                )
-
-                it.logFile.copyTo(externalPath)
-
-                logShareEvent.postValue(externalPath)
-            }
-        }
-    }
-
     val gmsState by smartLiveData {
         GoogleServicesState(
             version = PackageInfoCompat.getLongVersionCode(
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..feedc56bc7d28d2f0be692f7410a2e026c6662b7
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.test.appconfig.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.FragmentTestAppconfigBinding
+import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
+import de.rki.coronawarnapp.util.di.AutoInject
+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 org.joda.time.DateTimeZone
+import org.joda.time.format.ISODateTimeFormat
+import javax.inject.Inject
+
+@SuppressLint("SetTextI18n")
+class AppConfigTestFragment : Fragment(R.layout.fragment_test_appconfig), AutoInject {
+
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val vm: AppConfigTestFragmentViewModel by cwaViewModels { viewModelFactory }
+
+    private val binding: FragmentTestAppconfigBinding by viewBindingLazy()
+
+    private val timeFormatter = ISODateTimeFormat.dateTime()
+        .withZone(DateTimeZone.forID("Europe/Berlin"))
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        vm.currentConfig.observe2(this) { data ->
+            binding.currentConfiguration.text =
+                data?.rawConfig?.toString() ?: "No config available."
+            binding.lastUpdate.text = data?.updatedAt?.let { timeFormatter.print(it) } ?: "n/a"
+            binding.timeOffset.text = data?.let {
+                "${it.localOffset.millis}ms (isFallbackConfig=${it.isFallback})"
+            } ?: "n/a"
+        }
+
+        vm.errorEvent.observe2(this) {
+            Toast.makeText(requireContext(), it.toString(), Toast.LENGTH_LONG).show()
+        }
+
+        binding.downloadAction.setOnClickListener { vm.download() }
+        binding.deleteAction.setOnClickListener { vm.clearConfig() }
+    }
+
+    companion object {
+        val MENU_ITEM = TestMenuItem(
+            title = "Remote Config Data",
+            description = "View & Control the remote config.",
+            targetId = R.id.test_appconfig_fragment
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0ebba5dd4c2b262da0530e469a23571a2d44f009
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt
@@ -0,0 +1,18 @@
+package de.rki.coronawarnapp.test.appconfig.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 AppConfigTestFragmentModule {
+    @Binds
+    @IntoMap
+    @CWAViewModelKey(AppConfigTestFragmentViewModel::class)
+    abstract fun testTaskControllerFragment(
+        factory: AppConfigTestFragmentViewModel.Factory
+    ): CWAViewModelFactory<out CWAViewModel>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..98dfaf2ec999083114c0b47ac8224d127406a206
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt
@@ -0,0 +1,39 @@
+package de.rki.coronawarnapp.test.appconfig.ui
+
+import androidx.lifecycle.asLiveData
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import timber.log.Timber
+
+class AppConfigTestFragmentViewModel @AssistedInject constructor(
+    dispatcherProvider: DispatcherProvider,
+    private val appConfigProvider: AppConfigProvider
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    val currentConfig = appConfigProvider.currentConfig.asLiveData()
+    val errorEvent = SingleLiveEvent<Exception>()
+
+    fun download() {
+        launch {
+            try {
+                appConfigProvider.getAppConfig()
+            } catch (e: Exception) {
+                Timber.e(e, "Failed to get app config.")
+                errorEvent.postValue(e)
+            }
+        }
+    }
+
+    fun clearConfig() {
+        launch {
+            appConfigProvider.clear()
+        }
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<AppConfigTestFragmentViewModel>
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..8015debc73d4333c59d03accd9e61e6e823e9005
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt
@@ -0,0 +1,115 @@
+package de.rki.coronawarnapp.test.debugoptions.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.View
+import android.widget.RadioButton
+import android.widget.RadioGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.children
+import androidx.fragment.app.Fragment
+import com.google.android.material.snackbar.Snackbar
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.FragmentTestDebugoptionsBinding
+import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
+import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.setGone
+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 DebugOptionsFragment : Fragment(R.layout.fragment_test_debugoptions), AutoInject {
+
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val vm: DebugOptionsFragmentViewModel by cwaViewModels { viewModelFactory }
+
+    private val binding: FragmentTestDebugoptionsBinding by viewBindingLazy()
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        // Debug card
+        binding.hourlyKeyPkgMode.apply {
+            setOnClickListener { vm.setHourlyKeyPkgMode(isChecked) }
+        }
+
+        binding.backgroundNotificationsToggle.apply {
+            setOnClickListener { vm.setBackgroundNotifications(isChecked) }
+        }
+        vm.backgroundNotificationsToggleEvent.observe2(this@DebugOptionsFragment) {
+            showSnackBar("Background Notifications are activated: $it")
+        }
+        vm.debugOptionsState.observe2(this) { state ->
+            binding.apply {
+                backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled
+                hourlyKeyPkgMode.isChecked = state.isHourlyTestingMode
+            }
+        }
+        binding.testLogfileToggle.apply {
+            setOnClickListener { vm.setLoggerEnabled(isChecked) }
+        }
+        vm.loggerState.observe2(this) { state ->
+            binding.apply {
+                testLogfileToggle.isChecked = state.isLogging
+                testLogfileShare.setGone(!state.isLogging)
+            }
+        }
+        binding.testLogfileShare.setOnClickListener { vm.shareLogFile() }
+        vm.logShareEvent.observe2(this) { showSnackBar("Logfile copied to $it") }
+
+        // Server environment card
+        binding.environmentToggleGroup.apply {
+            setOnCheckedChangeListener { group, checkedId ->
+                val chip = group.findViewById<RadioButton>(checkedId)
+                if (!chip.isPressed) return@setOnCheckedChangeListener
+                vm.selectEnvironmentTytpe(chip.text.toString())
+            }
+        }
+
+        vm.environmentState.observe2(this) { state ->
+            binding.apply {
+                if (environmentToggleGroup.childCount != state.available.size) {
+                    environmentToggleGroup.removeAllViews()
+                    state.available.forEach { type ->
+                        RadioButton(requireContext()).apply {
+                            id = ViewCompat.generateViewId()
+                            text = type.rawKey
+                            layoutParams = RadioGroup.LayoutParams(
+                                RadioGroup.LayoutParams.MATCH_PARENT,
+                                RadioGroup.LayoutParams.WRAP_CONTENT
+                            )
+                            environmentToggleGroup.addView(this)
+                        }
+                    }
+                }
+
+                environmentToggleGroup.children.forEach {
+                    it as RadioButton
+                    it.isChecked = it.text == state.current.rawKey
+                }
+
+                environmentCdnurlDownload.text = "Download CDN:\n${state.urlDownload}"
+                environmentCdnurlSubmission.text = "Submission CDN:\n${state.urlSubmission}"
+                environmentCdnurlVerification.text = "Verification CDN:\n${state.urlVerification}"
+            }
+        }
+        vm.environmentChangeEvent.observe2(this) {
+            showSnackBar("Environment changed to: $it\nForce stop & restart the app!")
+        }
+    }
+
+    private fun showSnackBar(message: String) {
+        Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show()
+    }
+
+    companion object {
+        val MENU_ITEM = TestMenuItem(
+            title = "Debug options",
+            description = "Server environment, logging, hourly mode...",
+            targetId = R.id.test_debugoptions_fragment
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f24dda5b1559bf054dd0cd7fadefb1af0c73e864
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt
@@ -0,0 +1,18 @@
+package de.rki.coronawarnapp.test.debugoptions.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 DebugOptionsFragmentModule {
+    @Binds
+    @IntoMap
+    @CWAViewModelKey(DebugOptionsFragmentViewModel::class)
+    abstract fun testTaskControllerFragment(
+        factory: DebugOptionsFragmentViewModel.Factory
+    ): CWAViewModelFactory<out CWAViewModel>
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..784c9731edd17cf949a8bb4d22d8898732fb8691
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt
@@ -0,0 +1,108 @@
+package de.rki.coronawarnapp.test.debugoptions.ui
+
+import android.content.Context
+import androidx.lifecycle.viewModelScope
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.environment.EnvironmentSetup
+import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType
+import de.rki.coronawarnapp.risk.RiskLevelTask
+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
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.ui.smartLiveData
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.io.File
+
+class DebugOptionsFragmentViewModel @AssistedInject constructor(
+    @AppContext private val context: Context,
+    private val envSetup: EnvironmentSetup,
+    private val testSettings: TestSettings,
+    private val taskController: TaskController,
+    dispatcherProvider: DispatcherProvider
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    val debugOptionsState by smartLiveData {
+        DebugOptionsState(
+            areNotificationsEnabled = LocalData.backgroundNotification(),
+            isHourlyTestingMode = testSettings.isHourKeyPkgMode
+        )
+    }
+
+    fun setHourlyKeyPkgMode(enabled: Boolean) {
+        debugOptionsState.update {
+            testSettings.isHourKeyPkgMode = enabled
+            it.copy(isHourlyTestingMode = enabled)
+        }
+    }
+
+    val environmentState by smartLiveData {
+        envSetup.toEnvironmentState()
+    }
+    val environmentChangeEvent = SingleLiveEvent<EnvironmentSetup.Type>()
+
+    fun selectEnvironmentTytpe(type: String) {
+        environmentState.update {
+            envSetup.currentEnvironment = type.toEnvironmentType()
+            environmentChangeEvent.postValue(envSetup.currentEnvironment)
+            envSetup.toEnvironmentState()
+        }
+    }
+
+    val backgroundNotificationsToggleEvent = SingleLiveEvent<Boolean>()
+
+    fun setBackgroundNotifications(enabled: Boolean) {
+        debugOptionsState.update {
+            LocalData.backgroundNotification(enabled)
+            it.copy(areNotificationsEnabled = enabled)
+        }
+        backgroundNotificationsToggleEvent.postValue(enabled)
+    }
+
+    val loggerState by smartLiveData {
+        CWADebug.toLoggerState()
+    }
+
+    fun setLoggerEnabled(enable: Boolean) {
+        CWADebug.fileLogger?.let {
+            if (enable) it.start() else it.stop()
+        }
+        loggerState.update { CWADebug.toLoggerState() }
+    }
+
+    fun calculateRiskLevelClicked() {
+        taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
+    }
+
+    val logShareEvent = SingleLiveEvent<File?>()
+
+    fun shareLogFile() {
+        CWADebug.fileLogger?.let {
+            viewModelScope.launch(context = Dispatchers.Default) {
+                if (!it.logFile.exists()) return@launch
+
+                val externalPath = File(
+                    context.getExternalFilesDir(null),
+                    "LogFile-${System.currentTimeMillis()}.log"
+                )
+
+                it.logFile.copyTo(externalPath)
+
+                logShareEvent.postValue(externalPath)
+            }
+        }
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<DebugOptionsFragmentViewModel>
+}
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 aa58711c95116a6c9629216464e667b16fc3a9ce..d3316873e6c0bb8d17fc9cf190189b9e1eb4faed 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
@@ -3,7 +3,9 @@ package de.rki.coronawarnapp.test.menu.ui
 import androidx.lifecycle.MutableLiveData
 import com.squareup.inject.assisted.AssistedInject
 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.risklevel.ui.TestRiskLevelCalculationFragment
 import de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
@@ -14,10 +16,12 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() {
 
     val testMenuData by lazy {
         listOf(
-            SettingsCrashReportFragment.MENU_ITEM,
+            DebugOptionsFragment.MENU_ITEM,
+            AppConfigTestFragment.MENU_ITEM,
             TestForAPIFragment.MENU_ITEM,
             TestRiskLevelCalculationFragment.MENU_ITEM,
-            TestTaskControllerFragment.MENU_ITEM
+            TestTaskControllerFragment.MENU_ITEM,
+            SettingsCrashReportFragment.MENU_ITEM
         ).let { MutableLiveData(it) }
     }
     val showTestScreenEvent = SingleLiveEvent<TestMenuItem>()
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
index 8513f470caa8b3826af3a554c686554cbce80ab9..0644ec8261959aac9dd0a9428a133b11b15d01f3 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
@@ -71,7 +71,6 @@ class TestRiskLevelCalculationFragment : Fragment(R.layout.fragment_test_risk_le
             binding.labelBackendParameters.text = state.backendParameters
             binding.labelExposureSummary.text = state.exposureSummary
             binding.labelFormula.text = state.formula
-            binding.labelFullConfig.text = state.fullConfig
             binding.labelExposureInfo.text = state.exposureInfo
         }
         vm.startENFObserver()
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
index d98931f3cc0d5dcbd364d896b28ba612635b73f4..add8047c5e6ceb4138286da92f5ebf02ec1bf4d0 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
 import com.google.android.gms.nearby.exposurenotification.ExposureInformation
 import com.squareup.inject.assisted.Assisted
 import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
@@ -18,7 +19,6 @@ import de.rki.coronawarnapp.risk.RiskLevelTask
 import de.rki.coronawarnapp.risk.RiskLevels
 import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.storage.AppDatabase
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.RiskLevelRepository
@@ -29,6 +29,7 @@ import de.rki.coronawarnapp.ui.tracing.card.TracingCardStateProvider
 import de.rki.coronawarnapp.util.KeyFileHelper
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.security.SecurityHelper
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
@@ -118,7 +119,6 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
         val backendParameters: String = "",
         val exposureSummary: String = "",
         val formula: String = "",
-        val fullConfig: String = "",
         val exposureInfo: String = ""
     )
 
@@ -131,11 +131,11 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                 val exposureSummary =
                     InternalExposureNotificationClient.asyncGetExposureSummary(googleToken)
 
-                val appConfig =
-                    ApplicationConfigurationService.asyncRetrieveApplicationConfiguration()
+                val expDetectConfig: RiskCalculationConfig =
+                    AppInjector.component.appConfigProvider.getAppConfig()
 
                 val riskLevelScore = riskLevels.calculateRiskScore(
-                    appConfig.attenuationDuration,
+                    expDetectConfig.attenuationDuration,
                     exposureSummary
                 )
 
@@ -153,17 +153,17 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                 workState = workState.copy(riskScoreMsg = riskAsString)
 
                 val lowClass =
-                    appConfig.riskScoreClasses?.riskClassesList?.find { low -> low.label == "LOW" }
+                    expDetectConfig.riskScoreClasses.riskClassesList?.find { low -> low.label == "LOW" }
                 val highClass =
-                    appConfig.riskScoreClasses?.riskClassesList?.find { high -> high.label == "HIGH" }
+                    expDetectConfig.riskScoreClasses.riskClassesList?.find { high -> high.label == "HIGH" }
 
                 val configAsString =
-                    "Attenuation Weight Low: ${appConfig.attenuationDuration?.weights?.low}\n" +
-                        "Attenuation Weight Mid: ${appConfig.attenuationDuration?.weights?.mid}\n" +
-                        "Attenuation Weight High: ${appConfig.attenuationDuration?.weights?.high}\n\n" +
-                        "Attenuation Offset: ${appConfig.attenuationDuration?.defaultBucketOffset}\n" +
+                    "Attenuation Weight Low: ${expDetectConfig.attenuationDuration.weights?.low}\n" +
+                        "Attenuation Weight Mid: ${expDetectConfig.attenuationDuration.weights?.mid}\n" +
+                        "Attenuation Weight High: ${expDetectConfig.attenuationDuration.weights?.high}\n\n" +
+                        "Attenuation Offset: ${expDetectConfig.attenuationDuration.defaultBucketOffset}\n" +
                         "Attenuation Normalization: " +
-                        "${appConfig.attenuationDuration?.riskScoreNormalizationDivisor}\n\n" +
+                        "${expDetectConfig.attenuationDuration.riskScoreNormalizationDivisor}\n\n" +
                         "Risk Score Low Class: ${lowClass?.min ?: 0} - ${lowClass?.max ?: 0}\n" +
                         "Risk Score High Class: ${highClass?.min ?: 0} - ${highClass?.max ?: 0}"
 
@@ -185,19 +185,18 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                 workState = workState.copy(exposureSummary = summaryAsString)
 
                 val maxRisk = exposureSummary.maximumRiskScore
-                val atWeights = appConfig.attenuationDuration?.weights
+                val atWeights = expDetectConfig.attenuationDuration.weights
                 val attenuationDurationInMin =
                     exposureSummary.attenuationDurationsInMinutes
-                val attenuationConfig = appConfig.attenuationDuration
+                val attenuationConfig = expDetectConfig.attenuationDuration
                 val formulaString =
-                    "($maxRisk / ${attenuationConfig?.riskScoreNormalizationDivisor}) * " +
+                    "($maxRisk / ${attenuationConfig.riskScoreNormalizationDivisor}) * " +
                         "(${attenuationDurationInMin?.get(0)} * ${atWeights?.low} " +
                         "+ ${attenuationDurationInMin?.get(1)} * ${atWeights?.mid} " +
                         "+ ${attenuationDurationInMin?.get(2)} * ${atWeights?.high} " +
-                        "+ ${attenuationConfig?.defaultBucketOffset})"
+                        "+ ${attenuationConfig.defaultBucketOffset})"
 
-                workState =
-                    workState.copy(formula = formulaString, fullConfig = appConfig.toString())
+                workState = workState.copy(formula = formulaString)
 
                 val token = LocalData.googleApiToken()
                 if (token != null) {
@@ -273,7 +272,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                 // only testing implementation: this is used to wait for the broadcastreceiver of the OS / EN API
                 enfClient.provideDiagnosisKeys(
                     googleFileList,
-                    ApplicationConfigurationService.asyncRetrieveExposureConfiguration(),
+                    AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration,
                     token
                 )
                 apiKeysProvidedEvent.postValue(
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 cdf98cf1aa010d2e993173b7209eceda722c256e..dae7827378498fa470ebb0df278741e4d1e00522 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
@@ -4,6 +4,10 @@ import dagger.Module
 import dagger.android.ContributesAndroidInjector
 import de.rki.coronawarnapp.test.api.ui.TestForAPIFragment
 import de.rki.coronawarnapp.test.api.ui.TestForApiFragmentModule
+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.menu.ui.TestMenuFragment
 import de.rki.coronawarnapp.test.menu.ui.TestMenuFragmentModule
 import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment
@@ -25,4 +29,10 @@ abstract class MainActivityTestModule {
 
     @ContributesAndroidInjector(modules = [TestTaskControllerFragmentModule::class])
     abstract fun testTaskControllerFragment(): TestTaskControllerFragment
+
+    @ContributesAndroidInjector(modules = [AppConfigTestFragmentModule::class])
+    abstract fun appConfigTestFragment(): AppConfigTestFragment
+
+    @ContributesAndroidInjector(modules = [DebugOptionsFragmentModule::class])
+    abstract fun debugOptions(): DebugOptionsFragment
 }
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fb4d95036ce1e7f5e7e101beead64984209dc373
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    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="wrap_content"
+        android:layout_margin="8dp"
+        android:orientation="vertical"
+        android:paddingBottom="32dp">
+
+        <LinearLayout
+            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
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Last update" />
+
+            <TextView
+                android:id="@+id/last_update"
+                style="@style/body1"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Last update: ??" />
+
+            <TextView
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/spacing_tiny"
+                android:text="Time offset to server" />
+
+            <TextView
+                android:id="@+id/time_offset"
+                style="@style/body1"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Last update: ??" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/spacing_tiny"
+                android:orientation="horizontal">
+
+                <Button
+                    android:id="@+id/delete_action"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginEnd="@dimen/spacing_tiny"
+                    android:layout_weight="1"
+                    android:text="Delete" />
+
+                <Button
+                    android:id="@+id/download_action"
+                    android:layout_width="match_parent"
+                    android:layout_marginStart="@dimen/spacing_tiny"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:text="Download" />
+            </LinearLayout>
+        </LinearLayout>
+
+        <LinearLayout
+            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/current_configuration"
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                tools:text="@string/lorem_ipsum" />
+
+        </LinearLayout>
+
+    </LinearLayout>
+</androidx.core.widget.NestedScrollView>
\ No newline at end of file
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
new file mode 100644
index 0000000000000000000000000000000000000000..628201557567cccfde46d6928854209b410860e2
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml
@@ -0,0 +1,146 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout 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"
+    tools:ignore="HardcodedText">
+
+    <androidx.core.widget.NestedScrollView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:fillViewport="true">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/spacing_tiny"
+            android:orientation="vertical">
+
+            <androidx.constraintlayout.widget.ConstraintLayout
+                android:id="@+id/debug_container"
+                style="@style/card"
+                android:layout_margin="@dimen/spacing_tiny"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content">
+
+                <TextView
+                    android:id="@+id/debug_container_title"
+                    style="@style/headline6"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:text="Debug options"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    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"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    android:text="@string/test_api_switch_background_notifications"
+                    android:theme="@style/switchBase"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/hourly_key_pkg_mode" />
+
+                <Switch
+                    android:id="@+id/test_logfile_toggle"
+                    style="@style/body1"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    android:layout_weight="1"
+                    android:text="Logfile enabled"
+                    android:theme="@style/switchBase"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/background_notifications_toggle" />
+
+                <Button
+                    android:id="@+id/test_logfile_share"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    android:text="Share log"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/test_logfile_toggle" />
+            </androidx.constraintlayout.widget.ConstraintLayout>
+
+            <androidx.constraintlayout.widget.ConstraintLayout
+                android:id="@+id/environment_container"
+                style="@style/card"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_margin="@dimen/spacing_tiny">
+
+                <TextView
+                    android:id="@+id/environment_title"
+                    style="@style/headline6"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:text="Server environment"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toTopOf="parent" />
+
+                <TextView
+                    android:id="@+id/environment_cdnurl_download"
+                    style="@style/body2"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/environment_title"
+                    tools:text="Download: ?" />
+
+                <TextView
+                    android:id="@+id/environment_cdnurl_submission"
+                    style="@style/body2"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_download"
+                    tools:text="Submission: ?" />
+
+                <TextView
+                    android:id="@+id/environment_cdnurl_verification"
+                    style="@style/body2"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_submission"
+                    tools:text="Verification: ?" />
+
+                <RadioGroup
+                    android:id="@+id/environment_toggle_group"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    android:orientation="vertical"
+                    app:layout_constraintBottom_toBottomOf="parent"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_verification" />
+            </androidx.constraintlayout.widget.ConstraintLayout>
+
+        </LinearLayout>
+    </androidx.core.widget.NestedScrollView>
+</layout>
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 767e5f68d4038a3f1fde9349218f8f834b6c482b..a72eafd72f6db23a094411021dbf8dd578e9cfc2 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
@@ -15,132 +15,6 @@
             android:layout_margin="@dimen/spacing_tiny"
             android:orientation="vertical">
 
-            <androidx.constraintlayout.widget.ConstraintLayout
-                android:id="@+id/debug_container"
-                style="@style/card"
-                android:layout_margin="@dimen/spacing_tiny"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content">
-
-                <TextView
-                    android:id="@+id/debug_container_title"
-                    style="@style/headline6"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:text="Debug options"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    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"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    android:text="@string/test_api_switch_background_notifications"
-                    android:theme="@style/switchBase"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/hourly_key_pkg_mode" />
-
-                <Switch
-                    android:id="@+id/test_logfile_toggle"
-                    style="@style/body1"
-                    android:layout_width="0dp"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    android:layout_weight="1"
-                    android:text="Logfile enabled"
-                    android:theme="@style/switchBase"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/background_notifications_toggle" />
-
-                <Button
-                    android:id="@+id/test_logfile_share"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    android:text="Share log"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/test_logfile_toggle" />
-            </androidx.constraintlayout.widget.ConstraintLayout>
-
-            <androidx.constraintlayout.widget.ConstraintLayout
-                android:id="@+id/environment_container"
-                style="@style/card"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_margin="@dimen/spacing_tiny">
-
-                <TextView
-                    android:id="@+id/environment_title"
-                    style="@style/headline6"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:text="Server environment"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toTopOf="parent" />
-
-                <TextView
-                    android:id="@+id/environment_cdnurl_download"
-                    style="@style/body2"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/environment_title"
-                    tools:text="Download: ?" />
-
-                <TextView
-                    android:id="@+id/environment_cdnurl_submission"
-                    style="@style/body2"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_download"
-                    tools:text="Submission: ?" />
-
-                <TextView
-                    android:id="@+id/environment_cdnurl_verification"
-                    style="@style/body2"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_submission"
-                    tools:text="Verification: ?" />
-
-                <RadioGroup
-                    android:id="@+id/environment_toggle_group"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    android:orientation="vertical"
-                    app:layout_constraintBottom_toBottomOf="parent"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_verification" />
-            </androidx.constraintlayout.widget.ConstraintLayout>
-
             <androidx.constraintlayout.widget.ConstraintLayout
                 android:id="@+id/gms_container"
                 style="@style/card"
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml
index 1746c0c05c91002998bef221502d0b3b8b009abd..807e881a75173a9c0229d1a547d2b588ec679a5c 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml
@@ -13,7 +13,7 @@
         app:layout_constraintStart_toStartOf="parent"
         app:layout_constraintTop_toTopOf="parent"
         app:navigationIcon="@drawable/ic_coffee"
-        app:subtitle="For testers ;)"
+        app:subtitle="For testers &amp; QA &lt;3"
         app:title="Test Menu" />
 
     <androidx.recyclerview.widget.RecyclerView
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
index 06f6d5af7843901137e191926078cad0a1066b5a..9e29d88d479a233ef358cbe19976dfd67bd4798f 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
@@ -191,21 +191,6 @@
                 android:layout_height="wrap_content"
                 android:text="-" />
 
-            <TextView
-                android:id="@+id/label_full_config_title"
-                style="@style/headline6"
-                android:accessibilityHeading="true"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="Full Backend Configuration" />
-
-            <TextView
-                android:id="@+id/label_full_config"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginBottom="@dimen/spacing_normal"
-                android:text="-" />
-
         </LinearLayout>
     </ScrollView>
 </layout>
\ 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 3aff7a83c201775dc37583d1da61ee3844b3eb99..1c53c77729006536c92d18efecd0b547370bb3cf 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
@@ -8,7 +8,8 @@
     <fragment
         android:id="@+id/test_menu_fragment"
         android:name="de.rki.coronawarnapp.test.menu.ui.TestMenuFragment"
-        android:label="TestMenuFragment">
+        android:label="TestMenuFragment"
+        tools:layout="@layout/fragment_test_menu">
         <action
             android:id="@+id/action_testMenuFragment_to_settingsCrashReportFragment"
             app:destination="@id/test_bug_report_fragment" />
@@ -21,6 +22,12 @@
         <action
             android:id="@+id/action_test_menu_fragment_to_testTaskControllerFragment"
             app:destination="@id/test_taskcontroller_fragment" />
+        <action
+            android:id="@+id/action_test_menu_fragment_to_appConfigTestFragment"
+            app:destination="@id/test_appconfig_fragment" />
+        <action
+            android:id="@+id/action_test_menu_fragment_to_debugOptionsFragment"
+            app:destination="@id/test_debugoptions_fragment" />
     </fragment>
 
     <fragment
@@ -61,5 +68,15 @@
         android:name="de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment"
         android:label="TestTaskControllerFragment"
         tools:layout="@layout/fragment_test_task_controller" />
+    <fragment
+        android:id="@+id/test_appconfig_fragment"
+        android:name="de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment"
+        android:label="AppConfigTestFragment"
+        tools:layout="@layout/fragment_test_appconfig" />
+    <fragment
+        android:id="@+id/test_debugoptions_fragment"
+        android:name="de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment"
+        android:label="DebugOptionsFragment"
+        tools:layout="@layout/fragment_test_debugoptions" />
 
 </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 9216419bb2197d6e3736d33fe5da863ded200dbf..82936e52b38862e1288652fe250952d4e5669375 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
@@ -3,6 +3,12 @@ package de.rki.coronawarnapp.appconfig
 import android.content.Context
 import dagger.Module
 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.RiskCalculationConfigMapper
 import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient
 import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl
 import de.rki.coronawarnapp.util.di.AppContext
@@ -54,6 +60,19 @@ class AppConfigModule {
             .create(AppConfigApiV1::class.java)
     }
 
+    @Provides
+    fun cwaMapper(mapper: CWAConfigMapper): CWAConfig.Mapper = mapper
+
+    @Provides
+    fun downloadMapper(mapper: DownloadConfigMapper): KeyDownloadConfig.Mapper = mapper
+
+    @Provides
+    fun exposurMapper(mapper: ExposureDetectionConfigMapper): ExposureDetectionConfig.Mapper =
+        mapper
+
+    @Provides
+    fun riskMapper(mapper: RiskCalculationConfigMapper): RiskCalculationConfig.Mapper = mapper
+
     companion object {
         private val HTTP_TIMEOUT_APPCONFIG = Duration.standardSeconds(10)
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
index 4cbedfc9303397c2436237a51cdd51f4a515c854..91866abb5c8d0d302038a0ffce536ab629246140 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
@@ -1,149 +1,45 @@
 package de.rki.coronawarnapp.appconfig
 
-import androidx.annotation.VisibleForTesting
-import dagger.Lazy
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
-import de.rki.coronawarnapp.util.ZipHelper.unzip
-import de.rki.coronawarnapp.util.security.VerificationKeys
-import kotlinx.coroutines.Dispatchers
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
-import okhttp3.Cache
 import timber.log.Timber
 import javax.inject.Inject
 import javax.inject.Singleton
 
 @Singleton
 class AppConfigProvider @Inject constructor(
-    private val appConfigAPI: Lazy<AppConfigApiV1>,
-    private val verificationKeys: VerificationKeys,
-    @DownloadCDNHomeCountry private val homeCountry: LocationCode,
-    private val configStorage: AppConfigStorage,
-    @AppConfigHttpCache private val cache: Cache
+    private val source: AppConfigSource,
+    private val dispatcherProvider: DispatcherProvider,
+    @AppScope private val scope: CoroutineScope
 ) {
 
     private val mutex = Mutex()
-    private val configApi: AppConfigApiV1
-        get() = appConfigAPI.get()
+    private val currentConfigInternal = MutableStateFlow<ConfigData?>(null)
 
-    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
-    internal suspend fun downloadAppConfig(): ByteArray? {
-        Timber.tag(TAG).d("Fetching app config.")
-        var exportBinary: ByteArray? = null
-        var exportSignature: ByteArray? = null
-        configApi.getApplicationConfiguration(homeCountry.identifier).byteStream()
-            .unzip { entry, entryContent ->
-                if (entry.name == EXPORT_BINARY_FILE_NAME) exportBinary =
-                    entryContent.copyOf()
-                if (entry.name == EXPORT_SIGNATURE_FILE_NAME) exportSignature =
-                    entryContent.copyOf()
-            }
-        if (exportBinary == null || exportSignature == null) {
-            throw ApplicationConfigurationInvalidException()
-        }
-
-        if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) {
-            throw ApplicationConfigurationCorruptException()
-        }
-
-        return exportBinary!!
-    }
-
-    private fun tryParseConfig(byteArray: ByteArray?): ApplicationConfiguration? {
-        Timber.v("Parsing config (size=%dB)", byteArray?.size)
-        if (byteArray == null) return null
-        return ApplicationConfiguration.parseFrom(byteArray)
-    }
-
-    private suspend fun getNewAppConfig(): ApplicationConfiguration? {
-        val newConfigRaw = try {
-            downloadAppConfig()
-        } catch (e: Exception) {
-            Timber.w(e, "Failed to download latest AppConfig.")
-            if (configStorage.isAppConfigAvailable()) {
-                null
-            } else {
-                Timber.e("No fallback available, rethrowing!")
-                throw e
-            }
-        }
-
-        val newConfigParsed = try {
-            tryParseConfig(newConfigRaw)
-        } catch (e: Exception) {
-            Timber.w(e, "Failed to parse latest AppConfig.")
-            null
-        }
-
-        return newConfigParsed?.also {
-            Timber.d("Saving new valid config.")
-            Timber.v("New Config.supportedCountries: %s", it.supportedCountriesList)
-            configStorage.setAppConfigRaw(newConfigRaw)
-        }
-    }
-
-    private suspend fun getFallback(): ApplicationConfiguration {
-        val lastValidConfig = tryParseConfig(configStorage.getAppConfigRaw())
-        return if (lastValidConfig != null) {
-            Timber.d("Using fallback AppConfig.")
-            lastValidConfig
-        } else {
-            Timber.e("No valid fallback AppConfig available.")
-            throw ApplicationConfigurationInvalidException()
-        }
-    }
-
-    suspend fun getAppConfig(): ApplicationConfiguration = mutex.withLock {
-        withContext(Dispatchers.IO) {
-
-            val newAppConfig = getNewAppConfig()
-
-            return@withContext if (newAppConfig != null) {
-                newAppConfig
-            } else {
-                Timber.w("No new config available, using last valid.")
-                getFallback()
-            }
-        }.performSanityChecks()
-    }
+    val currentConfig: Flow<ConfigData?> = currentConfigInternal
 
     suspend fun clear() = mutex.withLock {
-        withContext(Dispatchers.IO) {
-            configStorage.setAppConfigRaw(null)
-
-            // We are using Dispatchers IO to make it appropriate
-            @Suppress("BlockingMethodInNonBlockingContext")
-            cache.evictAll()
-        }
+        Timber.tag(TAG).v("clear()")
+        source.clear()
+        currentConfigInternal.value = null
     }
 
-    private fun ApplicationConfiguration.performSanityChecks(): ApplicationConfiguration {
-        var sanityChecked = this
-
-        if (sanityChecked.supportedCountriesList == null) {
-            sanityChecked = sanityChecked.toNewConfig {
-                clearSupportedCountries()
-                addAllSupportedCountries(emptyList<String>())
-            }
-        }
-
-        val countryCheck = sanityChecked.supportedCountriesList
-        if (countryCheck.size == 1 && !VALID_CC.matches(countryCheck.single())) {
-            Timber.w("Invalid country data, clearing. (%s)", this.supportedCountriesList)
-            sanityChecked = sanityChecked.toNewConfig {
-                clearSupportedCountries()
+    suspend fun getAppConfig(): ConfigData = mutex.withLock {
+        Timber.tag(TAG).v("getAppConfig()")
+        withContext(context = scope.coroutineContext + dispatcherProvider.IO) {
+            source.retrieveConfig().also {
+                currentConfigInternal.emit(it)
             }
         }
-        return sanityChecked
     }
 
     companion object {
-        private val VALID_CC = "^([A-Z]{2,3})$".toRegex()
-        private const val EXPORT_BINARY_FILE_NAME = "export.bin"
-        private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig"
-        private val TAG = AppConfigProvider::class.java.simpleName
+        private const val TAG = "AppConfigProvider"
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fd31afca8ef1e6ce15beef7927dab09473d752ca
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt
@@ -0,0 +1,82 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.download.AppConfigServer
+import de.rki.coronawarnapp.appconfig.download.AppConfigStorage
+import de.rki.coronawarnapp.appconfig.download.ApplicationConfigurationInvalidException
+import de.rki.coronawarnapp.appconfig.mapping.ConfigParser
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AppConfigSource @Inject constructor(
+    private val server: AppConfigServer,
+    private val storage: AppConfigStorage,
+    private val parser: ConfigParser,
+    private val dispatcherProvider: DispatcherProvider
+) {
+
+    suspend fun retrieveConfig(): ConfigData = withContext(dispatcherProvider.IO) {
+        Timber.v("retrieveConfig()")
+        val (serverBytes, serverError) = try {
+            server.downloadAppConfig() to null
+        } catch (e: Exception) {
+            Timber.tag(TAG).w(e, "Failed to download AppConfig from server .")
+            null to e
+        }
+
+        var parsedConfig: ConfigData? = serverBytes?.let { configDownload ->
+            try {
+                parser.parse(configDownload.rawData).let {
+                    Timber.tag(TAG).d("Got a valid AppConfig from server, saving.")
+                    storage.setStoredConfig(configDownload)
+                    DefaultConfigData(
+                        mappedConfig = it,
+                        serverTime = configDownload.serverTime,
+                        localOffset = configDownload.localOffset,
+                        isFallback = false
+                    )
+                }
+            } catch (e: Exception) {
+                Timber.tag(TAG).e(e, "Failed to parse AppConfig from server, trying fallback.")
+                null
+            }
+        }
+
+        if (parsedConfig == null) {
+            parsedConfig = storage.getStoredConfig()?.let { storedDownloadConfig ->
+                try {
+                    storedDownloadConfig.let {
+                        DefaultConfigData(
+                            mappedConfig = parser.parse(it.rawData),
+                            serverTime = it.serverTime,
+                            localOffset = it.localOffset,
+                            isFallback = true
+                        )
+                    }
+                } catch (e: Exception) {
+                    Timber.tag(TAG).e(e, "Fallback config exists but could not be parsed!")
+                    throw e
+                }
+            }
+        }
+
+        if (parsedConfig == null) {
+            throw ApplicationConfigurationInvalidException(serverError)
+        }
+
+        return@withContext parsedConfig
+    }
+
+    suspend fun clear() {
+        storage.setStoredConfig(null)
+
+        server.clearCache()
+    }
+
+    companion object {
+        private const val TAG = "AppConfigRetriever"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt
deleted file mode 100644
index 54b6351b104cec7b0d98305dee5f7ba040f5572b..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package de.rki.coronawarnapp.appconfig
-
-import android.content.Context
-import de.rki.coronawarnapp.util.di.AppContext
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import timber.log.Timber
-import java.io.File
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class AppConfigStorage @Inject constructor(
-    @AppContext context: Context
-) {
-    private val configDir = File(context.filesDir, "appconfig_storage")
-    private val configFile = File(configDir, "appconfig")
-    private val mutex = Mutex()
-
-    suspend fun isAppConfigAvailable(): Boolean = mutex.withLock {
-        configFile.exists() && configFile.length() > MIN_VALID_CONFIG_BYTES
-    }
-
-    suspend fun getAppConfigRaw(): ByteArray? = mutex.withLock {
-        Timber.v("get() AppConfig")
-        if (!configFile.exists()) return null
-
-        val value = configFile.readBytes()
-        Timber.v("Read AppConfig of size %s and date %s", value.size, configFile.lastModified())
-        return value
-    }
-
-    suspend fun setAppConfigRaw(value: ByteArray?): Unit = mutex.withLock {
-        Timber.v("set(...) AppConfig: %dB", value?.size)
-
-        if (configDir.mkdirs()) Timber.v("Parent folder created.")
-
-        if (configFile.exists()) {
-            Timber.v("Overwriting %d from %s", configFile.length(), configFile.lastModified())
-        }
-        if (value != null) {
-            configFile.writeBytes(value)
-        } else {
-            configFile.delete()
-        }
-    }
-
-    companion object {
-        // The normal config is ~512B+, we just need to check for a non 0 value, 128 is fine.
-        private const val MIN_VALID_CONFIG_BYTES = 128
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt
deleted file mode 100644
index 153435397fcff0fa237e32723413fd5aae3f9c30..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.rki.coronawarnapp.appconfig
-
-import de.rki.coronawarnapp.exception.reporting.ErrorCodes
-import de.rki.coronawarnapp.exception.reporting.ReportedException
-
-class ApplicationConfigurationInvalidException : ReportedException(
-    ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code, "the application configuration is invalid"
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0a11bf822114ca77b02a3bdd283c0a83b6dad69a
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
+import de.rki.coronawarnapp.server.protocols.internal.AppFeaturesOuterClass
+import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig
+
+interface CWAConfig {
+
+    val appVersion: AppVersionConfig.ApplicationVersionConfiguration
+
+    val supportedCountries: List<String>
+
+    val appFeatureus: AppFeaturesOuterClass.AppFeatures
+
+    interface Mapper : ConfigMapper<CWAConfig>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6845e3d29be4db488fb1fe29e5bfdd9102040672
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt
@@ -0,0 +1,24 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapping
+import org.joda.time.Duration
+import org.joda.time.Instant
+
+interface ConfigData : ConfigMapping {
+
+    /**
+     * serverTime + localOffset = updatedAt
+     */
+    val updatedAt: Instant
+
+    /**
+     * If **[isFallback]** returns true,
+     * you should probably ignore the time offset.
+     */
+    val localOffset: Duration
+
+    /**
+     * Returns true if this is not a fresh config, e.g. server could not be reached.
+     */
+    val isFallback: Boolean
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5fc918d095771bf9df33b81ec5cf591595ed46b5
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt
@@ -0,0 +1,14 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapping
+import org.joda.time.Duration
+import org.joda.time.Instant
+
+data class DefaultConfigData(
+    val serverTime: Instant,
+    val mappedConfig: ConfigMapping,
+    override val localOffset: Duration,
+    override val isFallback: Boolean
+) : ConfigData, ConfigMapping by mappedConfig {
+    override val updatedAt: Instant = serverTime.plus(localOffset)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5281c51ede2e8a7d12d55e56be4ccb92b8155031
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt
@@ -0,0 +1,13 @@
+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
+
+interface ExposureDetectionConfig {
+
+    val exposureDetectionConfiguration: ExposureConfiguration
+    val exposureDetectionParameters: ExposureDetectionParameters.ExposureDetectionParametersAndroid
+
+    interface Mapper : ConfigMapper<ExposureDetectionConfig>
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..d82a6cd3a3ecc741986426e6de9013ee8a3626f6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
@@ -0,0 +1,11 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
+import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters
+
+interface KeyDownloadConfig {
+
+    val keyDownloadParameters: KeyDownloadParameters.KeyDownloadParametersAndroid
+
+    interface Mapper : ConfigMapper<KeyDownloadConfig>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2c19c0be637e843d0137d3db4983f1ccce8de97c
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
+import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass
+import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass
+
+interface RiskCalculationConfig {
+
+    val minRiskScore: Int
+
+    val attenuationDuration: AttenuationDurationOuterClass.AttenuationDuration
+
+    val riskScoreClasses: RiskScoreClassificationOuterClass.RiskScoreClassification
+
+    interface Mapper : ConfigMapper<RiskCalculationConfig>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt
similarity index 71%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt
index ae8898f1aabacd8f602ffc16065bcefe7040fb6a..0c3f61077cc884adf45aa8a02a78e2ed5fe7154d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt
@@ -1,6 +1,7 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import okhttp3.ResponseBody
+import retrofit2.Response
 import retrofit2.http.GET
 import retrofit2.http.Path
 
@@ -9,5 +10,5 @@ interface AppConfigApiV1 {
     @GET("/version/v1/configuration/country/{country}/app_config")
     suspend fun getApplicationConfiguration(
         @Path("country") country: String
-    ): ResponseBody
+    ): Response<ResponseBody>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt
similarity index 74%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt
index a3aff4add4b1df85c9093fa97efe839f2f57a924..253ac97d3fbedc4a7c0c5429cad471f77cac0837 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import javax.inject.Qualifier
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1d707c26bb248982443582fdba7ed672c640fe1e
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt
@@ -0,0 +1,99 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import dagger.Lazy
+import dagger.Reusable
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.ZipHelper.readIntoMap
+import de.rki.coronawarnapp.util.ZipHelper.unzip
+import de.rki.coronawarnapp.util.security.VerificationKeys
+import okhttp3.Cache
+import org.joda.time.Duration
+import org.joda.time.Instant
+import org.joda.time.format.DateTimeFormat
+import retrofit2.HttpException
+import retrofit2.Response
+import timber.log.Timber
+import java.util.Locale
+import javax.inject.Inject
+
+@Reusable
+class AppConfigServer @Inject constructor(
+    private val api: Lazy<AppConfigApiV1>,
+    private val verificationKeys: VerificationKeys,
+    private val timeStamper: TimeStamper,
+    @DownloadCDNHomeCountry private val homeCountry: LocationCode,
+    @AppConfigHttpCache private val cache: Cache
+) {
+
+    internal suspend fun downloadAppConfig(): ConfigDownload {
+        Timber.tag(TAG).d("Fetching app config.")
+
+        val response = api.get().getApplicationConfiguration(homeCountry.identifier)
+        if (!response.isSuccessful) throw HttpException(response)
+
+        // If this is a cached response, we need the original timestamp to calculate the time offset
+        val localTime = response.getCacheTimestamp() ?: timeStamper.nowUTC
+
+        val rawConfig = with(
+            requireNotNull(response.body()) { "Response was successful but body was null" }
+        ) {
+            val fileMap = byteStream().unzip().readIntoMap()
+
+            val exportBinary = fileMap[EXPORT_BINARY_FILE_NAME]
+            val exportSignature = fileMap[EXPORT_SIGNATURE_FILE_NAME]
+
+            if (exportBinary == null || exportSignature == null) {
+                throw ApplicationConfigurationInvalidException(message = "Unknown files: ${fileMap.keys}")
+            }
+
+            if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) {
+                throw ApplicationConfigurationCorruptException()
+            }
+
+            exportBinary
+        }
+
+        val serverTime = response.getServerDate() ?: localTime
+        val offset = Duration(serverTime, localTime)
+        Timber.tag(TAG).v("Time offset was %dms", offset.millis)
+
+        return ConfigDownload(
+            rawData = rawConfig,
+            serverTime = serverTime,
+            localOffset = offset
+        )
+    }
+
+    private fun <T> Response<T>.getServerDate(): Instant? = try {
+        val rawDate = headers()["Date"] ?: throw IllegalArgumentException(
+            "Server date unavailable: ${headers()}"
+        )
+        Instant.parse(rawDate, DATE_FORMAT)
+    } catch (e: Exception) {
+        Timber.e("Failed to get server time.")
+        null
+    }
+
+    private fun <T> Response<T>.getCacheTimestamp(): Instant? {
+        val cacheResponse = raw().cacheResponse
+        return cacheResponse?.sentRequestAtMillis?.let {
+            Instant.ofEpochMilli(it)
+        }
+    }
+
+    internal fun clearCache() {
+        Timber.tag(TAG).v("clearCache()")
+        cache.evictAll()
+    }
+
+    companion object {
+        private const val EXPORT_BINARY_FILE_NAME = "export.bin"
+        private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig"
+        private val DATE_FORMAT = DateTimeFormat
+            .forPattern("EEE, dd MMM yyyy HH:mm:ss zzz")
+            .withLocale(Locale.ROOT)
+        private val TAG = AppConfigServer::class.java.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c5a86a92887ab5c26dd0afe1dd54ee4220186e6f
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt
@@ -0,0 +1,87 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import android.content.Context
+import com.google.gson.Gson
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.serialization.BaseGson
+import de.rki.coronawarnapp.util.serialization.adapter.DurationAdapter
+import de.rki.coronawarnapp.util.serialization.adapter.InstantAdapter
+import de.rki.coronawarnapp.util.serialization.fromJson
+import de.rki.coronawarnapp.util.serialization.toJson
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.joda.time.Duration
+import org.joda.time.Instant
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AppConfigStorage @Inject constructor(
+    @AppContext context: Context,
+    private val timeStamper: TimeStamper,
+    @BaseGson private val baseGson: Gson
+) {
+
+    private val gson by lazy {
+        baseGson.newBuilder()
+            .registerTypeAdapter(Instant::class.java, InstantAdapter())
+            .registerTypeAdapter(Duration::class.java, DurationAdapter())
+            .create()
+    }
+    private val configDir = File(context.filesDir, "appconfig_storage")
+
+    // This is just the raw protobuf data
+    private val legacyConfigFile = File(configDir, "appconfig")
+    private val configFile = File(configDir, "appconfig.json")
+    private val mutex = Mutex()
+
+    suspend fun getStoredConfig(): ConfigDownload? = mutex.withLock {
+        Timber.v("get() AppConfig")
+
+        if (!configFile.exists() && legacyConfigFile.exists()) {
+            Timber.i("Returning legacy config.")
+            return@withLock try {
+                ConfigDownload(
+                    rawData = legacyConfigFile.readBytes(),
+                    serverTime = timeStamper.nowUTC,
+                    localOffset = Duration.ZERO
+                )
+            } catch (e: Exception) {
+                Timber.e(e, "Legacy config exits but couldn't be read.")
+                null
+            }
+        }
+
+        return@withLock try {
+            gson.fromJson<ConfigDownload>(configFile)
+        } catch (e: Exception) {
+            Timber.e(e, "Couldn't load config.")
+            null
+        }
+    }
+
+    suspend fun setStoredConfig(value: ConfigDownload?): Unit = mutex.withLock {
+        Timber.v("set(...) AppConfig: %s", value)
+
+        if (configDir.mkdirs()) Timber.v("Parent folder created.")
+
+        if (configFile.exists()) {
+            Timber.v("Overwriting %d from %s", configFile.length(), configFile.lastModified())
+        }
+
+        if (value != null) {
+            gson.toJson(value, configFile)
+
+            if (legacyConfigFile.exists()) {
+                if (legacyConfigFile.delete()) {
+                    Timber.i("Legacy config file deleted, superseeded.")
+                }
+            }
+        } else {
+            configFile.delete()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt
similarity index 86%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt
index 51c5dfb8933f457956bf161cd29acc5defef8c94..bd6940034f8af15de4d110ab65ece6eb9a34cd94 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import de.rki.coronawarnapp.exception.reporting.ErrorCodes
 import de.rki.coronawarnapp.exception.reporting.ReportedException
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt
new file mode 100644
index 0000000000000000000000000000000000000000..63cb1069e92febfd84f5a894a8d9dbf3c26ea618
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt
@@ -0,0 +1,13 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import de.rki.coronawarnapp.exception.reporting.ErrorCodes
+import de.rki.coronawarnapp.exception.reporting.ReportedException
+
+class ApplicationConfigurationInvalidException(
+    cause: Exception? = null,
+    message: String? = null
+) : ReportedException(
+    code = ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code,
+    message = message,
+    cause = cause
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1f9ba9050b61f2a5d802e2f5d692cdeba3881369
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt
@@ -0,0 +1,31 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import com.google.gson.annotations.SerializedName
+import org.joda.time.Duration
+import org.joda.time.Instant
+
+data class ConfigDownload(
+    @SerializedName("rawData") val rawData: ByteArray,
+    @SerializedName("serverTime") val serverTime: Instant,
+    @SerializedName("localOffset") val localOffset: Duration
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as ConfigDownload
+
+        if (!rawData.contentEquals(other.rawData)) return false
+        if (serverTime != other.serverTime) return false
+        if (localOffset != other.localOffset) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = rawData.contentHashCode()
+        result = 31 * result + serverTime.hashCode()
+        result = 31 * result + localOffset.hashCode()
+        return result
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt
similarity index 86%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt
index 9ac63ab9afff3274371dfeb60d95bd4ffb3fd5d9..9177f5ae96a596077080a79bb5a581f9a8a112cf 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.mapping
 
 import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8d78dddcdb0e87acb6e06cb32e7f3405e426a2b1
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt
@@ -0,0 +1,42 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import androidx.annotation.VisibleForTesting
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppFeaturesOuterClass
+import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class CWAConfigMapper @Inject constructor() : CWAConfig.Mapper {
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): CWAConfig {
+        return CWAConfigContainer(
+            appVersion = rawConfig.appVersion,
+            supportedCountries = rawConfig.getMappedSupportedCountries(),
+            appFeatureus = rawConfig.appFeatures
+        )
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal fun AppConfig.ApplicationConfiguration.getMappedSupportedCountries(): List<String> =
+        when {
+            supportedCountriesList == null -> emptyList()
+            supportedCountriesList.size == 1 && !VALID_CC.matches(supportedCountriesList.single()) -> {
+                Timber.w("Invalid country data, clearing. (%s)", supportedCountriesList)
+                emptyList()
+            }
+            else -> supportedCountriesList
+        }
+
+    data class CWAConfigContainer(
+        override val appVersion: AppVersionConfig.ApplicationVersionConfiguration,
+        override val supportedCountries: List<String>,
+        override val appFeatureus: AppFeaturesOuterClass.AppFeatures
+    ) : CWAConfig
+
+    companion object {
+        private val VALID_CC = "^([A-Z]{2,3})$".toRegex()
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..58c4b88b2f7beaff9765715e7c1ed54a4916f199
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt
@@ -0,0 +1,7 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+
+interface ConfigMapper<T> {
+    fun map(rawConfig: AppConfig.ApplicationConfiguration): T
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9858ec812bfc0b74c37330b2d44704decd085a5d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt
@@ -0,0 +1,17 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+
+interface ConfigMapping :
+    CWAConfig,
+    KeyDownloadConfig,
+    ExposureDetectionConfig,
+    RiskCalculationConfig {
+
+    @Deprecated("Try to access a more specific config type, avoid the RAW variant.")
+    val rawConfig: AppConfig.ApplicationConfiguration
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8449b81b7cf3c5e996dc8121f97de585bc0ef693
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt
@@ -0,0 +1,39 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class ConfigParser @Inject constructor(
+    private val cwaConfigMapper: CWAConfig.Mapper,
+    private val keyDownloadConfigMapper: KeyDownloadConfig.Mapper,
+    private val exposureDetectionConfigMapper: ExposureDetectionConfig.Mapper,
+    private val riskCalculationConfigMapper: RiskCalculationConfig.Mapper
+) {
+
+    fun parse(configBytes: ByteArray): ConfigMapping = try {
+        parseRawArray(configBytes).let {
+            DefaultConfigMapping(
+                rawConfig = it,
+                cwaConfig = cwaConfigMapper.map(it),
+                keyDownloadConfig = keyDownloadConfigMapper.map(it),
+                exposureDetectionConfig = exposureDetectionConfigMapper.map(it),
+                riskCalculationConfig = riskCalculationConfigMapper.map(it)
+            )
+        }
+    } catch (e: Exception) {
+        Timber.w(e, "Failed to parse AppConfig: %s", configBytes)
+        throw e
+    }
+
+    private fun parseRawArray(configBytes: ByteArray): AppConfig.ApplicationConfiguration {
+        Timber.v("Parsing config (size=%dB)", configBytes.size)
+        return AppConfig.ApplicationConfiguration.parseFrom(configBytes)
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt
new file mode 100644
index 0000000000000000000000000000000000000000..783385ddfdd3e7c06198eaff8c0ef5ba5a2961ff
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt
@@ -0,0 +1,19 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+
+data class DefaultConfigMapping(
+    override val rawConfig: AppConfig.ApplicationConfiguration,
+    val cwaConfig: CWAConfig,
+    val keyDownloadConfig: KeyDownloadConfig,
+    val exposureDetectionConfig: ExposureDetectionConfig,
+    val riskCalculationConfig: RiskCalculationConfig
+) : ConfigMapping,
+    CWAConfig by cwaConfig,
+    KeyDownloadConfig by keyDownloadConfig,
+    ExposureDetectionConfig by exposureDetectionConfig,
+    RiskCalculationConfig by riskCalculationConfig
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
new file mode 100644
index 0000000000000000000000000000000000000000..752f41cb176c35ca3ba562185dcf172548e098d1
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt
@@ -0,0 +1,21 @@
+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
new file mode 100644
index 0000000000000000000000000000000000000000..c010e25af42a0d9b70693ae7522488c33d5bcc4c
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt
@@ -0,0 +1,74 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import androidx.annotation.VisibleForTesting
+import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+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 javax.inject.Inject
+
+@Reusable
+class ExposureDetectionConfigMapper @Inject constructor() : ExposureDetectionConfig.Mapper {
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): ExposureDetectionConfig =
+        ExposureDetectionConfigContainer(
+            exposureDetectionConfiguration = rawConfig.mapRiskScoreToExposureConfiguration(),
+            exposureDetectionParameters = rawConfig.androidExposureDetectionParameters
+        )
+
+    data class ExposureDetectionConfigContainer(
+        override val exposureDetectionConfiguration: ExposureConfiguration,
+        override val exposureDetectionParameters: ExposureDetectionParametersAndroid
+    ) : ExposureDetectionConfig
+}
+
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+fun AppConfig.ApplicationConfiguration.mapRiskScoreToExposureConfiguration(): ExposureConfiguration =
+    ExposureConfiguration
+        .ExposureConfigurationBuilder()
+        .setTransmissionRiskScores(
+            this.exposureConfig.transmission.appDefined1Value,
+            this.exposureConfig.transmission.appDefined2Value,
+            this.exposureConfig.transmission.appDefined3Value,
+            this.exposureConfig.transmission.appDefined4Value,
+            this.exposureConfig.transmission.appDefined5Value,
+            this.exposureConfig.transmission.appDefined6Value,
+            this.exposureConfig.transmission.appDefined7Value,
+            this.exposureConfig.transmission.appDefined8Value
+        )
+        .setDurationScores(
+            this.exposureConfig.duration.eq0MinValue,
+            this.exposureConfig.duration.gt0Le5MinValue,
+            this.exposureConfig.duration.gt5Le10MinValue,
+            this.exposureConfig.duration.gt10Le15MinValue,
+            this.exposureConfig.duration.gt15Le20MinValue,
+            this.exposureConfig.duration.gt20Le25MinValue,
+            this.exposureConfig.duration.gt25Le30MinValue,
+            this.exposureConfig.duration.gt30MinValue
+        )
+        .setDaysSinceLastExposureScores(
+            this.exposureConfig.daysSinceLastExposure.ge14DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge12Lt14DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge10Lt12DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge8Lt10DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge6Lt8DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge4Lt6DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge2Lt4DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge0Lt2DaysValue
+        )
+        .setAttenuationScores(
+            this.exposureConfig.attenuation.gt73DbmValue,
+            this.exposureConfig.attenuation.gt63Le73DbmValue,
+            this.exposureConfig.attenuation.gt51Le63DbmValue,
+            this.exposureConfig.attenuation.gt33Le51DbmValue,
+            this.exposureConfig.attenuation.gt27Le33DbmValue,
+            this.exposureConfig.attenuation.gt15Le27DbmValue,
+            this.exposureConfig.attenuation.gt10Le15DbmValue,
+            this.exposureConfig.attenuation.le10DbmValue
+        )
+        .setMinimumRiskScore(this.minRiskScore)
+        .setDurationAtAttenuationThresholds(
+            this.attenuationDuration.thresholds.lower,
+            this.attenuationDuration.thresholds.upper
+        )
+        .build()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..dd36d4ea99f8f56af32e83fee682d90cc164460f
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt
@@ -0,0 +1,26 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass
+import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass
+import javax.inject.Inject
+
+@Reusable
+class RiskCalculationConfigMapper @Inject constructor() : RiskCalculationConfig.Mapper {
+
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): RiskCalculationConfig {
+        return RiskCalculationContainer(
+            minRiskScore = rawConfig.minRiskScore,
+            riskScoreClasses = rawConfig.riskScoreClasses,
+            attenuationDuration = rawConfig.attenuationDuration
+        )
+    }
+
+    data class RiskCalculationContainer(
+        override val minRiskScore: Int,
+        override val attenuationDuration: AttenuationDurationOuterClass.AttenuationDuration,
+        override val riskScoreClasses: RiskScoreClassificationOuterClass.RiskScoreClassification
+    ) : RiskCalculationConfig
+}
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/calculationtracker/CalculationTrackerStorage.kt
index 1aaf4d63b9fe1806db9ec52b6c7760786fe27017..18fb150e31c1164039079bf0130f5037bdd2c533 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/calculationtracker/CalculationTrackerStorage.kt
@@ -3,11 +3,13 @@ package de.rki.coronawarnapp.nearby.modules.calculationtracker
 import android.content.Context
 import com.google.gson.Gson
 import de.rki.coronawarnapp.util.di.AppContext
-import de.rki.coronawarnapp.util.gson.fromJson
-import de.rki.coronawarnapp.util.gson.toJson
 import de.rki.coronawarnapp.util.serialization.BaseGson
+import de.rki.coronawarnapp.util.serialization.fromJson
+import de.rki.coronawarnapp.util.serialization.getDefaultGsonTypeAdapter
+import de.rki.coronawarnapp.util.serialization.toJson
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import org.joda.time.Instant
 import timber.log.Timber
 import java.io.File
 import javax.inject.Inject
@@ -16,8 +18,14 @@ import javax.inject.Singleton
 @Singleton
 class CalculationTrackerStorage @Inject constructor(
     @AppContext private val context: Context,
-    @BaseGson private val gson: Gson
+    @BaseGson gson: Gson
 ) {
+    private val gson by lazy {
+        gson.newBuilder()
+            .registerTypeAdapter(Instant::class.java, Instant::class.getDefaultGsonTypeAdapter())
+            .create()
+    }
+
     private val mutex = Mutex()
     private val storageDir by lazy {
         File(context.filesDir, "calcuation_tracker").apply {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt
deleted file mode 100644
index 16ac02b7963ed29b9d0e3239d480d891abcfaa39..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package de.rki.coronawarnapp.service.applicationconfiguration
-
-import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
-import de.rki.coronawarnapp.util.di.AppInjector
-
-object ApplicationConfigurationService {
-    suspend fun asyncRetrieveApplicationConfiguration(): ApplicationConfiguration {
-        return AppInjector.component.appConfigProvider.getAppConfig()
-    }
-
-    suspend fun asyncRetrieveExposureConfiguration(): ExposureConfiguration =
-        asyncRetrieveApplicationConfiguration()
-            .mapRiskScoreToExposureConfiguration()
-
-    private fun ApplicationConfiguration.mapRiskScoreToExposureConfiguration(): ExposureConfiguration =
-        ExposureConfiguration
-            .ExposureConfigurationBuilder()
-            .setTransmissionRiskScores(
-                this.exposureConfig.transmission.appDefined1Value,
-                this.exposureConfig.transmission.appDefined2Value,
-                this.exposureConfig.transmission.appDefined3Value,
-                this.exposureConfig.transmission.appDefined4Value,
-                this.exposureConfig.transmission.appDefined5Value,
-                this.exposureConfig.transmission.appDefined6Value,
-                this.exposureConfig.transmission.appDefined7Value,
-                this.exposureConfig.transmission.appDefined8Value
-            )
-            .setDurationScores(
-                this.exposureConfig.duration.eq0MinValue,
-                this.exposureConfig.duration.gt0Le5MinValue,
-                this.exposureConfig.duration.gt5Le10MinValue,
-                this.exposureConfig.duration.gt10Le15MinValue,
-                this.exposureConfig.duration.gt15Le20MinValue,
-                this.exposureConfig.duration.gt20Le25MinValue,
-                this.exposureConfig.duration.gt25Le30MinValue,
-                this.exposureConfig.duration.gt30MinValue
-            )
-            .setDaysSinceLastExposureScores(
-                this.exposureConfig.daysSinceLastExposure.ge14DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge12Lt14DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge10Lt12DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge8Lt10DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge6Lt8DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge4Lt6DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge2Lt4DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge0Lt2DaysValue
-            )
-            .setAttenuationScores(
-                this.exposureConfig.attenuation.gt73DbmValue,
-                this.exposureConfig.attenuation.gt63Le73DbmValue,
-                this.exposureConfig.attenuation.gt51Le63DbmValue,
-                this.exposureConfig.attenuation.gt33Le51DbmValue,
-                this.exposureConfig.attenuation.gt27Le33DbmValue,
-                this.exposureConfig.attenuation.gt15Le27DbmValue,
-                this.exposureConfig.attenuation.gt10Le15DbmValue,
-                this.exposureConfig.attenuation.le10DbmValue
-            )
-            .setMinimumRiskScore(this.minRiskScore)
-            .setDurationAtAttenuationThresholds(
-                this.attenuationDuration.thresholds.lower,
-                this.attenuationDuration.thresholds.upper
-            )
-            .build()
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt
index b29b2469819cf00badd319ae1a24ef7c3a6fafbf..f14a53f5ff4656b76205d9e1e847d695e4cf3e76 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt
@@ -40,7 +40,7 @@ class InteroperabilityRepository @Inject constructor(
         runBlocking {
             try {
                 val countries = appConfigProvider.getAppConfig()
-                    .supportedCountriesList
+                    .supportedCountries
                     .mapNotNull { rawCode ->
                         val countryCode = rawCode.toLowerCase(Locale.ROOT)
 
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 8c1bb01b5ac4ff83b96705428fdf5b2c508c9213..7b605f10eaed4cc0b218eb2819301e931d15c7a3 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
@@ -2,10 +2,8 @@ package de.rki.coronawarnapp.submission
 
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
-import de.rki.coronawarnapp.appconfig.toNewConfig
 import de.rki.coronawarnapp.playbook.Playbook
 import de.rki.coronawarnapp.server.protocols.external.exposurenotification.TemporaryExposureKeyExportOuterClass
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig
 import de.rki.coronawarnapp.service.submission.SubmissionService
 import de.rki.coronawarnapp.task.Task
 import de.rki.coronawarnapp.task.TaskCancellationException
@@ -31,19 +29,20 @@ class SubmissionTask @Inject constructor(
     private var isCanceled = false
 
     override suspend fun run(arguments: Task.Arguments) = try {
-        arguments as Arguments
         Timber.d("Running with arguments=%s", arguments)
+        arguments as Arguments
+
         Playbook.SubmissionData(
             arguments.registrationToken,
             arguments.getHistory(),
             true,
-            applicationConfiguration().supportedCountriesList.also {
-                Timber.w("supported countries = $it")
-            }
+            getSupportedCountries()
         )
             .also { checkCancel() }
             .let { playbook.submit(it) }
+
         SubmissionService.submissionSuccessful()
+
         object : Task.Result {}
     } catch (error: Exception) {
         Timber.tag(TAG).e(error)
@@ -59,17 +58,15 @@ class SubmissionTask @Inject constructor(
             symptoms
         )
 
-    private suspend fun applicationConfiguration(): AppConfig.ApplicationConfiguration {
-        var result = appConfigProvider.getAppConfig()
-
-        if (result.supportedCountriesList.isEmpty()) {
-            result = result.toNewConfig {
-                addSupportedCountries(FALLBACK_COUNTRY)
+    private suspend fun getSupportedCountries(): List<String> {
+        val countries = appConfigProvider.getAppConfig().supportedCountries
+        return when {
+            countries.isEmpty() -> {
+                Timber.w("Country list was empty, corrected")
+                listOf(FALLBACK_COUNTRY)
             }
-            Timber.w("Country list was empty, corrected")
-        }
-
-        return result
+            else -> countries
+        }.also { Timber.i("Supported countries = $it") }
     }
 
     private fun checkCancel() {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
index 036905b788a065b9b14684da8811ad8b42bd32f4..493bb0c9e41d74b398d64b5c83012a72da296d5f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
@@ -26,7 +26,6 @@ import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.environment.EnvironmentSetup
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.API_SUBMISSION
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.CLOSE
@@ -195,11 +194,10 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
         val countries = if (environmentSetup.useEuropeKeyPackageFiles) {
             listOf("EUR")
         } else {
-            requestedCountries ?: ApplicationConfigurationService
-                .asyncRetrieveApplicationConfiguration()
-                .supportedCountriesList
+            requestedCountries
+                ?: AppInjector.component.appConfigProvider.getAppConfig().supportedCountries
         }
-            invokeSubmissionStartedInDebugOrBuildMode()
+        invokeSubmissionStartedInDebugOrBuildMode()
 
         val availableKeyFiles = executeFetchKeyFilesFromServer(countries)
 
@@ -295,7 +293,7 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
      */
     private suspend fun executeRetrieveRiskScoreParams() =
         executeState(RETRIEVE_RISK_SCORE_PARAMS) {
-            ApplicationConfigurationService.asyncRetrieveExposureConfiguration()
+            AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration
         }
 
     /**
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt
index b203364fc37e060d4a8d6a56533f4818e0c1e06b..42dfb2f60231fc8461b4b164d8d14bad74a37160 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt
@@ -6,10 +6,11 @@ import androidx.appcompat.app.AlertDialog
 import androidx.core.content.ContextCompat.startActivity
 import de.rki.coronawarnapp.BuildConfig
 import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.appconfig.ApplicationConfigurationCorruptException
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.download.ApplicationConfigurationCorruptException
 import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig.SemanticVersion
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.ui.LauncherActivity
+import de.rki.coronawarnapp.util.di.AppInjector
 import timber.log.Timber
 
 class UpdateChecker(private val activity: LauncherActivity) {
@@ -66,10 +67,9 @@ class UpdateChecker(private val activity: LauncherActivity) {
     }
 
     private suspend fun checkIfUpdatesNeededFromServer(): Boolean {
-        val applicationConfigurationFromServer =
-            ApplicationConfigurationService.asyncRetrieveApplicationConfiguration()
+        val cwaAppConfig: CWAConfig = AppInjector.component.appConfigProvider.getAppConfig()
 
-        val minVersionFromServer = applicationConfigurationFromServer.appVersion.android.min
+        val minVersionFromServer = cwaAppConfig.appVersion.android.min
         val minVersionFromServerString =
             constructSemanticVersionString(minVersionFromServer)
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt
index b99b9f2ccd8768f361e697298f185bb96511e5dc..97839c1c3b2f9e10205070f7cfbbb5747dae35da 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt
@@ -6,21 +6,29 @@ import java.util.Locale
 
 internal object HashExtensions {
 
+    fun ByteArray.toSHA256() = this.hashByteArray("SHA-256")
+
+    fun ByteArray.toSHA1() = this.hashByteArray("SHA-1")
+
+    fun ByteArray.toMD5() = this.hashByteArray("MD5")
+
     fun String.toSHA256() = this.hashString("SHA-256")
 
     fun String.toSHA1() = this.hashString("SHA-1")
 
     fun String.toMD5() = this.hashString("MD5")
 
-    private fun ByteArray.formatHash(): String = this
-        .joinToString(separator = "") { String.format("%02X", it) }
-        .toLowerCase(Locale.ROOT)
+    private fun String.hashString(type: String): String = toByteArray().hashByteArray(type)
 
-    private fun String.hashString(type: String): String = MessageDigest
+    private fun ByteArray.hashByteArray(type: String): String = MessageDigest
         .getInstance(type)
-        .digest(this.toByteArray())
+        .digest(this)
         .formatHash()
 
+    private fun ByteArray.formatHash(): String = this
+        .joinToString(separator = "") { String.format("%02X", it) }
+        .toLowerCase(Locale.ROOT)
+
     fun File.hashToMD5(): String = this.hashTo("MD5")
 
     private fun File.hashTo(type: String): String = MessageDigest
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt
index b296cced9c324d4a7499b2512b716e069a38abc0..14a58b2a1ec43f8a4f14d49c6570904e2f7465ad 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt
@@ -73,15 +73,21 @@ object ZipHelper {
         zipOutputStream.closeEntry()
     }
 
-    fun InputStream.unzip(callback: (entry: ZipEntry, entryContent: ByteArray) -> Any) =
-        ZipInputStream(this).use {
+    fun InputStream.unzip(): Sequence<Pair<ZipEntry, InputStream>> = sequence {
+        ZipInputStream(this@unzip).use {
             do {
                 val entry = it.nextEntry
                 if (entry != null) {
-                    Timber.v("read zip entry ${entry.name}")
-                    callback(entry, it.readBytes())
+                    Timber.v("Reading zip entry ${entry.name}")
+                    yield(entry to it)
                     it.closeEntry()
                 }
             } while (entry != null)
         }
+    }
+
+    fun Sequence<Pair<ZipEntry, InputStream>>.readIntoMap() =
+        fold(emptyMap()) { last: Map<String, ByteArray>, (entry, stream) ->
+            last.plus(entry.name to stream.readBytes())
+        }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt
index 9e5ce2d20f6407093dc93fcd451926eeed9a6813..e6a64015278bb9ab2a0067a626f4b433860ca64e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt
@@ -23,7 +23,7 @@ import androidx.room.TypeConverter
 import com.google.gson.Gson
 import com.google.gson.reflect.TypeToken
 import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import de.rki.coronawarnapp.util.gson.fromJson
+import de.rki.coronawarnapp.util.serialization.fromJson
 import org.joda.time.Instant
 import org.joda.time.LocalDate
 import org.joda.time.LocalTime
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
index 359c2e948ed6215083176ba02a1635aa7bbdf50c..c3d6d5787f6c991b9a8498e6cc3b3ede70afa522 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
@@ -1,7 +1,6 @@
 package de.rki.coronawarnapp.util.flow
 
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -10,7 +9,8 @@ import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.channelFlow
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.shareIn
@@ -21,8 +21,9 @@ import kotlin.coroutines.CoroutineContext
 class HotDataFlow<T : Any>(
     loggingTag: String,
     scope: CoroutineScope,
-    coroutineContext: CoroutineContext = Dispatchers.Default,
+    coroutineContext: CoroutineContext = scope.coroutineContext,
     sharingBehavior: SharingStarted = SharingStarted.WhileSubscribed(),
+    forwardException: Boolean = true,
     private val startValueProvider: suspend CoroutineScope.() -> T
 ) {
     private val tag = "$loggingTag:HD"
@@ -37,34 +38,71 @@ class HotDataFlow<T : Any>(
         onBufferOverflow = BufferOverflow.SUSPEND
     )
 
-    private val internalFlow = channelFlow {
+    private val internalProducer: Flow<Holder<T>> = channelFlow {
         var currentValue = startValueProvider().also {
             Timber.tag(tag).v("startValue=%s", it)
-            send(it)
+            val updatedBy: suspend T.() -> T = { it }
+            send(Holder.Data(value = it, updatedBy = updatedBy))
         }
 
         updateActions.collect { updateAction ->
             currentValue = updateAction(currentValue).also {
                 currentValue = it
-                send(it)
+                send(Holder.Data(value = it, updatedBy = updateAction))
             }
         }
     }
 
-    val data: Flow<T> = internalFlow
-        .distinctUntilChanged()
-        .onStart { Timber.tag(tag).v("internal onStart") }
+    private val internalFlow = internalProducer
+        .onStart { Timber.tag(tag).v("Internal onStart") }
         .catch {
-            Timber.tag(tag).e(it, "internal Error")
-            throw it
+            if (forwardException) {
+                Timber.tag(tag).w(it, "Forwarding internal Error")
+                // Wrap the error to get it past `sharedIn`
+                emit(Holder.Error(error = it))
+            } else {
+                Timber.tag(tag).e(it, "Throwing internal Error")
+                throw it
+            }
         }
-        .onCompletion { Timber.tag(tag).v("internal onCompletion") }
+        .onCompletion { Timber.tag(tag).v("Internal onCompletion") }
         .shareIn(
             scope = scope + coroutineContext,
             replay = 1,
             started = sharingBehavior
         )
-        .mapNotNull { it }
+        .map {
+            when (it) {
+                is Holder.Data<T> -> it
+                is Holder.Error<T> -> throw it.error
+            }
+        }
+
+    val data: Flow<T> = internalFlow.map { it.value }.distinctUntilChanged()
 
     fun updateSafely(update: suspend T.() -> T) = updateActions.tryEmit(update)
+
+    suspend fun updateBlocking(update: suspend T.() -> T): T {
+        updateActions.tryEmit(update)
+        Timber.tag(tag).v("Waiting for update.")
+        return internalFlow.first {
+            val targetUpdate = it.updatedBy
+            Timber.tag(tag).v(
+                "Comparing %s with %s; match=%b",
+                targetUpdate, update, targetUpdate == update
+            )
+            it.updatedBy == update
+        }.value.also { Timber.tag(tag).v("Returning blocking update result: %s", it) }
+    }
+
+    internal sealed class Holder<T> {
+        data class Data<T>(
+            val value: T,
+            val updatedBy: suspend T.() -> T
+        ) : Holder<T>()
+
+        data class Error<T>(
+            val error: Throwable
+        ) : Holder<T>()
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt
index e6df66e5b7996381dda133b616e34eb87f5b13c5..1ba8319fe9aae755a9d33947fc8744baca812e7a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt
@@ -25,8 +25,8 @@ class VerificationKeys @Inject constructor(
         Signature.getInstance(SecurityConstants.EXPORT_FILE_SIGNATURE_VERIFICATION_ALGORITHM)
 
     fun hasInvalidSignature(
-        export: ByteArray?,
-        signatureListBinary: ByteArray?
+        export: ByteArray,
+        signatureListBinary: ByteArray
     ): Boolean = SecurityHelper.withSecurityCatch {
         signature.getValidSignaturesForExport(export, signatureListBinary)
             .isEmpty()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt
similarity index 67%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt
index b79e725c5d935b80971ac86bf3ff39505eb5267a..601f833c3ded2777122e62d6814e07edff3380a3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt
@@ -1,8 +1,10 @@
-package de.rki.coronawarnapp.util.gson
+package de.rki.coronawarnapp.util.serialization
 
 import com.google.gson.Gson
+import com.google.gson.TypeAdapter
 import com.google.gson.reflect.TypeToken
 import java.io.File
+import kotlin.reflect.KClass
 
 inline fun <reified T> Gson.fromJson(json: String): T = fromJson(
     json,
@@ -16,3 +18,5 @@ inline fun <reified T> Gson.fromJson(file: File): T = file.reader().use {
 inline fun <reified T> Gson.toJson(data: T, file: File) = file.writer().use { writer ->
     toJson(data, writer)
 }
+
+fun <T : Any> KClass<T>.getDefaultGsonTypeAdapter(): TypeAdapter<T> = Gson().getAdapter(this.java)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
index e638c3811aad327869e5c7b6ba50deb0fefe409d..c2c33f43b43745b32465362577c0a0bffae874b9 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
@@ -5,6 +5,11 @@ import com.google.gson.GsonBuilder
 import dagger.Module
 import dagger.Provides
 import dagger.Reusable
+import de.rki.coronawarnapp.util.serialization.adapter.ByteArrayAdapter
+import de.rki.coronawarnapp.util.serialization.adapter.DurationAdapter
+import de.rki.coronawarnapp.util.serialization.adapter.InstantAdapter
+import org.joda.time.Duration
+import org.joda.time.Instant
 
 @Module
 class SerializationModule {
@@ -12,5 +17,9 @@ class SerializationModule {
     @BaseGson
     @Reusable
     @Provides
-    fun baseGson(): Gson = GsonBuilder().create()
+    fun baseGson(): Gson = GsonBuilder()
+        .registerTypeAdapter(Instant::class.java, InstantAdapter())
+        .registerTypeAdapter(Duration::class.java, DurationAdapter())
+        .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter())
+        .create()
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..10388e0c471d1324b63d868325e2e4c8a3d3f0c9
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt
@@ -0,0 +1,24 @@
+package de.rki.coronawarnapp.util.serialization.adapter
+
+import com.google.gson.JsonParseException
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonWriter
+import okio.ByteString.Companion.decodeBase64
+import okio.ByteString.Companion.toByteString
+import org.json.JSONObject.NULL
+
+class ByteArrayAdapter : TypeAdapter<ByteArray>() {
+    override fun write(out: JsonWriter, value: ByteArray?) {
+        if (value == null) out.nullValue()
+        else value.toByteString().base64().let { out.value(it) }
+    }
+
+    override fun read(reader: JsonReader): ByteArray? = when (reader.peek()) {
+        NULL -> reader.nextNull().let { null }
+        else -> {
+            val raw = reader.nextString()
+            raw.decodeBase64()?.toByteArray() ?: throw JsonParseException("Can't decode base64 ByteArray: $raw")
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0d194661ea3c1cbc85717aca1fc25ddb7d003a96
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt
@@ -0,0 +1,27 @@
+package de.rki.coronawarnapp.util.serialization.adapter
+
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonWriter
+import org.joda.time.Duration
+import org.json.JSONObject
+
+class DurationAdapter : TypeAdapter<Duration>() {
+    override fun write(out: JsonWriter, value: Duration?) {
+        if (value == null) {
+            out.nullValue()
+        } else {
+            out.value(value.millis)
+        }
+    }
+
+    override fun read(reader: JsonReader): Duration? = when (reader.peek()) {
+        JSONObject.NULL -> {
+            reader.nextNull()
+            null
+        }
+        else -> {
+            Duration.millis(reader.nextLong())
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..99ba6e6e18285126ddf9bf69c43ab57a8ae641c7
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt
@@ -0,0 +1,27 @@
+package de.rki.coronawarnapp.util.serialization.adapter
+
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonWriter
+import org.joda.time.Instant
+import org.json.JSONObject
+
+class InstantAdapter : TypeAdapter<Instant>() {
+    override fun write(out: JsonWriter, value: Instant?) {
+        if (value == null) {
+            out.nullValue()
+        } else {
+            out.value(value.millis)
+        }
+    }
+
+    override fun read(reader: JsonReader): Instant? = when (reader.peek()) {
+        JSONObject.NULL -> {
+            reader.nextNull()
+            null
+        }
+        else -> {
+            Instant.ofEpochMilli(reader.nextLong())
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
index 972136da0c94f4ce8b65306cbfec4c9102c445d9..00fcd329fc1e5456231e032cdfe1788531ba5f6f 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
@@ -1,37 +1,37 @@
 package de.rki.coronawarnapp.appconfig
 
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import de.rki.coronawarnapp.util.security.VerificationKeys
-import io.kotest.assertions.throwables.shouldThrow
+import de.rki.coronawarnapp.util.TimeStamper
 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.coVerifySequence
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
-import io.mockk.mockk
-import io.mockk.verify
-import okhttp3.ResponseBody.Companion.toResponseBody
-import okio.ByteString.Companion.decodeHex
+import io.mockk.just
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import org.joda.time.Duration
+import org.joda.time.Instant
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 import testhelpers.BaseIOTest
+import testhelpers.TestDispatcherProvider
+import testhelpers.coroutines.runBlockingTest2
+import testhelpers.coroutines.test
 import java.io.File
-import java.io.IOException
 
 class AppConfigProviderTest : BaseIOTest() {
 
-    @MockK lateinit var api: AppConfigApiV1
-    @MockK lateinit var verificationKeys: VerificationKeys
-    @MockK lateinit var appConfigStorage: AppConfigStorage
+    @MockK lateinit var source: AppConfigSource
+    @MockK lateinit var configData: ConfigData
+    @MockK lateinit var timeStamper: TimeStamper
 
     private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
 
-    private val defaultHomeCountry = LocationCode("DE")
-
-    private var mockConfigStorage: ByteArray? = null
+    private lateinit var testConfigDownload: ConfigData
 
     @BeforeEach
     fun setup() {
@@ -39,9 +39,16 @@ class AppConfigProviderTest : BaseIOTest() {
         testDir.mkdirs()
         testDir.exists() shouldBe true
 
-        coEvery { appConfigStorage.isAppConfigAvailable() } answers { mockConfigStorage != null }
-        coEvery { appConfigStorage.getAppConfigRaw() } answers { mockConfigStorage }
-        coEvery { appConfigStorage.setAppConfigRaw(any()) } answers { mockConfigStorage = arg(0) }
+        testConfigDownload = DefaultConfigData(
+            serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
+            localOffset = Duration.ZERO,
+            mappedConfig = configData,
+            isFallback = false
+        )
+        coEvery { source.clear() } just Runs
+        coEvery { source.retrieveConfig() } returns testConfigDownload
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
     }
 
     @AfterEach
@@ -50,160 +57,53 @@ class AppConfigProviderTest : BaseIOTest() {
         testDir.deleteRecursively()
     }
 
-    private fun createDownloadServer(
-        homeCountry: LocationCode = defaultHomeCountry
-    ) = AppConfigProvider(
-        appConfigAPI = { api },
-        verificationKeys = verificationKeys,
-        homeCountry = homeCountry,
-        configStorage = appConfigStorage,
-        cache = mockk()
+    private fun createInstance(scope: CoroutineScope) = AppConfigProvider(
+        source = source,
+        dispatcherProvider = TestDispatcherProvider,
+        scope = scope
     )
 
     @Test
-    suspend fun `application config download`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        val downloadServer = createDownloadServer()
-
-        val rawConfig = downloadServer.downloadAppConfig()
-        rawConfig shouldBe APPCONFIG_RAW.toByteArray()
-
-        verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) }
-    }
-
-    @Test
-    suspend fun `application config data is faulty`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex()
-            .toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        val downloadServer = createDownloadServer()
+    fun `appConfig is observable`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance(this)
 
-        shouldThrow<ApplicationConfigurationInvalidException> {
-            downloadServer.downloadAppConfig()
-        }
-    }
+        val testCollector = instance.currentConfig.test(startOnScope = this)
 
-    @Test
-    suspend fun `application config verification fails`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
-            .toResponseBody()
+        instance.getAppConfig()
+        instance.clear()
+        instance.getAppConfig()
 
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
+        advanceUntilIdle()
 
-        val downloadServer = createDownloadServer()
+        testCollector.latestValues shouldBe listOf(
+            null,
+            testConfigDownload,
+            null,
+            testConfigDownload
+        )
 
-        shouldThrow<ApplicationConfigurationCorruptException> {
-            downloadServer.downloadAppConfig()
+        coVerifySequence {
+            source.retrieveConfig()
+            source.clear()
+            source.retrieveConfig()
         }
     }
 
     @Test
-    suspend fun `successful download stores new config`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
-            .toResponseBody()
+    fun `clear clears storage and current config`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance(this)
 
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
+        instance.getAppConfig() shouldBe testConfigDownload
+        instance.currentConfig.first() shouldBe testConfigDownload
 
-        val downloadServer = createDownloadServer()
-        downloadServer.getAppConfig()
+        instance.clear()
 
-        mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
-        coVerify { appConfigStorage.setAppConfigRaw(APPCONFIG_RAW.toByteArray()) }
-    }
-
-    @Test
-    suspend fun `failed download doesn't overwrite valid config`() {
-        mockConfigStorage = APPCONFIG_RAW.toByteArray()
-        coEvery { api.getApplicationConfiguration("DE") } throws IOException()
+        instance.currentConfig.first() shouldBe null
 
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        createDownloadServer().getAppConfig()
-
-        coVerify(exactly = 0) { appConfigStorage.setAppConfigRaw(any()) }
-        mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
-    }
-
-    @Test
-    suspend fun `failed verification doesn't overwrite valid config`() {
-        mockConfigStorage = APPCONFIG_RAW.toByteArray()
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
-            .toResponseBody()
+        coVerifySequence {
+            source.retrieveConfig()
 
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
-
-        createDownloadServer().getAppConfig()
-
-        coVerify(exactly = 0) { appConfigStorage.setAppConfigRaw(any()) }
-        mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
-    }
-
-    @Test
-    suspend fun `fallback to last config if verification fails`() {
-        mockConfigStorage = APPCONFIG_RAW.toByteArray()
-
-        coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex()
-            .toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } throws Exception()
-        createDownloadServer().getAppConfig().minRiskScore shouldBe 11
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
-        createDownloadServer().getAppConfig().minRiskScore shouldBe 11
-    }
-
-    @Test
-    suspend fun `fallback to last config if download fails`() {
-        mockConfigStorage = APPCONFIG_RAW.toByteArray()
-
-        coEvery { api.getApplicationConfiguration("DE") } throws Exception()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        createDownloadServer().getAppConfig().minRiskScore shouldBe 11
-    }
-
-    // Because the UI requires this to detect when to show alternative UI elements
-    @Test
-    suspend fun `if supportedCountryList is empty, we do not insert DE as fallback`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody()
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        createDownloadServer().getAppConfig().supportedCountriesList shouldBe emptyList()
-    }
-
-    companion object {
-        private val APPCONFIG_BUNDLE =
-            (
-                "504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" +
-                    "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" +
-                    "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" +
-                    "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" +
-                    "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" +
-                    "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" +
-                    "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" +
-                    "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" +
-                    "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" +
-                    "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" +
-                    "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" +
-                    "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" +
-                    "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" +
-                    "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" +
-                    "0200020070000000ae0100000000"
-                ).decodeHex()
-        private val APPCONFIG_RAW =
-            (
-                "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
-                    "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
-                    "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
-                    "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
-                    "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
-                    "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
-                ).decodeHex()
+            source.clear()
+        }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a4ba54e99f2a9ad6630597cbfcec4f3601a91021
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt
@@ -0,0 +1,144 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.download.AppConfigServer
+import de.rki.coronawarnapp.appconfig.download.AppConfigStorage
+import de.rki.coronawarnapp.appconfig.download.ConfigDownload
+import de.rki.coronawarnapp.appconfig.mapping.ConfigParser
+import de.rki.coronawarnapp.util.TimeStamper
+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.coVerifyOrder
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import okio.ByteString.Companion.decodeHex
+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.TestDispatcherProvider
+import testhelpers.coroutines.runBlockingTest2
+import java.io.File
+import java.io.IOException
+
+class AppConfigSourceTest : BaseIOTest() {
+
+    @MockK lateinit var configServer: AppConfigServer
+    @MockK lateinit var configStorage: AppConfigStorage
+    @MockK lateinit var configParser: ConfigParser
+    @MockK lateinit var configData: ConfigData
+    @MockK lateinit var timeStamper: TimeStamper
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+
+    private var testConfigDownload = ConfigDownload(
+        rawData = APPCONFIG_RAW,
+        serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
+        localOffset = Duration.standardHours(1)
+    )
+
+    private var mockConfigStorage: ConfigDownload? = null
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        coEvery { configStorage.getStoredConfig() } answers { mockConfigStorage }
+        coEvery { configStorage.setStoredConfig(any()) } answers {
+            mockConfigStorage = arg(0)
+        }
+
+        coEvery { configServer.downloadAppConfig() } returns testConfigDownload
+        every { configServer.clearCache() } just Runs
+
+        every { configParser.parse(APPCONFIG_RAW) } returns configData
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createInstance() = AppConfigSource(
+        server = configServer,
+        storage = configStorage,
+        parser = configParser,
+        dispatcherProvider = TestDispatcherProvider
+    )
+
+    @Test
+    fun `successful download stores new config`() = runBlockingTest2(ignoreActive = true) {
+        val source = createInstance()
+        source.retrieveConfig() shouldBe DefaultConfigData(
+            serverTime = mockConfigStorage!!.serverTime,
+            localOffset = mockConfigStorage!!.localOffset,
+            mappedConfig = configData,
+            isFallback = false
+        )
+
+        mockConfigStorage shouldBe testConfigDownload
+
+        coVerify { configStorage.setStoredConfig(testConfigDownload) }
+    }
+
+    @Test
+    fun `fallback to last config if download fails`() = runBlockingTest2(ignoreActive = true) {
+        mockConfigStorage = testConfigDownload
+        coEvery { configServer.downloadAppConfig() } throws Exception()
+
+        createInstance().retrieveConfig() shouldBe DefaultConfigData(
+            serverTime = mockConfigStorage!!.serverTime,
+            localOffset = mockConfigStorage!!.localOffset,
+            mappedConfig = configData,
+            isFallback = true
+        )
+    }
+
+    @Test
+    fun `failed download doesn't overwrite valid config`() = runBlockingTest2(ignoreActive = true) {
+        mockConfigStorage = testConfigDownload
+        coEvery { configServer.downloadAppConfig() } throws IOException()
+
+        createInstance().retrieveConfig()
+
+        mockConfigStorage shouldBe testConfigDownload
+
+        coVerify(exactly = 0) { configStorage.setStoredConfig(any()) }
+    }
+
+    @Test
+    fun `clear clears caches`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance()
+
+        instance.clear()
+
+        advanceUntilIdle()
+
+        coVerifyOrder {
+            configStorage.setStoredConfig(null)
+            configServer.clearCache()
+        }
+    }
+
+    companion object {
+        private val APPCONFIG_RAW = (
+            "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+                "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+                "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+                "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+                "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+                "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+            ).decodeHex().toByteArray()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt
deleted file mode 100644
index aea45f221b950b4b2ac503079adb1c0691004e64..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package de.rki.coronawarnapp.appconfig
-
-import android.content.Context
-import io.kotest.matchers.shouldBe
-import io.mockk.MockKAnnotations
-import io.mockk.clearAllMocks
-import io.mockk.every
-import io.mockk.impl.annotations.MockK
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
-import testhelpers.BaseIOTest
-import java.io.File
-
-class AppConfigStorageTest : BaseIOTest() {
-
-    @MockK private lateinit var context: Context
-
-    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
-    private val privateFiles = File(testDir, "files")
-    private val storageDir = File(privateFiles, "appconfig_storage")
-    private val configPath = File(storageDir, "appconfig")
-    private val testByteArray = "The Cake Is A Lie".toByteArray()
-
-    @BeforeEach
-    fun setup() {
-        MockKAnnotations.init(this)
-        every { context.filesDir } returns privateFiles
-    }
-
-    @AfterEach
-    fun teardown() {
-        clearAllMocks()
-        testDir.deleteRecursively()
-    }
-
-    private fun createStorage() = AppConfigStorage(context)
-
-    @Test
-    suspend fun `config availability is determined by file existence and min size`() {
-        storageDir.mkdirs()
-        val storage = createStorage()
-        storage.isAppConfigAvailable() shouldBe false
-        configPath.createNewFile()
-        storage.isAppConfigAvailable() shouldBe false
-
-        configPath.writeBytes(ByteArray(128) { 1 })
-        storage.isAppConfigAvailable() shouldBe false
-
-        configPath.writeBytes(ByteArray(129) { 1 })
-        storage.isAppConfigAvailable() shouldBe true
-    }
-
-    @Test
-    suspend fun `simple read and write config`() {
-        configPath.exists() shouldBe false
-        val storage = createStorage()
-        configPath.exists() shouldBe false
-
-        storage.setAppConfigRaw(testByteArray)
-
-        configPath.exists() shouldBe true
-        configPath.readBytes() shouldBe testByteArray
-
-        storage.getAppConfigRaw() shouldBe testByteArray
-    }
-
-    @Test
-    suspend fun `nulling and overwriting`() {
-        val storage = createStorage()
-        configPath.exists() shouldBe false
-
-        storage.getAppConfigRaw() shouldBe null
-        storage.setAppConfigRaw(null)
-        configPath.exists() shouldBe false
-
-        storage.getAppConfigRaw() shouldBe null
-        storage.setAppConfigRaw(testByteArray)
-        storage.getAppConfigRaw() shouldBe testByteArray
-        configPath.exists() shouldBe true
-        configPath.readBytes() shouldBe testByteArray
-
-        storage.setAppConfigRaw(null)
-        storage.getAppConfigRaw() shouldBe null
-        configPath.exists() shouldBe false
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt
similarity index 83%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt
index 671fd3ab13b4be98186c78f0e730204372e60efa..da1ab2f7d7e9dd50d4286a5f55827b76f55e2ad9 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt
@@ -1,6 +1,7 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import android.content.Context
+import de.rki.coronawarnapp.appconfig.AppConfigModule
 import de.rki.coronawarnapp.environment.download.DownloadCDNModule
 import de.rki.coronawarnapp.http.HttpModule
 import io.kotest.matchers.shouldBe
@@ -75,7 +76,9 @@ class AppConfigApiTest : BaseIOTest() {
         webServer.enqueue(MockResponse().setBody("~appconfig"))
 
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
 
         val request = webServer.takeRequest(5, TimeUnit.SECONDS)!!
@@ -94,7 +97,9 @@ class AppConfigApiTest : BaseIOTest() {
 
         webServer.enqueue(configResponse)
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
         cacheDir.exists() shouldBe true
         cacheDir.listFiles()!!.size shouldBe 3
@@ -106,7 +111,9 @@ class AppConfigApiTest : BaseIOTest() {
 
         webServer.enqueue(configResponse)
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
         cacheDir.exists() shouldBe true
         cacheDir.listFiles()!!.size shouldBe 3
@@ -117,7 +124,9 @@ class AppConfigApiTest : BaseIOTest() {
 
         webServer.enqueue(configResponse)
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
         cacheDir.exists() shouldBe true
         cacheDir.listFiles()!!.size shouldBe 3
@@ -139,7 +148,9 @@ class AppConfigApiTest : BaseIOTest() {
 
         webServer.enqueue(configResponse)
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
         cacheDir.exists() shouldBe true
         cacheDir.listFiles()!!.size shouldBe 3
@@ -152,7 +163,9 @@ class AppConfigApiTest : BaseIOTest() {
         webServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_DURING_REQUEST_BODY))
 
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt
similarity index 92%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt
index fdae6dd05d6b673b95c5b49a3a5edf39d76c9124..eae101cd8e6281f82ba2658056819e564473fbe0 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt
@@ -1,6 +1,7 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import android.content.Context
+import de.rki.coronawarnapp.appconfig.AppConfigModule
 import io.kotest.assertions.throwables.shouldNotThrowAny
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..eda32b98ef41d9a00ff05020c9e24f3551e5cbd8
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt
@@ -0,0 +1,199 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.security.VerificationKeys
+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.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.test.runBlockingTest
+import okhttp3.Headers
+import okhttp3.ResponseBody.Companion.toResponseBody
+import okio.ByteString.Companion.decodeHex
+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 retrofit2.Response
+import testhelpers.BaseIOTest
+import java.io.File
+
+class AppConfigServerTest : BaseIOTest() {
+
+    @MockK lateinit var api: AppConfigApiV1
+    @MockK lateinit var verificationKeys: VerificationKeys
+    @MockK lateinit var timeStamper: TimeStamper
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+
+    private val defaultHomeCountry = LocationCode("DE")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        every { timeStamper.nowUTC } returns Instant.ofEpochMilli(123456789)
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createInstance(homeCountry: LocationCode = defaultHomeCountry) = AppConfigServer(
+        api = { api },
+        verificationKeys = verificationKeys,
+        homeCountry = homeCountry,
+        cache = mockk(),
+        timeStamper = timeStamper
+    )
+
+    @Test
+    fun `application config download`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody(),
+            Headers.headersOf("Date", "Tue, 03 Nov 2020 08:46:03 GMT")
+        )
+
+        val downloadServer = createInstance()
+
+        val configDownload = downloadServer.downloadAppConfig()
+        configDownload shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.parse("2020-11-03T08:46:03.000Z"),
+            localOffset = Duration(
+                Instant.parse("2020-11-03T08:46:03.000Z"),
+                Instant.ofEpochMilli(123456789)
+            )
+        )
+
+        verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) }
+    }
+
+    @Test
+    fun `application config data is faulty`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            "123ABC".decodeHex().toResponseBody()
+        )
+
+        val downloadServer = createInstance()
+
+        shouldThrow<ApplicationConfigurationInvalidException> {
+            downloadServer.downloadAppConfig()
+        }
+    }
+
+    @Test
+    fun `application config verification fails`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody()
+        )
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
+
+        val downloadServer = createInstance()
+
+        shouldThrow<ApplicationConfigurationCorruptException> {
+            downloadServer.downloadAppConfig()
+        }
+    }
+
+    @Test
+    fun `missing server date leads to local time fallback`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody()
+        )
+
+        val downloadServer = createInstance()
+
+        val configDownload = downloadServer.downloadAppConfig()
+        configDownload shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.ofEpochMilli(123456789),
+            localOffset = Duration.ZERO
+        )
+    }
+
+    @Test
+    fun `local offset is the difference between server time and local time`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody(),
+            Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT")
+        )
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
+
+        val downloadServer = createInstance()
+
+        downloadServer.downloadAppConfig() shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.parse("2020-11-03T06:35:16.000Z"),
+            localOffset = Duration.standardHours(-1)
+        )
+    }
+
+    @Test
+    fun `local offset uses cached timestamps for cached responses`() = runBlockingTest {
+        val response = spyk(
+            Response.success(
+                APPCONFIG_BUNDLE.toResponseBody(),
+                Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT")
+            )
+        )
+
+        val mockCacheResponse = mockk<okhttp3.Response>()
+        // The cached one is 2 hours before our local time, so the offset will be -2 hours
+        every { mockCacheResponse.sentRequestAtMillis } returns Instant.parse("2020-11-03T04:35:16.000Z").millis
+        every { response.raw().cacheResponse } returns mockCacheResponse
+
+        coEvery { api.getApplicationConfiguration("DE") } returns response
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
+
+        val downloadServer = createInstance()
+
+        downloadServer.downloadAppConfig() shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.parse("2020-11-03T06:35:16.000Z"),
+            localOffset = Duration.standardHours(-2)
+        )
+    }
+
+    companion object {
+        private val APPCONFIG_BUNDLE =
+            (
+                "504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" +
+                    "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" +
+                    "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" +
+                    "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" +
+                    "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" +
+                    "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" +
+                    "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" +
+                    "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" +
+                    "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" +
+                    "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" +
+                    "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" +
+                    "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" +
+                    "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" +
+                    "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" +
+                    "0200020070000000ae0100000000"
+                ).decodeHex()
+        private val APPCONFIG_RAW =
+            (
+                "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+                    "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+                    "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+                    "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+                    "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+                    "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+                ).decodeHex().toByteArray()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fa15cab904896ee99211904a97fbf2411fbe4621
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt
@@ -0,0 +1,149 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import android.content.Context
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.serialization.SerializationModule
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.test.runBlockingTest
+import okio.ByteString.Companion.decodeHex
+import okio.ByteString.Companion.toByteString
+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.extensions.toComparableJson
+import java.io.File
+
+class AppConfigStorageTest : BaseIOTest() {
+
+    @MockK private lateinit var context: Context
+    @MockK private lateinit var timeStamper: TimeStamper
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val privateFiles = File(testDir, "files")
+    private val storageDir = File(privateFiles, "appconfig_storage")
+    private val legacyConfigPath = File(storageDir, "appconfig")
+    private val configPath = File(storageDir, "appconfig.json")
+
+    private val testConfigDownload = ConfigDownload(
+        rawData = APPCONFIG_RAW,
+        serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
+        localOffset = Duration.standardHours(1)
+    )
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { context.filesDir } returns privateFiles
+
+        every { timeStamper.nowUTC } returns Instant.ofEpochMilli(1234)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createStorage() = AppConfigStorage(
+        context = context,
+        timeStamper = timeStamper,
+        baseGson = SerializationModule().baseGson()
+    )
+
+    @Test
+    fun `simple read and write config`() = runBlockingTest {
+        configPath.exists() shouldBe false
+        val storage = createStorage()
+        configPath.exists() shouldBe false
+
+        storage.setStoredConfig(testConfigDownload)
+
+        configPath.exists() shouldBe true
+        configPath.readText().toComparableJson() shouldBe """
+            {
+                "rawData": "$APPCONFIG_BASE64",
+                "serverTime": 1604381716000,
+                "localOffset": 3600000
+            }
+        """.toComparableJson()
+
+        storage.getStoredConfig() shouldBe testConfigDownload
+    }
+
+    @Test
+    fun `nulling and overwriting`() = runBlockingTest {
+        val storage = createStorage()
+        configPath.exists() shouldBe false
+
+        storage.getStoredConfig() shouldBe null
+        storage.setStoredConfig(null)
+        configPath.exists() shouldBe false
+
+        storage.getStoredConfig() shouldBe null
+        storage.setStoredConfig(testConfigDownload)
+        storage.getStoredConfig() shouldBe testConfigDownload
+
+        configPath.exists() shouldBe true
+        configPath.readText().toComparableJson() shouldBe """
+            {
+                "rawData": "$APPCONFIG_BASE64",
+                "serverTime": 1604381716000,
+                "localOffset": 3600000
+            }
+        """.toComparableJson()
+
+        storage.setStoredConfig(null)
+        storage.getStoredConfig() shouldBe null
+        configPath.exists() shouldBe false
+    }
+
+    @Test
+    fun `if no fallback exists, but we have a legacy config, use that`() = runBlockingTest {
+        configPath.exists() shouldBe false
+        legacyConfigPath.exists() shouldBe false
+
+        legacyConfigPath.parentFile!!.mkdirs()
+        legacyConfigPath.writeBytes(APPCONFIG_RAW)
+
+        val storage = createStorage()
+
+        storage.getStoredConfig() shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.ofEpochMilli(1234),
+            localOffset = Duration.ZERO
+        )
+    }
+
+    @Test
+    fun `writing a new config deletes any legacy configsconfig`() = runBlockingTest {
+        legacyConfigPath.parentFile!!.mkdirs()
+        legacyConfigPath.writeBytes(APPCONFIG_RAW)
+        configPath.exists() shouldBe false
+
+        val storage = createStorage()
+        storage.setStoredConfig(testConfigDownload)
+
+        legacyConfigPath.exists() shouldBe false
+        configPath.exists() shouldBe true
+    }
+
+    companion object {
+        private val APPCONFIG_RAW = (
+            "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+                "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+                "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+                "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+                "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+                "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+            ).decodeHex().toByteArray()
+
+        private val APPCONFIG_BASE64 = APPCONFIG_RAW.toByteString().base64()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt
similarity index 93%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt
index d817a316743f01bdfd03b5966a627f93af82ce5a..ad61b6ac73aa40167bddf07bb1f9b2578d879247 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.mapping
 
 import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
 import io.kotest.matchers.shouldBe
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2ef853f468f8e5b58bf7db38802a617b5c0d76b5
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt
@@ -0,0 +1,45 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class CWAConfigMapperTest : BaseTest() {
+
+    private fun createInstance() = CWAConfigMapper()
+
+    @Test
+    fun `simple creation`() {
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .addAllSupportedCountries(listOf("DE", "NL"))
+            .build()
+        createInstance().map(rawConfig).apply {
+            this.appVersion shouldBe rawConfig.appVersion
+            this.supportedCountries shouldBe listOf("DE", "NL")
+        }
+    }
+
+    @Test
+    fun `invalid supported countries are filtered out`() {
+        // Could happen due to protobuf scheme missmatch
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .addAllSupportedCountries(listOf("plausible deniability"))
+            .build()
+        createInstance().map(rawConfig).apply {
+            this.appVersion shouldBe rawConfig.appVersion
+            this.supportedCountries shouldBe emptyList()
+        }
+    }
+
+    @Test
+    fun `if supportedCountryList is empty, we do not insert DE as fallback`() {
+        // Because the UI requires this to detect when to show alternative UI elements
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .build()
+        createInstance().map(rawConfig).apply {
+            this.appVersion shouldBe rawConfig.appVersion
+            this.supportedCountries shouldBe emptyList()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..18ee07f72f83cfdbfccae776f6df5edc21a23083
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt
@@ -0,0 +1,70 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.verifySequence
+import okio.ByteString.Companion.decodeHex
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class ConfigParserTest : BaseTest() {
+    @MockK lateinit var cwaConfigMapper: CWAConfig.Mapper
+    @MockK lateinit var keyDownloadConfigMapper: KeyDownloadConfig.Mapper
+    @MockK lateinit var exposureDetectionConfigMapper: ExposureDetectionConfig.Mapper
+    @MockK lateinit var riskCalculationConfigMapper: RiskCalculationConfig.Mapper
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { cwaConfigMapper.map(any()) } returns mockk()
+        every { keyDownloadConfigMapper.map(any()) } returns mockk()
+        every { exposureDetectionConfigMapper.map(any()) } returns mockk()
+        every { riskCalculationConfigMapper.map(any()) } returns mockk()
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance(): ConfigParser = ConfigParser(
+        cwaConfigMapper = cwaConfigMapper,
+        keyDownloadConfigMapper = keyDownloadConfigMapper,
+        exposureDetectionConfigMapper = exposureDetectionConfigMapper,
+        riskCalculationConfigMapper = riskCalculationConfigMapper
+    )
+
+    @Test
+    fun `simple init`() {
+        createInstance().parse(APPCONFIG_RAW.toByteArray()).apply {
+
+            verifySequence {
+                cwaConfigMapper.map(any())
+                keyDownloadConfigMapper.map(any())
+                exposureDetectionConfigMapper.map(any())
+                riskCalculationConfigMapper.map(any())
+            }
+        }
+    }
+
+    companion object {
+        private val APPCONFIG_RAW = (
+            "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+                "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+                "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+                "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+                "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+                "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+            ).decodeHex()
+    }
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..d8ce3fd278925df0151f609f257866f3db3371b9
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt
@@ -0,0 +1,19 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DownloadConfigMapperTest : BaseTest() {
+    private fun createInstance() = DownloadConfigMapper()
+
+    @Test
+    fun `simple creation`() {
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .build()
+        createInstance().map(rawConfig).apply {
+            keyDownloadParameters shouldBe rawConfig.androidKeyDownloadParameters
+        }
+    }
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..2552a72dc3554e0879ad28a1bfaf2da77d2dca15
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt
@@ -0,0 +1,22 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class ExposureDetectionConfigMapperTest : BaseTest() {
+
+    private fun createInstance() = ExposureDetectionConfigMapper()
+
+    @Test
+    fun `simple creation`() {
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setMinRiskScore(1)
+            .build()
+        createInstance().map(rawConfig).apply {
+            exposureDetectionConfiguration shouldBe rawConfig.mapRiskScoreToExposureConfiguration()
+            exposureDetectionParameters shouldBe rawConfig.androidExposureDetectionParameters
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e0cf0e3c09a213d3f214cb292efca309a4824e67
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt
@@ -0,0 +1,22 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class RiskCalculationConfigMapperTest : BaseTest() {
+
+    private fun createInstance() = RiskCalculationConfigMapper()
+
+    @Test
+    fun `simple creation`() {
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .build()
+        createInstance().map(rawConfig).apply {
+            this.attenuationDuration shouldBe rawConfig.attenuationDuration
+            this.minRiskScore shouldBe rawConfig.minRiskScore
+            this.riskScoreClasses shouldBe rawConfig.riskScoreClasses
+        }
+    }
+}
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/calculationtracker/CalculationTrackerStorageTest.kt
index 5b1be36e8bec29072f2aa32140cbc1a4a091214b..4a7c2a019b42c627cf506154457c74be5bf5b1b4 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/calculationtracker/CalculationTrackerStorageTest.kt
@@ -2,8 +2,8 @@ package de.rki.coronawarnapp.nearby.modules.calculationtracker
 
 import android.content.Context
 import com.google.gson.GsonBuilder
-import de.rki.coronawarnapp.util.gson.fromJson
 import de.rki.coronawarnapp.util.serialization.SerializationModule
+import de.rki.coronawarnapp.util.serialization.fromJson
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
 import io.mockk.clearAllMocks
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/calculationtracker/DefaultCalculationTrackerTest.kt
index 067680cac59871576df6c49a6acb9eec6426e300..976ba6a4b0cc0015cc0636c19c747fac90921fee 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/calculationtracker/DefaultCalculationTrackerTest.kt
@@ -61,7 +61,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `data is restored from storage`() = runBlockingTest2(permanentJobs = true) {
+    fun `data is restored from storage`() = runBlockingTest2(ignoreActive = true) {
         val calcData = Calculation(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH
@@ -73,7 +73,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `tracking a new calculation`() = runBlockingTest2(permanentJobs = true) {
+    fun `tracking a new calculation`() = runBlockingTest2(ignoreActive = true) {
         createInstance(scope = this).apply {
             val expectedIdentifier = UUID.randomUUID().toString()
             trackNewCalaculation(expectedIdentifier)
@@ -101,7 +101,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `finish an existing calcluation`() = runBlockingTest2(permanentJobs = true) {
+    fun `finish an existing calcluation`() = runBlockingTest2(ignoreActive = true) {
         val calcData = Calculation(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH
@@ -136,7 +136,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `a late calculation overwrites timeout state`() = runBlockingTest2(permanentJobs = true) {
+    fun `a late calculation overwrites timeout state`() = runBlockingTest2(ignoreActive = true) {
         val calcData = Calculation(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH,
@@ -165,7 +165,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `no more than 10 calcluations are tracked`() = runBlockingTest2(permanentJobs = true) {
+    fun `no more than 10 calcluations are tracked`() = runBlockingTest2(ignoreActive = true) {
         val calcData = (1..15L).map {
             val calcData = Calculation(
                 identifier = "$it",
@@ -189,7 +189,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `15 minute timeout on ongoing calcs`() = runBlockingTest2(permanentJobs = true) {
+    fun `15 minute timeout on ongoing calcs`() = runBlockingTest2(ignoreActive = true) {
         every { timeStamper.nowUTC } returns Instant.EPOCH
             .plus(Duration.standardMinutes(15))
             .plus(2)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
index 81581412be578ed1f02be138075b250f0c7dcf01..4cbe2bda9a570500b6b6d56b6b23d3bcf139fc19 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
@@ -1,9 +1,10 @@
 package de.rki.coronawarnapp.transaction
 
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.ConfigData
 import de.rki.coronawarnapp.environment.EnvironmentSetup
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.util.GoogleAPIVersion
 import de.rki.coronawarnapp.util.di.AppInjector
@@ -37,6 +38,8 @@ class RetrieveDiagnosisKeysTransactionTest {
 
     @MockK lateinit var mockEnfClient: ENFClient
     @MockK lateinit var environmentSetup: EnvironmentSetup
+    @MockK lateinit var configProvider: AppConfigProvider
+    @MockK lateinit var configData: ConfigData
 
     @BeforeEach
     fun setUp() {
@@ -50,17 +53,21 @@ class RetrieveDiagnosisKeysTransactionTest {
                 mockEnfClient,
                 environmentSetup
             )
+            every { appConfigProvider } returns configProvider
         }
+
+        coEvery { configProvider.getAppConfig() } returns configData
+        every { configData.supportedCountries } returns emptyList()
+        every { configData.exposureDetectionConfiguration } returns mockk()
+
         every { AppInjector.component } returns appComponent
 
         mockkObject(InternalExposureNotificationClient)
-        mockkObject(ApplicationConfigurationService)
         mockkObject(RetrieveDiagnosisKeysTransaction)
         mockkObject(LocalData)
 
         coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
 
-        coEvery { ApplicationConfigurationService.asyncRetrieveExposureConfiguration() } returns mockk()
         every { LocalData.googleApiToken(any()) } just Runs
         every { LocalData.lastTimeDiagnosisKeysFromServerFetch() } returns Date()
         every { LocalData.lastTimeDiagnosisKeysFromServerFetch(any()) } just Runs
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt
index ad8d1c58d286307a36d310eff65450b97042a18e..3384063bf964993859ccb719cdf16ffe23aa32d1 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt
@@ -14,7 +14,8 @@ import java.io.File
 
 class HashExtensionsTest : BaseIOTest() {
 
-    private val testInput = "The Cake Is A Lie"
+    private val testInputText = "The Cake Is A Lie"
+    private val testInputByteArray = testInputText.toByteArray()
     private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
 
     @BeforeEach
@@ -31,17 +32,32 @@ class HashExtensionsTest : BaseIOTest() {
 
     @Test
     fun `hash string to MD5`() {
-        testInput.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179"
+        testInputText.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179"
     }
 
     @Test
     fun `hash string to SHA256`() {
-        testInput.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61"
+        testInputText.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61"
     }
 
     @Test
     fun `hash string to SHA1`() {
-        testInput.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76"
+        testInputText.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76"
+    }
+
+    @Test
+    fun `hash bytearray to MD5`() {
+        testInputByteArray.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179"
+    }
+
+    @Test
+    fun `hash bytearray to SHA256`() {
+        testInputByteArray.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61"
+    }
+
+    @Test
+    fun `hash bytearray to SHA1`() {
+        testInputByteArray.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76"
     }
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
index e5bbbc32bf1b36461320d765152b9771a9a65607..3de7010f7bf7105aa25a8738dbcd6772f0c140b4 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
@@ -1,5 +1,6 @@
 package de.rki.coronawarnapp.util.flow
 
+import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.matchers.shouldBe
 import io.kotest.matchers.types.instanceOf
 import io.mockk.coEvery
@@ -7,10 +8,13 @@ import io.mockk.coVerify
 import io.mockk.mockk
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.test.TestCoroutineScope
+import kotlinx.coroutines.withTimeoutOrNull
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 import testhelpers.coroutines.runBlockingTest2
@@ -22,23 +26,47 @@ import kotlin.concurrent.thread
 class HotDataFlowTest : BaseTest() {
 
     @Test
-    fun `init call only happens on first collection`() {
+    fun `init happens on first collection and exception is forwarded`() {
         val testScope = TestCoroutineScope()
         val hotData = HotDataFlow<String>(
             loggingTag = "tag",
             scope = testScope,
             coroutineContext = Dispatchers.Unconfined,
-            startValueProvider = {
-                throw IOException()
-            }
+            startValueProvider = { throw IOException() }
         )
 
-        testScope.apply {
-            runBlockingTest2(permanentJobs = true) {
+        runBlocking {
+            // This blocking scope get's the init exception as the first caller
+            shouldThrow<IOException> {
                 hotData.data.first()
             }
-            uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
         }
+
+        testScope.advanceUntilIdle()
+
+        testScope.uncaughtExceptions.singleOrNull() shouldBe null
+    }
+
+    @Test
+    fun `exception is not forwarded if flag is set`() {
+        val testScope = TestCoroutineScope()
+        val hotData = HotDataFlow<String>(
+            loggingTag = "tag",
+            scope = testScope,
+            coroutineContext = Dispatchers.Unconfined,
+            forwardException = false,
+            startValueProvider = { throw IOException() }
+        )
+        runBlocking {
+            withTimeoutOrNull(500) {
+                // This blocking scope get's the init exception as the first caller
+                hotData.data.firstOrNull()
+            } shouldBe null
+        }
+
+        testScope.advanceUntilIdle()
+
+        testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
     }
 
     @Test
@@ -179,4 +207,35 @@ class HotDataFlowTest : BaseTest() {
         }
         coVerify(exactly = 1) { valueProvider.invoke(any()) }
     }
+
+    @Test
+    fun `blocking update is actually blocking`() = runBlocking {
+        val testScope = TestCoroutineScope()
+        val hotData = HotDataFlow(
+            loggingTag = "tag",
+            scope = testScope,
+            coroutineContext = testScope.coroutineContext,
+            startValueProvider = {
+                delay(2000)
+                2
+            },
+            sharingBehavior = SharingStarted.Lazily
+        )
+
+        hotData.updateSafely {
+            delay(2000)
+            this + 1
+        }
+
+        val testCollector = hotData.data.test(startOnScope = testScope)
+
+        testScope.advanceUntilIdle()
+
+        hotData.updateBlocking { this - 3 } shouldBe 0
+
+        testCollector.await { list, i -> i == 3 }
+        testCollector.latestValues shouldBe listOf(2, 3, 0)
+
+        testCollector.cancel()
+    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f5e88d201075dd4f3f38dd53c5a5772422862a55
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt
@@ -0,0 +1,73 @@
+package de.rki.coronawarnapp.util.serialization.adapter
+
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonParseException
+import de.rki.coronawarnapp.util.serialization.fromJson
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import okio.ByteString.Companion.decodeHex
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class ByteArrayAdapterTest : BaseTest() {
+
+    private val gson = GsonBuilder()
+        .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter())
+        .create()
+
+    // This is actually an app config, some cases like did not trigger a few serialization issues in the server test.
+    private val goodByteArray = (
+        "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+            "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+            "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+            "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+            "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+            "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+        ).decodeHex().toByteArray()
+
+    @Test
+    fun `serialize and deserialize`() {
+        val serialized: String = gson.toJson(TestData(goodByteArray))
+
+        gson.fromJson<TestData>(serialized) shouldBe TestData(goodByteArray)
+    }
+
+    @Test
+    fun `malformed base64 should throw specific exception`() {
+        shouldThrow<JsonParseException> {
+            """
+                {
+                    "byteArray": "Don't feed this to your base 64 decoder :("
+                }
+            """.trimIndent().let { gson.fromJson<TestData>(it) }
+        }
+    }
+
+    @Test
+    fun `empty base64 string is OK`() {
+        """
+            {
+                "byteArray": ""
+            }
+        """.trimIndent().let {
+            gson.fromJson<TestData>(it) shouldBe TestData(ByteArray(0))
+        }
+    }
+
+    data class TestData(
+        val byteArray: ByteArray
+    ) {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (javaClass != other?.javaClass) return false
+
+            other as TestData
+
+            if (!byteArray.contentEquals(other.byteArray)) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int = byteArray.contentHashCode()
+    }
+}
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 0fc5027bee9cc61a1545b2cab0ad52bdde4b1488..b5a62565aa2ee00e0fb214a9d1c6d53453d7199a 100644
--- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
+++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
@@ -13,13 +13,13 @@ fun TestCoroutineScope.runBlockingTest2(
     permanentJobs: Boolean = false,
     block: suspend TestCoroutineScope.() -> Unit
 ): Unit = runBlockingTest2(
-    permanentJobs = permanentJobs,
+    ignoreActive = permanentJobs,
     context = coroutineContext,
     testBody = block
 )
 
 fun runBlockingTest2(
-    permanentJobs: Boolean = false,
+    ignoreActive: Boolean = false,
     context: CoroutineContext = EmptyCoroutineContext,
     testBody: suspend TestCoroutineScope.() -> Unit
 ) {
@@ -31,11 +31,11 @@ fun runBlockingTest2(
                     testBody = testBody
                 )
             } catch (e: UncompletedCoroutinesError) {
-                if (!permanentJobs) throw e
+                if (!ignoreActive) throw e
             }
         }
     } catch (e: Exception) {
-        if (!permanentJobs || (e.message != "This job has not completed yet")) {
+        if (!ignoreActive || (e.message != "This job has not completed yet")) {
             throw e
         }
     }
diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt
index 83b3f2cfeeec433dc06a057e4404c92cfef5507d..b132c1dec5cc6518c811289293f39578b911b531 100644
--- a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt
+++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt
@@ -1,38 +1,22 @@
 package de.rki.coronawarnapp.test.api.ui
 
 import android.content.Context
-import androidx.lifecycle.Observer
 import de.rki.coronawarnapp.environment.EnvironmentSetup
-import de.rki.coronawarnapp.storage.TestSettings
 import de.rki.coronawarnapp.task.TaskController
-import io.kotest.matchers.shouldBe
 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 kotlinx.coroutines.ExperimentalCoroutinesApi
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.extension.ExtendWith
 import testhelpers.BaseTest
 import testhelpers.extensions.CoroutinesTestExtension
 import testhelpers.extensions.InstantExecutorExtension
-import testhelpers.flakyTest
-import kotlin.time.ExperimentalTime
 
-@ExperimentalTime
-@ExperimentalCoroutinesApi
 @ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class)
 class TestForApiFragmentViewModelTest : BaseTest() {
 
-    @MockK private lateinit var environmentSetup: EnvironmentSetup
     @MockK private lateinit var context: Context
-    @MockK private lateinit var testSettings: TestSettings
     @MockK lateinit var taskController: TaskController
 
     private var currentEnvironment = EnvironmentSetup.Type.DEV
@@ -40,20 +24,6 @@ class TestForApiFragmentViewModelTest : BaseTest() {
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
-        currentEnvironment = EnvironmentSetup.Type.DEV
-
-        every { environmentSetup.defaultEnvironment } returns EnvironmentSetup.Type.DEV
-        every { environmentSetup.submissionCdnUrl } returns "submissionUrl"
-        every { environmentSetup.downloadCdnUrl } returns "downloadUrl"
-        every { environmentSetup.verificationCdnUrl } returns "verificationUrl"
-
-        every { environmentSetup.currentEnvironment = any() } answers {
-            currentEnvironment = arg(0)
-            Unit
-        }
-        every { environmentSetup.currentEnvironment } answers {
-            currentEnvironment
-        }
     }
 
     @AfterEach
@@ -62,45 +32,7 @@ class TestForApiFragmentViewModelTest : BaseTest() {
     }
 
     private fun createViewModel(): TestForApiFragmentViewModel = TestForApiFragmentViewModel(
-        envSetup = environmentSetup,
         context = context,
-        testSettings = testSettings,
         taskController = taskController
     )
-
-    @Test
-    fun `toggeling the env works`() = flakyTest {
-        currentEnvironment = EnvironmentSetup.Type.DEV
-        val vm = createViewModel()
-
-        val states = mutableListOf<EnvironmentState>()
-        val observerState = mockk<Observer<EnvironmentState>>()
-        every { observerState.onChanged(capture(states)) } just Runs
-        vm.environmentState.observeForever(observerState)
-
-        val events = mutableListOf<EnvironmentSetup.Type>()
-        val observerEvent = mockk<Observer<EnvironmentSetup.Type>>()
-        every { observerEvent.onChanged(capture(events)) } just Runs
-        vm.environmentChangeEvent.observeForever(observerEvent)
-
-        vm.selectEnvironmentTytpe(EnvironmentSetup.Type.DEV.rawKey)
-        vm.selectEnvironmentTytpe(EnvironmentSetup.Type.WRU_XA.rawKey)
-
-        verify(exactly = 3, timeout = 3000) { observerState.onChanged(any()) }
-        verify(exactly = 2, timeout = 3000) { observerEvent.onChanged(any()) }
-
-        states[0].apply {
-            current shouldBe EnvironmentSetup.Type.DEV
-        }
-
-        states[1].apply {
-            current shouldBe EnvironmentSetup.Type.DEV
-        }
-        events[0] shouldBe EnvironmentSetup.Type.DEV
-
-        states[2].apply {
-            current shouldBe EnvironmentSetup.Type.WRU_XA
-        }
-        events[1] shouldBe EnvironmentSetup.Type.WRU_XA
-    }
 }
diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..42ad7c156bb1ceff1fb4a3603f1849edb92f444c
--- /dev/null
+++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt
@@ -0,0 +1,105 @@
+package de.rki.coronawarnapp.test.debugoptions.ui
+
+import android.content.Context
+import androidx.lifecycle.Observer
+import de.rki.coronawarnapp.environment.EnvironmentSetup
+import de.rki.coronawarnapp.storage.TestSettings
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.test.api.ui.EnvironmentState
+import io.kotest.matchers.shouldBe
+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 org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import testhelpers.BaseTest
+import testhelpers.TestDispatcherProvider
+import testhelpers.extensions.CoroutinesTestExtension
+import testhelpers.extensions.InstantExecutorExtension
+import testhelpers.flakyTest
+
+@ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class)
+class DebugOptionsFragmentViewModelTest : BaseTest() {
+
+    @MockK private lateinit var environmentSetup: EnvironmentSetup
+    @MockK private lateinit var context: Context
+    @MockK private lateinit var testSettings: TestSettings
+    @MockK lateinit var taskController: TaskController
+
+    private var currentEnvironment = EnvironmentSetup.Type.DEV
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        currentEnvironment = EnvironmentSetup.Type.DEV
+
+        every { environmentSetup.defaultEnvironment } returns EnvironmentSetup.Type.DEV
+        every { environmentSetup.submissionCdnUrl } returns "submissionUrl"
+        every { environmentSetup.downloadCdnUrl } returns "downloadUrl"
+        every { environmentSetup.verificationCdnUrl } returns "verificationUrl"
+
+        every { environmentSetup.currentEnvironment = any() } answers {
+            currentEnvironment = arg(0)
+            Unit
+        }
+        every { environmentSetup.currentEnvironment } answers {
+            currentEnvironment
+        }
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createViewModel(): DebugOptionsFragmentViewModel = DebugOptionsFragmentViewModel(
+        context = context,
+        taskController = taskController,
+        envSetup = environmentSetup,
+        testSettings = testSettings,
+        dispatcherProvider = TestDispatcherProvider
+    )
+
+    @Test
+    fun `toggeling the env works`() = flakyTest {
+        currentEnvironment = EnvironmentSetup.Type.DEV
+        val vm = createViewModel()
+
+        val states = mutableListOf<EnvironmentState>()
+        val observerState = mockk<Observer<EnvironmentState>>()
+        every { observerState.onChanged(capture(states)) } just Runs
+        vm.environmentState.observeForever(observerState)
+
+        val events = mutableListOf<EnvironmentSetup.Type>()
+        val observerEvent = mockk<Observer<EnvironmentSetup.Type>>()
+        every { observerEvent.onChanged(capture(events)) } just Runs
+        vm.environmentChangeEvent.observeForever(observerEvent)
+
+        vm.selectEnvironmentTytpe(EnvironmentSetup.Type.DEV.rawKey)
+        vm.selectEnvironmentTytpe(EnvironmentSetup.Type.WRU_XA.rawKey)
+
+        verify(exactly = 3, timeout = 3000) { observerState.onChanged(any()) }
+        verify(exactly = 2, timeout = 3000) { observerEvent.onChanged(any()) }
+
+        states[0].apply {
+            current shouldBe EnvironmentSetup.Type.DEV
+        }
+
+        states[1].apply {
+            current shouldBe EnvironmentSetup.Type.DEV
+        }
+        events[0] shouldBe EnvironmentSetup.Type.DEV
+
+        states[2].apply {
+            current shouldBe EnvironmentSetup.Type.WRU_XA
+        }
+        events[1] shouldBe EnvironmentSetup.Type.WRU_XA
+    }
+}