From 047ce4f1debcc61dbaf17601e08c3b756b528805 Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <matthias.urhahn@sap.com>
Date: Mon, 28 Sep 2020 09:57:09 +0200
Subject: [PATCH] Handle App Config uniformly with caching (EXPOSUREAPP-2747)
 (#1213)

* Move AppConfig related code into it's own package.
* Use "files" instead of "cache" for storing our AppConfigApiTest.
* Add "files" to "cache" migration.
* Refactor CDN related code into an independent "environment module".

TODO: UnitTests, Fallback behavior even if cached AppConfig is stale.

* Further structure refactoring and unit test for app config cache migration.

* Added config fallback behavior for all cases + additional tests.

* Improve code readability.

* Remove test TODO
---
 .../server => appconfig}/AppConfigApiV1.kt    |   2 +-
 .../appconfig/AppConfigModule.kt              |  52 +++++
 .../appconfig/AppConfigProvider.kt            | 106 +++++++++
 .../appconfig/AppConfigStorage.kt             |  39 ++++
 ...pplicationConfigurationCorruptException.kt |   2 +-
 ...pplicationConfigurationInvalidException.kt |   2 +-
 .../diagnosiskeys/DiagnosisKeysModule.kt      |  72 +-----
 .../diagnosiskeys/server/AppConfigServer.kt   |  61 -----
 .../server/DiagnosisKeyServer.kt              |   3 +-
 .../environment/EnvironmentModule.kt          |   7 +
 .../download/DownloadCDNHomeCountry.kt}       |   4 +-
 .../download/DownloadCDNHttpClient.kt}        |   4 +-
 .../environment/download/DownloadCDNModule.kt |  50 ++++
 .../download/DownloadCDNServerUrl.kt}         |   4 +-
 .../ApplicationConfigurationService.kt        |   2 +-
 .../rki/coronawarnapp/update/UpdateChecker.kt |   2 +-
 .../util/di/ApplicationComponent.kt           |  10 +-
 .../server => appconfig}/AppConfigApiTest.kt  |  56 ++---
 .../appconfig/AppConfigModuleTest.kt          |  48 ++++
 .../appconfig/AppConfigServerTest.kt          | 213 ++++++++++++++++++
 .../appconfig/AppConfigStorageTest.kt         |  72 ++++++
 .../diagnosiskeys/DiagnosisKeysModuleTest.kt  |  22 +-
 .../server/AppConfigServerTest.kt             | 131 -----------
 .../server/DiagnosisKeyApiTest.kt             |  23 +-
 .../environment/EnvironmentModuleTest.kt      |  17 ++
 .../download/DownloadCDNModuleTest.kt         |  32 +++
 .../ApplicationConfigurationServiceTest.kt    |   8 +-
 27 files changed, 712 insertions(+), 332 deletions(-)
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{diagnosiskeys/server => appconfig}/AppConfigApiV1.kt (85%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{exception => appconfig}/ApplicationConfigurationCorruptException.kt (88%)
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{exception => appconfig}/ApplicationConfigurationInvalidException.kt (88%)
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServer.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{diagnosiskeys/server/DownloadServerUrl.kt => environment/download/DownloadCDNHomeCountry.kt} (52%)
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{diagnosiskeys/server/DownloadHttpClient.kt => environment/download/DownloadCDNHttpClient.kt} (53%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNModule.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{diagnosiskeys/server/DownloadHomeCountry.kt => environment/download/DownloadCDNServerUrl.kt} (53%)
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/{diagnosiskeys/server => appconfig}/AppConfigApiTest.kt (74%)
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt
 delete mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServerTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentModuleTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/download/DownloadCDNModuleTest.kt

diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt
similarity index 85%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiV1.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt
index 95a991413..ae8898f1a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiV1.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.diagnosiskeys.server
+package de.rki.coronawarnapp.appconfig
 
 import okhttp3.ResponseBody
 import retrofit2.http.GET
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
new file mode 100644
index 000000000..aa295a352
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt
@@ -0,0 +1,52 @@
+package de.rki.coronawarnapp.appconfig
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient
+import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl
+import okhttp3.Cache
+import okhttp3.OkHttpClient
+import org.joda.time.Duration
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.io.File
+import java.util.concurrent.TimeUnit
+import javax.inject.Singleton
+
+@Module
+class AppConfigModule {
+
+    @Singleton
+    @Provides
+    fun provideAppConfigApi(
+        context: Context,
+        @DownloadCDNHttpClient client: OkHttpClient,
+        @DownloadCDNServerUrl url: String,
+        gsonConverterFactory: GsonConverterFactory
+    ): AppConfigApiV1 {
+        val cacheSize = 1 * 1024 * 1024L // 1MB
+
+        val cacheDir = File(context.cacheDir, "http_app-config")
+        val cache = Cache(cacheDir, cacheSize)
+
+        val cachingClient = client.newBuilder().apply {
+            cache(cache)
+            connectTimeout(HTTP_TIMEOUT_APPCONFIG.millis, TimeUnit.MILLISECONDS)
+            readTimeout(HTTP_TIMEOUT_APPCONFIG.millis, TimeUnit.MILLISECONDS)
+            writeTimeout(HTTP_TIMEOUT_APPCONFIG.millis, TimeUnit.MILLISECONDS)
+            callTimeout(HTTP_TIMEOUT_APPCONFIG.millis, TimeUnit.MILLISECONDS)
+        }.build()
+
+        return Retrofit.Builder()
+            .client(cachingClient)
+            .baseUrl(url)
+            .addConverterFactory(gsonConverterFactory)
+            .build()
+            .create(AppConfigApiV1::class.java)
+    }
+
+    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
new file mode 100644
index 000000000..72130ea55
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
@@ -0,0 +1,106 @@
+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.ApplicationConfigurationOuterClass
+import de.rki.coronawarnapp.util.ZipHelper.unzip
+import de.rki.coronawarnapp.util.security.VerificationKeys
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+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
+) {
+
+    private val configApi: AppConfigApiV1
+        get() = appConfigAPI.get()
+
+    @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 suspend fun getNewAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration? {
+        val newConfigRaw = try {
+            downloadAppConfig()
+        } catch (e: Exception) {
+            Timber.w(e, "Failed to download latest AppConfig.")
+            null
+        }
+
+        val newConfigParsed = try {
+            tryParseConfig(newConfigRaw)
+        } catch (e: Exception) {
+            Timber.w(e, "Failed to parse latest AppConfig.")
+            null
+        }
+
+        return newConfigParsed?.also {
+            Timber.v("Saving new valid config.")
+            configStorage.appConfigRaw = newConfigRaw
+        }
+    }
+
+    private fun getFallback(): ApplicationConfigurationOuterClass.ApplicationConfiguration {
+        val lastValidConfig = tryParseConfig(configStorage.appConfigRaw)
+        return if (lastValidConfig != null) {
+            Timber.d("Using fallback AppConfig.")
+            lastValidConfig
+        } else {
+            Timber.e("No valid fallback AppConfig available.")
+            throw ApplicationConfigurationInvalidException()
+        }
+    }
+
+    suspend fun getAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration =
+        withContext(Dispatchers.IO) {
+            val newAppConfig = getNewAppConfig()
+
+            return@withContext if (newAppConfig != null) {
+                newAppConfig
+            } else {
+                Timber.w("No new config available, using last valid.")
+                getFallback()
+            }
+        }
+
+    private fun tryParseConfig(byteArray: ByteArray?): ApplicationConfigurationOuterClass.ApplicationConfiguration? {
+        Timber.v("Parsing config (size=%dB)", byteArray?.size)
+        if (byteArray == null) return null
+        return ApplicationConfigurationOuterClass.ApplicationConfiguration.parseFrom(byteArray)
+    }
+
+    companion object {
+        private val TAG = AppConfigProvider::class.java.simpleName
+
+        private const val EXPORT_BINARY_FILE_NAME = "export.bin"
+        private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig"
+    }
+}
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
new file mode 100644
index 000000000..49bbca096
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt
@@ -0,0 +1,39 @@
+package de.rki.coronawarnapp.appconfig
+
+import android.content.Context
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AppConfigStorage @Inject constructor(
+    context: Context
+) {
+    private val configDir = File(context.filesDir, "appconfig_storage")
+    private val configFile = File(configDir, "appconfig")
+
+    var appConfigRaw: ByteArray?
+        get() {
+            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
+        }
+        set(value) {
+            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()
+            }
+        }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationCorruptException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt
similarity index 88%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationCorruptException.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt
index 5cb36f066..51c5dfb89 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationCorruptException.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.exception
+package de.rki.coronawarnapp.appconfig
 
 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/exception/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt
similarity index 88%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationInvalidException.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt
index eb71a47e2..153435397 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/ApplicationConfigurationInvalidException.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.exception
+package de.rki.coronawarnapp.appconfig
 
 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/diagnosiskeys/DiagnosisKeysModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModule.kt
index 88be7abab..213c479c3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModule.kt
@@ -3,45 +3,24 @@ package de.rki.coronawarnapp.diagnosiskeys
 import android.content.Context
 import dagger.Module
 import dagger.Provides
-import dagger.Reusable
-import de.rki.coronawarnapp.BuildConfig
-import de.rki.coronawarnapp.diagnosiskeys.server.AppConfigApiV1
 import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyApiV1
-import de.rki.coronawarnapp.diagnosiskeys.server.DownloadHomeCountry
-import de.rki.coronawarnapp.diagnosiskeys.server.DownloadHttpClient
-import de.rki.coronawarnapp.diagnosiskeys.server.DownloadServerUrl
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
 import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.KeyCacheLegacyDao
-import de.rki.coronawarnapp.http.HttpClientDefault
+import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient
+import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl
 import de.rki.coronawarnapp.storage.AppDatabase
-import okhttp3.Cache
-import okhttp3.ConnectionSpec
 import okhttp3.OkHttpClient
-import okhttp3.TlsVersion
 import retrofit2.Retrofit
 import retrofit2.converter.gson.GsonConverterFactory
-import java.io.File
 import javax.inject.Singleton
 
 @Module
 class DiagnosisKeysModule {
 
-    @Singleton
-    @DownloadHomeCountry
-    @Provides
-    fun provideDiagnosisHomeCountry(): LocationCode = LocationCode("DE")
-
-    @Reusable
-    @DownloadHttpClient
-    @Provides
-    fun cdnHttpClient(@HttpClientDefault defaultHttpClient: OkHttpClient): OkHttpClient =
-        defaultHttpClient.newBuilder().connectionSpecs(CDN_CONNECTION_SPECS).build()
-
     @Singleton
     @Provides
     fun provideDiagnosisKeyApi(
-        @DownloadHttpClient client: OkHttpClient,
-        @DownloadServerUrl url: String,
+        @DownloadCDNHttpClient client: OkHttpClient,
+        @DownloadCDNServerUrl url: String,
         gsonConverterFactory: GsonConverterFactory
     ): DiagnosisKeyApiV1 = Retrofit.Builder()
         .client(client)
@@ -50,52 +29,9 @@ class DiagnosisKeysModule {
         .build()
         .create(DiagnosisKeyApiV1::class.java)
 
-    @Singleton
-    @Provides
-    fun provideAppConfigApi(
-        context: Context,
-        @DownloadHttpClient client: OkHttpClient,
-        @DownloadServerUrl url: String,
-        gsonConverterFactory: GsonConverterFactory
-    ): AppConfigApiV1 {
-        val cacheSize = 1 * 1024 * 1024L // 1MB
-        val cacheDir = File(context.cacheDir, "http_app-config")
-        val cache = Cache(cacheDir, cacheSize)
-        val cachingClient = client.newBuilder().cache(cache).build()
-        return Retrofit.Builder()
-            .client(cachingClient)
-            .baseUrl(url)
-            .addConverterFactory(gsonConverterFactory)
-            .build()
-            .create(AppConfigApiV1::class.java)
-    }
-
-    @Singleton
-    @DownloadServerUrl
-    @Provides
-    fun provideDownloadServerUrl(): String {
-        val url = BuildConfig.DOWNLOAD_CDN_URL
-        if (!url.startsWith("https://")) throw IllegalStateException("Innvalid: $url")
-        return url
-    }
-
     @Singleton
     @Provides
     fun legacyKeyCacheDao(context: Context): KeyCacheLegacyDao {
         return AppDatabase.getInstance(context).dateDao()
     }
-
-    companion object {
-        private val CDN_CONNECTION_SPECS = listOf(
-            ConnectionSpec.Builder(ConnectionSpec.COMPATIBLE_TLS)
-                .tlsVersions(
-                    TlsVersion.TLS_1_0,
-                    TlsVersion.TLS_1_1,
-                    TlsVersion.TLS_1_2,
-                    TlsVersion.TLS_1_3
-                )
-                .allEnabledCipherSuites()
-                .build()
-        )
-    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServer.kt
deleted file mode 100644
index f5bee9979..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServer.kt
+++ /dev/null
@@ -1,61 +0,0 @@
-package de.rki.coronawarnapp.diagnosiskeys.server
-
-import com.google.protobuf.InvalidProtocolBufferException
-import dagger.Lazy
-import de.rki.coronawarnapp.exception.ApplicationConfigurationCorruptException
-import de.rki.coronawarnapp.exception.ApplicationConfigurationInvalidException
-import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass
-import de.rki.coronawarnapp.util.ZipHelper.unzip
-import de.rki.coronawarnapp.util.security.VerificationKeys
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import timber.log.Timber
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class AppConfigServer @Inject constructor(
-    private val appConfigAPI: Lazy<AppConfigApiV1>,
-    private val verificationKeys: VerificationKeys,
-    @DownloadHomeCountry private val homeCountry: LocationCode
-) {
-
-    private val configApi: AppConfigApiV1
-        get() = appConfigAPI.get()
-
-    suspend fun downloadAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration =
-        withContext(Dispatchers.IO) {
-            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()
-            }
-
-            try {
-                return@withContext ApplicationConfigurationOuterClass.ApplicationConfiguration.parseFrom(
-                    exportBinary
-                )
-            } catch (e: InvalidProtocolBufferException) {
-                throw ApplicationConfigurationInvalidException()
-            }
-        }
-
-    companion object {
-        private val TAG = AppConfigServer::class.java.simpleName
-
-        private const val EXPORT_BINARY_FILE_NAME = "export.bin"
-        private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig"
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt
index acf0c392b..7114ff634 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt
@@ -1,6 +1,7 @@
 package de.rki.coronawarnapp.diagnosiskeys.server
 
 import dagger.Lazy
+import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry
 import de.rki.coronawarnapp.util.HashExtensions.hashToMD5
 import de.rki.coronawarnapp.util.debug.measureTimeMillisWithResult
 import kotlinx.coroutines.Dispatchers
@@ -17,7 +18,7 @@ import javax.inject.Singleton
 @Singleton
 class DiagnosisKeyServer @Inject constructor(
     private val diagnosisKeyAPI: Lazy<DiagnosisKeyApiV1>,
-    @DownloadHomeCountry private val homeCountry: LocationCode
+    @DownloadCDNHomeCountry private val homeCountry: LocationCode
 ) {
 
     private val keyApi: DiagnosisKeyApiV1
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt
new file mode 100644
index 000000000..5d2288500
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt
@@ -0,0 +1,7 @@
+package de.rki.coronawarnapp.environment
+
+import dagger.Module
+import de.rki.coronawarnapp.environment.download.DownloadCDNModule
+
+@Module(includes = [DownloadCDNModule::class])
+class EnvironmentModule
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadServerUrl.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHomeCountry.kt
similarity index 52%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadServerUrl.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHomeCountry.kt
index f347e5333..9ee43e7cf 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadServerUrl.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHomeCountry.kt
@@ -1,8 +1,8 @@
-package de.rki.coronawarnapp.diagnosiskeys.server
+package de.rki.coronawarnapp.environment.download
 
 import javax.inject.Qualifier
 
 @Qualifier
 @MustBeDocumented
 @Retention(AnnotationRetention.RUNTIME)
-annotation class DownloadServerUrl
+annotation class DownloadCDNHomeCountry
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHttpClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHttpClient.kt
similarity index 53%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHttpClient.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHttpClient.kt
index f9536135c..fd7011178 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHttpClient.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNHttpClient.kt
@@ -1,8 +1,8 @@
-package de.rki.coronawarnapp.diagnosiskeys.server
+package de.rki.coronawarnapp.environment.download
 
 import javax.inject.Qualifier
 
 @Qualifier
 @MustBeDocumented
 @Retention(AnnotationRetention.RUNTIME)
-annotation class DownloadHttpClient
+annotation class DownloadCDNHttpClient
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNModule.kt
new file mode 100644
index 000000000..67235fe1a
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNModule.kt
@@ -0,0 +1,50 @@
+package de.rki.coronawarnapp.environment.download
+
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import de.rki.coronawarnapp.BuildConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.http.HttpClientDefault
+import okhttp3.ConnectionSpec
+import okhttp3.OkHttpClient
+import okhttp3.TlsVersion
+import javax.inject.Singleton
+
+@Module
+class DownloadCDNModule {
+
+    @Reusable
+    @DownloadCDNHttpClient
+    @Provides
+    fun cdnHttpClient(@HttpClientDefault defaultHttpClient: OkHttpClient): OkHttpClient =
+        defaultHttpClient.newBuilder().connectionSpecs(DOWNLOAD_CDN_CONNECTION_SPECS).build()
+
+    @Singleton
+    @DownloadCDNServerUrl
+    @Provides
+    fun provideDownloadServerUrl(): String {
+        val url = BuildConfig.DOWNLOAD_CDN_URL
+        if (!url.startsWith("https://")) throw IllegalStateException("Innvalid: $url")
+        return url
+    }
+
+    @Singleton
+    @DownloadCDNHomeCountry
+    @Provides
+    fun provideDiagnosisHomeCountry(): LocationCode = LocationCode("DE")
+
+    companion object {
+        private val DOWNLOAD_CDN_CONNECTION_SPECS = ConnectionSpec
+            .Builder(ConnectionSpec.COMPATIBLE_TLS)
+            .tlsVersions(
+                TlsVersion.TLS_1_0,
+                TlsVersion.TLS_1_1,
+                TlsVersion.TLS_1_2,
+                TlsVersion.TLS_1_3
+            )
+            .allEnabledCipherSuites()
+            .build()
+            .let { listOf(it) }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHomeCountry.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNServerUrl.kt
similarity index 53%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHomeCountry.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNServerUrl.kt
index 69aa0f792..03af297db 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadHomeCountry.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/download/DownloadCDNServerUrl.kt
@@ -1,8 +1,8 @@
-package de.rki.coronawarnapp.diagnosiskeys.server
+package de.rki.coronawarnapp.environment.download
 
 import javax.inject.Qualifier
 
 @Qualifier
 @MustBeDocumented
 @Retention(AnnotationRetention.RUNTIME)
-annotation class DownloadHomeCountry
+annotation class DownloadCDNServerUrl
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
index df4a14e30..4c9e23a42 100644
--- 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
@@ -7,7 +7,7 @@ import de.rki.coronawarnapp.util.di.AppInjector
 
 object ApplicationConfigurationService {
     suspend fun asyncRetrieveApplicationConfiguration(): ApplicationConfiguration {
-        return AppInjector.component.appConfigServer.downloadAppConfig().let {
+        return AppInjector.component.appConfigProvider.getAppConfig().let {
             if (CWADebug.isDebugBuildOrMode) {
                 // TODO: THIS IS A MOCK -> Remove after Backend is providing this information.
                 it.toBuilder()
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 bf416407d..b08ae1e74 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,7 +6,7 @@ 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.exception.ApplicationConfigurationCorruptException
+import de.rki.coronawarnapp.appconfig.ApplicationConfigurationCorruptException
 import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass
 import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.ui.LauncherActivity
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
index 01f3dca1d..cb87334da 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
@@ -5,10 +5,12 @@ import dagger.Component
 import dagger.android.AndroidInjector
 import dagger.android.support.AndroidSupportInjectionModule
 import de.rki.coronawarnapp.CoronaWarnApplication
+import de.rki.coronawarnapp.appconfig.AppConfigModule
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule
 import de.rki.coronawarnapp.diagnosiskeys.download.KeyFileDownloader
-import de.rki.coronawarnapp.diagnosiskeys.server.AppConfigServer
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
+import de.rki.coronawarnapp.environment.EnvironmentModule
 import de.rki.coronawarnapp.http.HttpModule
 import de.rki.coronawarnapp.http.ServiceFactory
 import de.rki.coronawarnapp.nearby.ENFClient
@@ -40,7 +42,9 @@ import javax.inject.Singleton
         DeviceModule::class,
         ENFModule::class,
         HttpModule::class,
-        DiagnosisKeysModule::class
+        EnvironmentModule::class,
+        DiagnosisKeysModule::class,
+        AppConfigModule::class
     ]
 )
 interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
@@ -58,7 +62,7 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
     val keyFileDownloader: KeyFileDownloader
     val serviceFactory: ServiceFactory
 
-    val appConfigServer: AppConfigServer
+    val appConfigProvider: AppConfigProvider
 
     val enfClient: ENFClient
 
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt
similarity index 74%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt
index 4db01f22b..24c628143 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigApiTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt
@@ -1,7 +1,7 @@
-package de.rki.coronawarnapp.diagnosiskeys.server
+package de.rki.coronawarnapp.appconfig
 
 import android.content.Context
-import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule
+import de.rki.coronawarnapp.environment.download.DownloadCDNModule
 import de.rki.coronawarnapp.http.HttpModule
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
@@ -22,19 +22,19 @@ import java.util.concurrent.TimeUnit
 
 class AppConfigApiTest : BaseIOTest() {
 
-    @MockK
-    private lateinit var context: Context
+    @MockK private lateinit var context: Context
 
     private lateinit var webServer: MockWebServer
     private lateinit var serverAddress: String
 
     private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
-    private val appConfigCacheDir = File(testDir, "http_app-config")
+    private val cacheFiles = File(testDir, "cache")
+    private val cacheDir = File(cacheFiles, "http_app-config")
 
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
-        every { context.cacheDir } returns testDir
+        every { context.cacheDir } returns cacheFiles
 
         webServer = MockWebServer()
         webServer.start()
@@ -53,18 +53,18 @@ class AppConfigApiTest : BaseIOTest() {
         val defaultHttpClient = httpModule.defaultHttpClient()
         val gsonConverterFactory = httpModule.provideGSONConverter()
 
-        return DiagnosisKeysModule().let {
-            val downloadHttpClient = it.cdnHttpClient(defaultHttpClient)
-                .newBuilder()
-                .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS))
-                .build()
-            it.provideAppConfigApi(
-                context = context,
-                client = downloadHttpClient,
-                url = serverAddress,
-                gsonConverterFactory = gsonConverterFactory
-            )
-        }
+        val cdnHttpClient = DownloadCDNModule()
+            .cdnHttpClient(defaultHttpClient)
+            .newBuilder()
+            .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS))
+            .build()
+
+        return AppConfigModule().provideAppConfigApi(
+            context = context,
+            client = cdnHttpClient,
+            url = serverAddress,
+            gsonConverterFactory = gsonConverterFactory
+        )
     }
 
     @Test
@@ -84,7 +84,7 @@ class AppConfigApiTest : BaseIOTest() {
 
     @Test
     fun `application config download uses cache`() {
-        appConfigCacheDir.exists() shouldBe false
+        cacheDir.exists() shouldBe false
 
         val api = createAPI()
 
@@ -95,8 +95,8 @@ class AppConfigApiTest : BaseIOTest() {
         runBlocking {
             api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
         }
-        appConfigCacheDir.exists() shouldBe true
-        appConfigCacheDir.listFiles()!!.size shouldBe 3
+        cacheDir.exists() shouldBe true
+        cacheDir.listFiles()!!.size shouldBe 3
 
         webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply {
             method shouldBe "GET"
@@ -107,8 +107,8 @@ class AppConfigApiTest : BaseIOTest() {
         runBlocking {
             api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
         }
-        appConfigCacheDir.exists() shouldBe true
-        appConfigCacheDir.listFiles()!!.size shouldBe 3
+        cacheDir.exists() shouldBe true
+        cacheDir.listFiles()!!.size shouldBe 3
 
         webServer.takeRequest(2, TimeUnit.SECONDS) shouldBe null
 
@@ -118,8 +118,8 @@ class AppConfigApiTest : BaseIOTest() {
         runBlocking {
             api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
         }
-        appConfigCacheDir.exists() shouldBe true
-        appConfigCacheDir.listFiles()!!.size shouldBe 3
+        cacheDir.exists() shouldBe true
+        cacheDir.listFiles()!!.size shouldBe 3
 
         webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply {
             method shouldBe "GET"
@@ -129,7 +129,7 @@ class AppConfigApiTest : BaseIOTest() {
 
     @Test
     fun `cache is used when connection is flaky`() {
-        appConfigCacheDir.exists() shouldBe false
+        cacheDir.exists() shouldBe false
 
         val api = createAPI()
 
@@ -140,8 +140,8 @@ class AppConfigApiTest : BaseIOTest() {
         runBlocking {
             api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
         }
-        appConfigCacheDir.exists() shouldBe true
-        appConfigCacheDir.listFiles()!!.size shouldBe 3
+        cacheDir.exists() shouldBe true
+        cacheDir.listFiles()!!.size shouldBe 3
 
         webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply {
             method shouldBe "GET"
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/AppConfigModuleTest.kt
new file mode 100644
index 000000000..e9917c952
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt
@@ -0,0 +1,48 @@
+package de.rki.coronawarnapp.appconfig
+
+import android.content.Context
+import io.kotest.assertions.throwables.shouldNotThrowAny
+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 AppConfigModuleTest : BaseIOTest() {
+    @MockK
+    private lateinit var context: Context
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val cacheFiles = File(testDir, "cache")
+    private val httpCacheDir = File(cacheFiles, "http_app-config")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { context.cacheDir } returns cacheFiles
+
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createModule() = AppConfigModule()
+
+    @Test
+    fun `sideeffect free instantiation`() {
+        shouldNotThrowAny {
+            createModule()
+        }
+    }
+
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt
new file mode 100644
index 000000000..31df2ce6a
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt
@@ -0,0 +1,213 @@
+package de.rki.coronawarnapp.appconfig
+
+import dagger.Lazy
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+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.verify
+import kotlinx.coroutines.runBlocking
+import okhttp3.ResponseBody.Companion.toResponseBody
+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.BaseIOTest
+import java.io.File
+import java.io.IOException
+
+class AppConfigServerTest : BaseIOTest() {
+
+    @MockK lateinit var api: AppConfigApiV1
+    @MockK lateinit var verificationKeys: VerificationKeys
+    @MockK lateinit var appConfigStorage: AppConfigStorage
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+
+    private val defaultHomeCountry = LocationCode("DE")
+
+    private var mockConfigStorage: ByteArray? = null
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        every { appConfigStorage.appConfigRaw } answers { mockConfigStorage }
+        every { appConfigStorage.appConfigRaw = any() } answers { mockConfigStorage = arg(0) }
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createDownloadServer(
+        homeCountry: LocationCode = defaultHomeCountry
+    ) = AppConfigProvider(
+        appConfigAPI = Lazy { api },
+        verificationKeys = verificationKeys,
+        homeCountry = homeCountry,
+        configStorage = appConfigStorage
+    )
+
+    @Test
+    fun `application config download`() {
+        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody()
+
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
+
+        val downloadServer = createDownloadServer()
+
+        runBlocking {
+            val rawConfig = downloadServer.downloadAppConfig()
+            rawConfig shouldBe APPCONFIG_RAW.toByteArray()
+        }
+
+        verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) }
+    }
+
+    @Test
+    fun `application config data is faulty`() {
+        coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex()
+            .toResponseBody()
+
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
+
+        val downloadServer = createDownloadServer()
+
+        runBlocking {
+            shouldThrow<ApplicationConfigurationInvalidException> {
+                downloadServer.downloadAppConfig()
+            }
+        }
+    }
+
+    @Test
+    fun `application config verification fails`() {
+        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
+            .toResponseBody()
+
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
+
+        val downloadServer = createDownloadServer()
+
+        runBlocking {
+            shouldThrow<ApplicationConfigurationCorruptException> {
+                downloadServer.downloadAppConfig()
+            }
+        }
+    }
+
+    @Test
+    fun `successful download stores new config`() {
+        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
+            .toResponseBody()
+
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
+
+        val downloadServer = createDownloadServer()
+
+        runBlocking {
+            downloadServer.getAppConfig()
+
+            mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
+            verify { appConfigStorage.appConfigRaw = APPCONFIG_RAW.toByteArray() }
+        }
+    }
+
+    @Test
+    fun `failed download doesn't overwrite valid config`() {
+        mockConfigStorage = APPCONFIG_RAW.toByteArray()
+        coEvery { api.getApplicationConfiguration("DE") } throws IOException()
+
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
+
+        runBlocking {
+            createDownloadServer().getAppConfig()
+        }
+
+        verify(exactly = 0) { appConfigStorage.appConfigRaw = any() }
+        mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
+    }
+
+    @Test
+    fun `failed verification doesn't overwrite valid config`() {
+        mockConfigStorage = APPCONFIG_RAW.toByteArray()
+        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
+            .toResponseBody()
+
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
+
+        runBlocking {
+            createDownloadServer().getAppConfig()
+        }
+
+        verify(exactly = 0) { appConfigStorage.appConfigRaw = any() }
+        mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
+    }
+
+    @Test
+    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()
+        runBlocking {
+            createDownloadServer().getAppConfig().minRiskScore shouldBe 11
+        }
+
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
+        runBlocking {
+            createDownloadServer().getAppConfig().minRiskScore shouldBe 11
+        }
+    }
+
+    @Test
+    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
+
+        runBlocking {
+            createDownloadServer().getAppConfig().minRiskScore shouldBe 11
+        }
+    }
+
+    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()
+    }
+}
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
new file mode 100644
index 000000000..aaecb0b01
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt
@@ -0,0 +1,72 @@
+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
+    fun `simple read and write config`() {
+        configPath.exists() shouldBe false
+        val storage = createStorage()
+        configPath.exists() shouldBe false
+
+        storage.appConfigRaw = testByteArray
+
+        configPath.exists() shouldBe true
+        configPath.readBytes() shouldBe testByteArray
+
+        storage.appConfigRaw shouldBe testByteArray
+    }
+
+    @Test
+    fun `nulling and overwriting`() {
+        val storage = createStorage()
+        configPath.exists() shouldBe false
+
+        storage.appConfigRaw shouldBe null
+        storage.appConfigRaw = null
+        configPath.exists() shouldBe false
+
+        storage.appConfigRaw shouldBe null
+        storage.appConfigRaw = testByteArray
+        storage.appConfigRaw shouldBe testByteArray
+        configPath.exists() shouldBe true
+        configPath.readBytes() shouldBe testByteArray
+
+        storage.appConfigRaw = null
+        storage.appConfigRaw shouldBe null
+        configPath.exists() shouldBe false
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModuleTest.kt
index 353312909..6c8049eba 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModuleTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/DiagnosisKeysModuleTest.kt
@@ -1,23 +1,17 @@
 package de.rki.coronawarnapp.diagnosiskeys
 
-import de.rki.coronawarnapp.BuildConfig
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import io.kotest.matchers.shouldBe
+import io.kotest.assertions.throwables.shouldNotThrowAny
 import org.junit.jupiter.api.Test
-import testhelpers.BaseIOTest
+import testhelpers.BaseTest
 
-class DiagnosisKeysModuleTest : BaseIOTest() {
+class DiagnosisKeysModuleTest : BaseTest() {
 
-    private val module = DiagnosisKeysModule()
+    private fun createModule() = DiagnosisKeysModule()
 
     @Test
-    fun `home country should be DE`() {
-        module.provideDiagnosisHomeCountry() shouldBe LocationCode("DE")
+    fun `sideeffect free instantiation`() {
+        shouldNotThrowAny {
+            createModule()
+        }
     }
-
-    @Test
-    fun `download URL comes from BuildConfig`() {
-        module.provideDownloadServerUrl() shouldBe BuildConfig.DOWNLOAD_CDN_URL
-    }
-
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServerTest.kt
deleted file mode 100644
index 0fbd9e4c8..000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/AppConfigServerTest.kt
+++ /dev/null
@@ -1,131 +0,0 @@
-package de.rki.coronawarnapp.diagnosiskeys.server
-
-import dagger.Lazy
-import de.rki.coronawarnapp.exception.ApplicationConfigurationCorruptException
-import de.rki.coronawarnapp.exception.ApplicationConfigurationInvalidException
-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.verify
-import kotlinx.coroutines.runBlocking
-import okhttp3.ResponseBody.Companion.toResponseBody
-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.BaseIOTest
-import java.io.File
-
-class AppConfigServerTest : BaseIOTest() {
-
-    @MockK
-    lateinit var api: AppConfigApiV1
-
-    @MockK
-    lateinit var verificationKeys: VerificationKeys
-
-    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
-    }
-
-    @AfterEach
-    fun teardown() {
-        clearAllMocks()
-        testDir.deleteRecursively()
-    }
-
-    private fun createDownloadServer(
-        homeCountry: LocationCode = defaultHomeCountry
-    ) = AppConfigServer(
-        appConfigAPI = Lazy { api },
-        verificationKeys = verificationKeys,
-        homeCountry = homeCountry
-    )
-
-    @Test
-    fun `application config download`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_HEX.decodeHex()
-            .toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        val downloadServer = createDownloadServer()
-
-        runBlocking {
-            val config = downloadServer.downloadAppConfig()
-            config.apply {
-                // We just care here that it's non default values, i.e. conversion worked
-                minRiskScore shouldBe 11
-                appVersion.android.latest.major shouldBe 1
-                appVersion.android.latest.minor shouldBe 0
-                appVersion.android.latest.patch shouldBe 4
-
-            }
-        }
-
-        verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) }
-    }
-
-    @Test
-    fun `application config data is faulty`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex()
-            .toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        val downloadServer = createDownloadServer()
-
-        runBlocking {
-            shouldThrow<ApplicationConfigurationInvalidException> {
-                downloadServer.downloadAppConfig()
-            }
-        }
-    }
-
-    @Test
-    fun `application config verification fails`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_HEX.decodeHex()
-            .toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
-
-        val downloadServer = createDownloadServer()
-
-        runBlocking {
-            shouldThrow<ApplicationConfigurationCorruptException> {
-                downloadServer.downloadAppConfig()
-            }
-        }
-    }
-
-    companion object {
-        private const val APPCONFIG_HEX =
-            "504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" +
-                    "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" +
-                    "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" +
-                    "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" +
-                    "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" +
-                    "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" +
-                    "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" +
-                    "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" +
-                    "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" +
-                    "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" +
-                    "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" +
-                    "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" +
-                    "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" +
-                    "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" +
-                    "0200020070000000ae0100000000"
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt
index 2c6c522ed..61255ae4d 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt
@@ -1,6 +1,7 @@
 package de.rki.coronawarnapp.diagnosiskeys.server
 
 import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule
+import de.rki.coronawarnapp.environment.download.DownloadCDNModule
 import de.rki.coronawarnapp.http.HttpModule
 import io.kotest.matchers.shouldBe
 import kotlinx.coroutines.runBlocking
@@ -35,17 +36,17 @@ class DiagnosisKeyApiTest : BaseIOTest() {
         val defaultHttpClient = httpModule.defaultHttpClient()
         val gsonConverterFactory = httpModule.provideGSONConverter()
 
-        return DiagnosisKeysModule().let {
-            val downloadHttpClient = it.cdnHttpClient(defaultHttpClient)
-                .newBuilder()
-                .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS))
-                .build()
-            it.provideDiagnosisKeyApi(
-                client = downloadHttpClient,
-                url = serverAddress,
-                gsonConverterFactory = gsonConverterFactory
-            )
-        }
+        val cdnHttpClient = DownloadCDNModule()
+            .cdnHttpClient(defaultHttpClient)
+            .newBuilder()
+            .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS))
+            .build()
+
+        return DiagnosisKeysModule().provideDiagnosisKeyApi(
+            client = cdnHttpClient,
+            url = serverAddress,
+            gsonConverterFactory = gsonConverterFactory
+        )
     }
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentModuleTest.kt
new file mode 100644
index 000000000..d28841931
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentModuleTest.kt
@@ -0,0 +1,17 @@
+package de.rki.coronawarnapp.environment
+
+import io.kotest.assertions.throwables.shouldNotThrowAny
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class EnvironmentModuleTest : BaseTest() {
+
+    private fun createModule() = EnvironmentModule()
+
+    @Test
+    fun `sideeffect free instantiation`() {
+        shouldNotThrowAny {
+            createModule()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/download/DownloadCDNModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/download/DownloadCDNModuleTest.kt
new file mode 100644
index 000000000..961f4a2cf
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/download/DownloadCDNModuleTest.kt
@@ -0,0 +1,32 @@
+package de.rki.coronawarnapp.environment.download
+
+import de.rki.coronawarnapp.BuildConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import io.kotest.assertions.throwables.shouldNotThrowAny
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+
+class DownloadCDNModuleTest : BaseIOTest() {
+
+    private fun createModule() = DownloadCDNModule()
+
+    @Test
+    fun `sideeffect free instantiation`() {
+        shouldNotThrowAny {
+            createModule()
+        }
+    }
+
+    @Test
+    fun `home country should be DE`() {
+        val module = createModule()
+        module.provideDiagnosisHomeCountry() shouldBe LocationCode("DE")
+    }
+
+    @Test
+    fun `download URL comes from BuildConfig`() {
+        val module = createModule()
+        module.provideDownloadServerUrl() shouldBe BuildConfig.DOWNLOAD_CDN_URL
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt
index 06f4368a0..eca0ea6cb 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationServiceTest.kt
@@ -1,6 +1,6 @@
 package de.rki.coronawarnapp.service.applicationconfiguration
 
-import de.rki.coronawarnapp.diagnosiskeys.server.AppConfigServer
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.http.WebRequestBuilder
 import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass
 import de.rki.coronawarnapp.util.CWADebug
@@ -38,12 +38,12 @@ class ApplicationConfigurationServiceTest : BaseTest() {
 
         every { appConfigBuilder.build() } returns appConfig
 
-        val downloadServer = mockk<AppConfigServer>()
-        coEvery { downloadServer.downloadAppConfig() } returns appConfig
+        val downloadServer = mockk<AppConfigProvider>()
+        coEvery { downloadServer.getAppConfig() } returns appConfig
 
         mockkObject(AppInjector)
         mockk<ApplicationComponent>().apply {
-            every { this@apply.appConfigServer } returns downloadServer
+            every { this@apply.appConfigProvider } returns downloadServer
             every { AppInjector.component } returns this@apply
         }
 
-- 
GitLab