From 647848d65a8a4a413e11c4f821dce7fdf24cd7ce Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <matthias.urhahn@sap.com>
Date: Thu, 5 Nov 2020 13:35:18 +0100
Subject: [PATCH] Improved AppConfig logic for 1.7.x and 1.8.x
 (EXPOSUREAPP-3455) (#1520)

* Split and hide the protobuf config behind interfaces with individual mappers responsible for creating the desired formats.

* Merge branch 'release/1.7.x' into feature/3455-more-frequent-riskscore-updates-configs

# Conflicts:
#	Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
#	Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RiskLevelTransaction.kt
#	Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RiskLevelTransactionTest.kt

* Make the AppConfig observable.
Provide the server time offset.
Offer a lastUpdatedAt timestamp.
Add an app config specific test screen.
Clean up test screens a bit and move debug options out of API test options.

* Fix test regression due to refactoring (moved code around).

* Store the server timestamp and offset at retrieval.
Switch to config storage via json to be able to store additional meta data fields (i.e. time).

* KLint and Me have a hate relationship based on both mutual admiration.

* Fix time offset parsing being locale dependent.

* Fix broken unit tests.

* Improve offset accuracy, move before unzipping.

* Fix overly long livedata subscription to results (viewmodel scope vs observer scope)

* Add mapping for the new protobuf configs + tests.

* For cached (retrofit) response, we need to check the cacheResponse and its timestamps
to determine an accurate time offset.

* Exposure a boolean property to tell us when a fallback config is being used.

* Hide the observable flow<ConfigData> behind a method that can automatically triggers refreshes.

* Use a common mapper interface.

* Address PR comments and KLints.

* Fix refactoring regression.

* Improve config unzipping code.

* Add flag to forward exception thrown during HotDataFlow.kt initialization.

* Don't specify a default context via singleton.

* Move download and fallback logic into it's own class just responsible for sourcing the config: "AppConfigSource".
"AppConfigProvider" is now only responsible for making it available.

* Simplify current concepts for making the app config observable until we have a default configuration.

* Improve app config test screen, delete options, better feedback.
Show toast instead of crash on errors.

* Fixed GSON serialization not encoding/decoding the byte array correctly.
Added specific type adapters for instant and duration to get cleaner json.

* Remove type adapters from base gson due to conflict with CalculationTrackerStorage.

* We want to default to forced serialization of instant by our converters, instead of using the default serialization which will differ
between Java8.Instant and JodaTime.Instant, to prevent future headaches there, register explicit converters by default,
and overwrite them if necessary (currently only needed for CalculationTrackerStorage.kt).

* Improve AppConfigServer code readability by moving code into extensions.

* Fix merge conflicts

* Throw a more specific exception if gson type decoding fails due to malformed base64 encoding.

* Add unit test for Gson ByteArrayAdapter.

Co-authored-by: harambasicluka <64483219+harambasicluka@users.noreply.github.com>
---
 .../test/api/ui/TestForAPIFragment.kt         |  81 +------
 .../api/ui/TestForApiFragmentViewModel.kt     |  81 -------
 .../appconfig/ui/AppConfigTestFragment.kt     |  58 +++++
 .../ui/AppConfigTestFragmentModule.kt         |  18 ++
 .../ui/AppConfigTestFragmentViewModel.kt      |  39 ++++
 .../debugoptions/ui/DebugOptionsFragment.kt   | 115 ++++++++++
 .../ui/DebugOptionsFragmentModule.kt          |  18 ++
 .../ui/DebugOptionsFragmentViewModel.kt       | 108 +++++++++
 .../test/menu/ui/TestMenuFragmentViewModel.kt |   8 +-
 .../ui/TestRiskLevelCalculationFragment.kt    |   1 -
 ...iskLevelCalculationFragmentCWAViewModel.kt |  37 ++-
 .../ui/main/MainActivityTestModule.kt         |  10 +
 .../res/layout/fragment_test_appconfig.xml    |  93 ++++++++
 .../res/layout/fragment_test_debugoptions.xml | 146 ++++++++++++
 .../res/layout/fragment_test_for_a_p_i.xml    | 126 -----------
 .../res/layout/fragment_test_menu.xml         |   2 +-
 .../fragment_test_risk_level_calculation.xml  |  15 --
 .../res/navigation/test_nav_graph.xml         |  19 +-
 .../appconfig/AppConfigModule.kt              |  19 ++
 .../appconfig/AppConfigProvider.kt            | 142 ++----------
 .../appconfig/AppConfigSource.kt              |  82 +++++++
 .../appconfig/AppConfigStorage.kt             |  52 -----
 ...pplicationConfigurationInvalidException.kt |   8 -
 .../rki/coronawarnapp/appconfig/CWAConfig.kt  |  16 ++
 .../rki/coronawarnapp/appconfig/ConfigData.kt |  24 ++
 .../appconfig/DefaultConfigData.kt            |  14 ++
 .../appconfig/ExposureDetectionConfig.kt      |  13 ++
 .../appconfig/KeyDownloadConfig.kt            |  11 +
 .../appconfig/RiskCalculationConfig.kt        |  16 ++
 .../{ => download}/AppConfigApiV1.kt          |   5 +-
 .../{ => download}/AppConfigHttpCache.kt      |   2 +-
 .../appconfig/download/AppConfigServer.kt     |  99 ++++++++
 .../appconfig/download/AppConfigStorage.kt    |  87 +++++++
 ...pplicationConfigurationCorruptException.kt |   2 +-
 ...pplicationConfigurationInvalidException.kt |  13 ++
 .../appconfig/download/ConfigDownload.kt      |  31 +++
 .../ApplicationConfigurationExtensions.kt     |   2 +-
 .../appconfig/mapping/CWAConfigMapper.kt      |  42 ++++
 .../appconfig/mapping/ConfigMapper.kt         |   7 +
 .../appconfig/mapping/ConfigMapping.kt        |  17 ++
 .../appconfig/mapping/ConfigParser.kt         |  39 ++++
 .../appconfig/mapping/DefaultConfigMapping.kt |  19 ++
 .../appconfig/mapping/DownloadConfigMapper.kt |  21 ++
 .../mapping/ExposureDetectionConfigMapper.kt  |  74 ++++++
 .../mapping/RiskCalculationConfigMapper.kt    |  26 +++
 .../CalculationTrackerStorage.kt              |  14 +-
 .../ApplicationConfigurationService.kt        |  65 ------
 .../InteroperabilityRepository.kt             |   2 +-
 .../submission/SubmissionTask.kt              |  29 ++-
 .../RetrieveDiagnosisKeysTransaction.kt       |  10 +-
 .../rki/coronawarnapp/update/UpdateChecker.kt |  10 +-
 .../rki/coronawarnapp/util/HashExtensions.kt  |  18 +-
 .../de/rki/coronawarnapp/util/ZipHelper.kt    |  14 +-
 .../util/database/CommonConverters.kt         |   2 +-
 .../coronawarnapp/util/flow/HotDataFlow.kt    |  64 ++++--
 .../util/security/VerificationKeys.kt         |   4 +-
 .../{gson => serialization}/GsonExtensions.kt |   6 +-
 .../util/serialization/SerializationModule.kt |  11 +-
 .../serialization/adapter/ByteArrayAdapter.kt |  24 ++
 .../serialization/adapter/DurationAdapter.kt  |  27 +++
 .../serialization/adapter/InstantAdapter.kt   |  27 +++
 .../appconfig/AppConfigProviderTest.kt        | 212 +++++-------------
 .../appconfig/AppConfigSourceTest.kt          | 144 ++++++++++++
 .../appconfig/AppConfigStorageTest.kt         |  87 -------
 .../{ => download}/AppConfigApiTest.kt        |  27 ++-
 .../{ => download}/AppConfigModuleTest.kt     |   3 +-
 .../appconfig/download/AppConfigServerTest.kt | 199 ++++++++++++++++
 .../download/AppConfigStorageTest.kt          | 149 ++++++++++++
 .../ApplicationConfigurationExtensionsTest.kt |   2 +-
 .../appconfig/mapping/CWAConfigMapperTest.kt  |  45 ++++
 .../appconfig/mapping/ConfigParserTest.kt     |  70 ++++++
 .../mapping/DownloadConfigMapperTest.kt       |  19 ++
 .../ExposureDetectionConfigMapperTest.kt      |  22 ++
 .../RiskCalculationConfigMapperTest.kt        |  22 ++
 .../CalculationTrackerStorageTest.kt          |   2 +-
 .../DefaultCalculationTrackerTest.kt          |  12 +-
 .../RetrieveDiagnosisKeysTransactionTest.kt   |  13 +-
 .../coronawarnapp/util/HashExtensionsTest.kt  |  24 +-
 .../util/flow/HotDataFlowTest.kt              |  73 +++++-
 .../adapter/ByteArrayAdapterTest.kt           |  73 ++++++
 .../testhelpers/coroutines/TestExtensions.kt  |   8 +-
 .../api/ui/TestForApiFragmentViewModelTest.kt |  68 ------
 .../ui/DebugOptionsFragmentViewModelTest.kt   | 105 +++++++++
 83 files changed, 2583 insertions(+), 981 deletions(-)
 create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt
 create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt
 create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt
 create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt
 create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt
 create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt
 create mode 100644 Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml
 create mode 100644 Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/{ => download}/AppConfigApiV1.kt (71%)
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/{ => download}/AppConfigHttpCache.kt (74%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/{ => download}/ApplicationConfigurationCorruptException.kt (86%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/{ => mapping}/ApplicationConfigurationExtensions.kt (86%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/{gson => serialization}/GsonExtensions.kt (67%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt
 delete mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/{ => download}/AppConfigApiTest.kt (83%)
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/{ => download}/AppConfigModuleTest.kt (92%)
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/{ => mapping}/ApplicationConfigurationExtensionsTest.kt (93%)
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt
 create mode 100644 Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt

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 6b8bf5b59..fbdd56b53 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 49c8127c4..f2ebfd26e 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 000000000..feedc56bc
--- /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 000000000..0ebba5dd4
--- /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 000000000..98dfaf2ec
--- /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 000000000..8015debc7
--- /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 000000000..f24dda5b1
--- /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 000000000..784c9731e
--- /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 aa58711c9..d3316873e 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 8513f470c..0644ec826 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 d98931f3c..add8047c5 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 cdf98cf1a..dae782737 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 000000000..fb4d95036
--- /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 000000000..628201557
--- /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 767e5f68d..a72eafd72 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 1746c0c05..807e881a7 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 06f6d5af7..9e29d88d4 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 3aff7a83c..1c53c7772 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 9216419bb..82936e52b 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 4cbedfc93..91866abb5 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 000000000..fd31afca8
--- /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 54b6351b1..000000000
--- 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 153435397..000000000
--- 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 000000000..0a11bf822
--- /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 000000000..6845e3d29
--- /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 000000000..5fc918d09
--- /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 000000000..5281c51ed
--- /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 000000000..d82a6cd3a
--- /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 000000000..2c19c0be6
--- /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 ae8898f1a..0c3f61077 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 a3aff4add..253ac97d3 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 000000000..1d707c26b
--- /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 000000000..c5a86a928
--- /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 51c5dfb89..bd6940034 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 000000000..63cb1069e
--- /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 000000000..1f9ba9050
--- /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 9ac63ab9a..9177f5ae9 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 000000000..8d78dddcd
--- /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 000000000..58c4b88b2
--- /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 000000000..9858ec812
--- /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 000000000..8449b81b7
--- /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 000000000..783385ddf
--- /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 000000000..752f41cb1
--- /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 000000000..c010e25af
--- /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 000000000..dd36d4ea9
--- /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 1aaf4d63b..18fb150e3 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 16ac02b79..000000000
--- 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 b29b24698..f14a53f5f 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 8c1bb01b5..7b605f10e 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 036905b78..493bb0c9e 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 b203364fc..42dfb2f60 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 b99b9f2cc..97839c1c3 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 b296cced9..14a58b2a1 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 9e5ce2d20..e6a640152 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 359c2e948..c3d6d5787 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 e6df66e5b..1ba8319fe 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 b79e725c5..601f833c3 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 e638c3811..c2c33f43b 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 000000000..10388e0c4
--- /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 000000000..0d194661e
--- /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 000000000..99ba6e6e1
--- /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 972136da0..00fcd329f 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 000000000..a4ba54e99
--- /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 aea45f221..000000000
--- 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 671fd3ab1..da1ab2f7d 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 fdae6dd05..eae101cd8 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 000000000..eda32b98e
--- /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 000000000..fa15cab90
--- /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 d817a3167..ad61b6ac7 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 000000000..2ef853f46
--- /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 000000000..18ee07f72
--- /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 000000000..d8ce3fd27
--- /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 000000000..2552a72dc
--- /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 000000000..e0cf0e3c0
--- /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 5b1be36e8..4a7c2a019 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 067680cac..976ba6a4b 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 81581412b..4cbe2bda9 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 ad8d1c58d..3384063bf 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 e5bbbc32b..3de7010f7 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 000000000..f5e88d201
--- /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 0fc5027be..b5a62565a 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 83b3f2cfe..b132c1dec 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 000000000..42ad7c156
--- /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
+    }
+}
-- 
GitLab