diff --git a/Corona-Warn-App/proguard-rules.pro b/Corona-Warn-App/proguard-rules.pro
index 067b7d21c059535c5904f766d6e1ed0dd58f1a9c..b6efd7f86501984cf30beb1eb661ba51d78a0463 100644
--- a/Corona-Warn-App/proguard-rules.pro
+++ b/Corona-Warn-App/proguard-rules.pro
@@ -57,7 +57,4 @@
 # With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy
 # and replaces all potential values with null. Explicitly keeping the interfaces prevents this.
 -if interface * { @retrofit2.http.* <methods>; }
--keep,allowobfuscation interface <1>
-
--keep class de.rki.coronawarnapp.http.requests.* { *; }
--keep class de.rki.coronawarnapp.http.responses.* { *; }
\ No newline at end of file
+-keep,allowobfuscation interface <1>
\ No newline at end of file
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 c7c45d7babc6cc52af544b1390ee3c23df91ad01..6f8e10d248095717d40e0de6284c41a731b541bc 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
@@ -33,7 +33,6 @@ import com.google.zxing.BarcodeFormat
 import com.google.zxing.integration.android.IntentIntegrator
 import com.google.zxing.integration.android.IntentResult
 import com.google.zxing.qrcode.QRCodeWriter
-import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.RiskLevelAndKeyRetrievalBenchmark
 import de.rki.coronawarnapp.databinding.FragmentTestForAPIBinding
@@ -55,6 +54,7 @@ import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.tracing.TracingIntervalRepository
 import de.rki.coronawarnapp.transaction.RiskLevelTransaction
 import de.rki.coronawarnapp.ui.viewmodel.TracingViewModel
+import de.rki.coronawarnapp.util.CWADebug
 import de.rki.coronawarnapp.util.KeyFileHelper
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.AutoInject
@@ -298,9 +298,9 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
             false
         }
 
-        binding.testLogfileToggle.isChecked = CoronaWarnApplication.fileLogger?.isLogging ?: false
+        binding.testLogfileToggle.isChecked = CWADebug.fileLogger?.isLogging ?: false
         binding.testLogfileToggle.setOnClickListener { buttonView ->
-            CoronaWarnApplication.fileLogger?.let {
+            CWADebug.fileLogger?.let {
                 if (binding.testLogfileToggle.isChecked) {
                     it.start()
                 } else {
@@ -310,7 +310,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
         }
 
         binding.testLogfileShare.setOnClickListener {
-            CoronaWarnApplication.fileLogger?.let {
+            CWADebug.fileLogger?.let {
                 lifecycleScope.launch {
                     val targetPath = withContext(Dispatchers.IO) {
                         async {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
index 184ec476975eeee93a524b35091a2208054fc6ad..abeedeea482c5d135171cb36b9cd80306fd62db4 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
@@ -6,14 +6,11 @@ import android.app.Application
 import android.content.Context
 import android.content.IntentFilter
 import android.content.pm.ActivityInfo
-import android.net.wifi.WifiManager
 import android.os.Bundle
-import android.os.PowerManager
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.LifecycleObserver
 import androidx.lifecycle.OnLifecycleEvent
 import androidx.lifecycle.ProcessLifecycleOwner
-import androidx.lifecycle.lifecycleScope
 import androidx.localbroadcastmanager.content.LocalBroadcastManager
 import androidx.work.Configuration
 import androidx.work.WorkManager
@@ -23,187 +20,115 @@ import dagger.android.HasAndroidInjector
 import de.rki.coronawarnapp.exception.reporting.ErrorReportReceiver
 import de.rki.coronawarnapp.exception.reporting.ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL
 import de.rki.coronawarnapp.notification.NotificationHelper
-import de.rki.coronawarnapp.storage.LocalData
-import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction
 import de.rki.coronawarnapp.util.CWADebug
-import de.rki.coronawarnapp.util.ConnectivityHelper
-import de.rki.coronawarnapp.util.debug.FileLogger
+import de.rki.coronawarnapp.util.WatchdogService
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.ApplicationComponent
 import de.rki.coronawarnapp.worker.BackgroundWorkHelper
-import de.rki.coronawarnapp.worker.BackgroundWorkScheduler
-import kotlinx.coroutines.launch
 import org.conscrypt.Conscrypt
 import timber.log.Timber
 import java.security.Security
-import java.util.UUID
 import javax.inject.Inject
 
-class CoronaWarnApplication : Application(), LifecycleObserver,
-    Application.ActivityLifecycleCallbacks, HasAndroidInjector {
+class CoronaWarnApplication : Application(), HasAndroidInjector {
 
-    companion object {
-        val TAG: String? = CoronaWarnApplication::class.simpleName
-        private lateinit var instance: CoronaWarnApplication
-
-        /* describes if the app is in foreground
-         * Initialized to false, because app could also be started by a background job.
-         * For the cases where the app is started via the launcher icon, the onAppForegrounded
-         * event will be called, setting it to true
-         */
-        var isAppInForeground = false
-
-        fun getAppContext(): Context =
-            instance.applicationContext
-
-        const val TEN_MINUTE_TIMEOUT_IN_MS = 10 * 60 * 1000L
-        var fileLogger: FileLogger? = null
-    }
+    @Inject lateinit var component: ApplicationComponent
 
-    private lateinit var errorReceiver: ErrorReportReceiver
-
-    @Inject
-    lateinit var component: ApplicationComponent
-
-    @Inject
-    lateinit var androidInjector: DispatchingAndroidInjector<Any>
+    @Inject lateinit var androidInjector: DispatchingAndroidInjector<Any>
     override fun androidInjector(): AndroidInjector<Any> = androidInjector
 
+    @Inject lateinit var watchdogService: WatchdogService
+
     override fun onCreate() {
         instance = this
         super.onCreate()
-        AppInjector.init(this)
+        CWADebug.init(this)
 
-        if (CWADebug.isDebugBuildOrMode) System.setProperty("kotlinx.coroutines.debug", "on")
+        Timber.v("onCreate(): Initializing Dagger")
+        AppInjector.init(this)
 
-        val configuration = Configuration.Builder()
-            .setMinimumLoggingLevel(android.util.Log.DEBUG)
-            .build()
-        WorkManager.initialize(this, configuration)
+        Timber.v("onCreate(): Initializing WorkManager")
+        Configuration.Builder()
+            .apply { setMinimumLoggingLevel(android.util.Log.DEBUG) }.build()
+            .let { WorkManager.initialize(this, it) }
 
         NotificationHelper.createNotificationChannel()
+
         // Enable Conscrypt for TLS1.3 Support below API Level 29
         Security.insertProviderAt(Conscrypt.newProvider(), 1)
-        ProcessLifecycleOwner.get().lifecycle.addObserver(this)
-        registerActivityLifecycleCallbacks(this)
 
-        if (BuildConfig.DEBUG) {
-            Timber.plant(Timber.DebugTree())
-        }
-        if ((BuildConfig.FLAVOR == "deviceForTesters" || BuildConfig.DEBUG)) {
-            fileLogger = FileLogger(this)
-        }
+        ProcessLifecycleOwner.get().lifecycle.addObserver(foregroundStateUpdater)
+        registerActivityLifecycleCallbacks(activityLifecycleCallback)
 
-        // notification to test the WakeUpService from Google when the app
-        // was force stopped
+        // notification to test the WakeUpService from Google when the app was force stopped
         BackgroundWorkHelper.sendDebugNotification(
             "Application onCreate", "App was woken up"
         )
-        // Only do this if the background jobs are enabled
-        if (ConnectivityHelper.autoModeEnabled(applicationContext)) {
-            ProcessLifecycleOwner.get().lifecycleScope.launch {
-                // we want a wakelock as the OS does not handle this for us like in the background
-                // job execution
-                val wakeLock = createWakeLock()
-                // we keep a wifi lock to wake up the wifi connection in case the device is dozing
-                val wifiLock = createWifiLock()
-                try {
-                    BackgroundWorkHelper.sendDebugNotification(
-                        "Automatic mode is on", "Check if we have downloaded keys already today"
-                    )
-                    RetrieveDiagnosisKeysTransaction.startWithConstraints()
-                } catch (e: Exception) {
-                    BackgroundWorkHelper.sendDebugNotification(
-                        "RetrieveDiagnosisKeysTransaction failed",
-                        (e.localizedMessage
-                            ?: "Unknown exception occurred in onCreate") + "\n\n" + (e.cause
-                            ?: "Cause is unknown").toString()
-                    )
-                    // retry the key retrieval in case of an error with a scheduled work
-                    BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()
-                }
-
-                if (wifiLock.isHeld) wifiLock.release()
-                if (wakeLock.isHeld) wakeLock.release()
-            }
+        watchdogService.launch()
+    }
+
+    private val foregroundStateUpdater = object : LifecycleObserver {
+        @OnLifecycleEvent(Lifecycle.Event.ON_START)
+        fun onAppForegrounded() {
+            isAppInForeground = true
+            Timber.v("App is in the foreground")
+        }
 
-            // if the user is onboarded we will schedule period background jobs
-            // in case the app was force stopped and woken up again by the Google WakeUpService
-            if (LocalData.onboardingCompletedTimestamp() != null) BackgroundWorkScheduler.startWorkScheduler()
+        @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
+        fun onAppBackgrounded() {
+            isAppInForeground = false
+            Timber.v("App is in the background")
         }
     }
 
-    private fun createWakeLock(): PowerManager.WakeLock =
-        (getSystemService(Context.POWER_SERVICE) as PowerManager)
-            .run {
-                newWakeLock(
-                    PowerManager.PARTIAL_WAKE_LOCK,
-                    TAG + "-WAKE-" + UUID.randomUUID().toString()
-                ).apply {
-                    acquire(TEN_MINUTE_TIMEOUT_IN_MS)
-                }
-            }
+    private val activityLifecycleCallback = object : ActivityLifecycleCallbacks {
+        private val localBM by lazy {
+            LocalBroadcastManager.getInstance(this@CoronaWarnApplication)
+        }
+        private var errorReceiver: ErrorReportReceiver? = null
 
-    private fun createWifiLock(): WifiManager.WifiLock =
-        (getSystemService(Context.WIFI_SERVICE) as WifiManager)
-            .run {
-                createWifiLock(
-                    WifiManager.WIFI_MODE_FULL_HIGH_PERF,
-                    TAG + "-WIFI-" + UUID.randomUUID().toString()
-                ).apply {
-                    acquire()
-                }
+        override fun onActivityPaused(activity: Activity) {
+            errorReceiver?.let {
+                localBM.unregisterReceiver(it)
+                errorReceiver = null
             }
+        }
 
-    /**
-     * Callback when the app is open but backgrounded
-     */
-    @OnLifecycleEvent(Lifecycle.Event.ON_STOP)
-    fun onAppBackgrounded() {
-        isAppInForeground = false
-        Timber.v("App backgrounded")
-    }
+        override fun onActivityStarted(activity: Activity) {}
 
-    /**
-     * Callback when the app is foregrounded
-     */
-    @OnLifecycleEvent(Lifecycle.Event.ON_START)
-    fun onAppForegrounded() {
-        isAppInForeground = true
-        Timber.v("App foregrounded")
-    }
+        override fun onActivityDestroyed(activity: Activity) {}
 
-    override fun onActivityPaused(activity: Activity) {
-        // unregisters error receiver
-        LocalBroadcastManager.getInstance(this).unregisterReceiver(errorReceiver)
-    }
+        override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
 
-    override fun onActivityStarted(activity: Activity) {
-        // does not override function. Empty on intention
-    }
+        override fun onActivityStopped(activity: Activity) {}
 
-    override fun onActivityDestroyed(activity: Activity) {
-        // does not override function. Empty on intention
-    }
+        @SuppressLint("SourceLockedOrientationActivity")
+        override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
+            activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
+        }
 
-    override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
-        // does not override function. Empty on intention
-    }
+        override fun onActivityResumed(activity: Activity) {
+            errorReceiver?.let {
+                localBM.unregisterReceiver(it)
+                errorReceiver = null
+            }
 
-    override fun onActivityStopped(activity: Activity) {
-        // does not override function. Empty on intention
+            errorReceiver = ErrorReportReceiver(activity).also {
+                localBM.registerReceiver(it, IntentFilter(ERROR_REPORT_LOCAL_BROADCAST_CHANNEL))
+            }
+        }
     }
 
-    @SuppressLint("SourceLockedOrientationActivity")
-    override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
-        // set screen orientation to portrait
-        activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
-    }
+    companion object {
+        private lateinit var instance: CoronaWarnApplication
+
+        /* describes if the app is in foreground
+         * Initialized to false, because app could also be started by a background job.
+         * For the cases where the app is started via the launcher icon, the onAppForegrounded
+         * event will be called, setting it to true
+         */
+        var isAppInForeground = false
 
-    override fun onActivityResumed(activity: Activity) {
-        errorReceiver =
-            ErrorReportReceiver(activity)
-        LocalBroadcastManager.getInstance(this)
-            .registerReceiver(errorReceiver, IntentFilter(ERROR_REPORT_LOCAL_BROADCAST_CHANNEL))
+        fun getAppContext(): Context = instance.applicationContext
     }
 }
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 72130ea55e1d9c2017e172e378f6f76767ac7581..796f19a123ce9fd8886172f8389ebfb122aff32a 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
@@ -4,7 +4,7 @@ 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.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration
 import de.rki.coronawarnapp.util.ZipHelper.unzip
 import de.rki.coronawarnapp.util.security.VerificationKeys
 import kotlinx.coroutines.Dispatchers
@@ -47,7 +47,13 @@ class AppConfigProvider @Inject constructor(
         return exportBinary!!
     }
 
-    private suspend fun getNewAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration? {
+    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) {
@@ -68,7 +74,7 @@ class AppConfigProvider @Inject constructor(
         }
     }
 
-    private fun getFallback(): ApplicationConfigurationOuterClass.ApplicationConfiguration {
+    private fun getFallback(): ApplicationConfiguration {
         val lastValidConfig = tryParseConfig(configStorage.appConfigRaw)
         return if (lastValidConfig != null) {
             Timber.d("Using fallback AppConfig.")
@@ -79,22 +85,15 @@ class AppConfigProvider @Inject constructor(
         }
     }
 
-    suspend fun getAppConfig(): ApplicationConfigurationOuterClass.ApplicationConfiguration =
-        withContext(Dispatchers.IO) {
-            val newAppConfig = getNewAppConfig()
+    suspend fun getAppConfig(): ApplicationConfiguration = withContext(Dispatchers.IO) {
+        val newAppConfig = getNewAppConfig()
 
-            return@withContext if (newAppConfig != null) {
-                newAppConfig
-            } else {
-                Timber.w("No new config available, using last valid.")
-                getFallback()
-            }
+        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 {
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/ApplicationConfigurationExtensions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..48423b587ce0079e958f1d01b16700370ed79a7d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt
@@ -0,0 +1,11 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration
+
+fun ApplicationConfiguration.toNewConfig(
+    action: ApplicationConfiguration.Builder.() -> Unit
+): ApplicationConfiguration {
+    val builder = this.toBuilder()
+    action(builder)
+    return builder.build()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt
index dea26f41e610798f6dfcb8c3ed1ba958c1303365..1ca6cf28a1157c070f1f1213bf4aef857228de91 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt
@@ -17,9 +17,6 @@ import javax.inject.Singleton
 class EnvironmentSetup @Inject constructor(
     private val context: Context
 ) {
-    companion object {
-        private const val PKEY_CURRENT_ENVINROMENT = "environment.current"
-    }
 
     enum class ENVKEY(val rawKey: String) {
         SUBMISSION("SUBMISSION_CDN_URL"),
@@ -101,4 +98,8 @@ class EnvironmentSetup @Inject constructor(
     private fun String.toEnvironmentType(): Type = Type.values().single {
         it.rawKey == this
     }
+
+    companion object {
+        private const val PKEY_CURRENT_ENVINROMENT = "environment.current"
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/HttpModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/HttpModule.kt
index a8c68ad05ffbdfa4555d2d91188bc72235dc7d04..a921a828fdc1e36ebdeb51188ed4553e94a64cfd 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/HttpModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/HttpModule.kt
@@ -8,8 +8,11 @@ import de.rki.coronawarnapp.http.config.HTTPVariables
 import de.rki.coronawarnapp.http.interceptor.RetryInterceptor
 import de.rki.coronawarnapp.http.interceptor.WebSecurityVerificationInterceptor
 import de.rki.coronawarnapp.risk.TimeVariables
+import okhttp3.CipherSuite
+import okhttp3.ConnectionSpec
 import okhttp3.Interceptor
 import okhttp3.OkHttpClient
+import okhttp3.TlsVersion
 import okhttp3.logging.HttpLoggingInterceptor
 import retrofit2.converter.gson.GsonConverterFactory
 import retrofit2.converter.protobuf.ProtoConverterFactory
@@ -52,5 +55,40 @@ class HttpModule {
 
     @Reusable
     @Provides
-    fun provideProtoonverter(): ProtoConverterFactory = ProtoConverterFactory.create()
+    fun provideProtoConverter(): ProtoConverterFactory = ProtoConverterFactory.create()
+
+    @Reusable
+    @RestrictedConnectionSpecs
+    @Provides
+    fun restrictedConnectionSpecs(): List<ConnectionSpec> = ConnectionSpec
+        .Builder(ConnectionSpec.RESTRICTED_TLS)
+        .tlsVersions(
+            TlsVersion.TLS_1_2,
+            TlsVersion.TLS_1_3
+        )
+        .cipherSuites(
+            // TLS 1.2 with Perfect Forward Secrecy (BSI TR-02102-2)
+            CipherSuite.TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256,
+            CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384,
+            CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
+            CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
+            CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,
+            CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384,
+            CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
+            CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
+            CipherSuite.TLS_DHE_DSS_WITH_AES_128_CBC_SHA256,
+            CipherSuite.TLS_DHE_DSS_WITH_AES_256_CBC_SHA256,
+            CipherSuite.TLS_DHE_DSS_WITH_AES_128_GCM_SHA256,
+            CipherSuite.TLS_DHE_DSS_WITH_AES_256_GCM_SHA384,
+            CipherSuite.TLS_DHE_RSA_WITH_AES_128_CBC_SHA256,
+            CipherSuite.TLS_DHE_RSA_WITH_AES_256_CBC_SHA256,
+            CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256,
+            CipherSuite.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384,
+            // TLS 1.3 (BSI TR-02102-2)
+            CipherSuite.TLS_AES_128_GCM_SHA256,
+            CipherSuite.TLS_AES_256_GCM_SHA384,
+            CipherSuite.TLS_AES_128_CCM_SHA256
+        )
+        .build()
+        .let { listOf(it) }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/RestrictedConnectionSpecs.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/RestrictedConnectionSpecs.kt
new file mode 100644
index 0000000000000000000000000000000000000000..396eb9a39d0cc47c463a2e8e67369d9eaf019b74
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/RestrictedConnectionSpecs.kt
@@ -0,0 +1,8 @@
+package de.rki.coronawarnapp.http
+
+import javax.inject.Qualifier
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class RestrictedConnectionSpecs
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/ServiceFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/ServiceFactory.kt
deleted file mode 100644
index b659b337a6f947cc4ed110b679b17ded3f44bd0d..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/ServiceFactory.kt
+++ /dev/null
@@ -1,101 +0,0 @@
-package de.rki.coronawarnapp.http
-
-import de.rki.coronawarnapp.CoronaWarnApplication
-import de.rki.coronawarnapp.environment.submission.SubmissionCDNServerUrl
-import de.rki.coronawarnapp.environment.verification.VerificationCDNServerUrl
-import de.rki.coronawarnapp.http.service.SubmissionService
-import de.rki.coronawarnapp.http.service.VerificationService
-import okhttp3.Cache
-import okhttp3.CipherSuite
-import okhttp3.ConnectionSpec
-import okhttp3.OkHttpClient
-import okhttp3.TlsVersion
-import retrofit2.Retrofit
-import retrofit2.converter.gson.GsonConverterFactory
-import retrofit2.converter.protobuf.ProtoConverterFactory
-import java.io.File
-import javax.inject.Inject
-
-class ServiceFactory @Inject constructor(
-    @VerificationCDNServerUrl private val verificationCdnUrl: String,
-    @SubmissionCDNServerUrl private val submissionCdnUrl: String,
-    private val gsonConverterFactory: GsonConverterFactory,
-    private val protoConverterFactory: ProtoConverterFactory,
-    @HttpClientDefault private val defaultHttpClient: OkHttpClient
-) {
-
-    private val cache by lazy {
-        Cache(
-            directory = File(CoronaWarnApplication.getAppContext().cacheDir, HTTP_CACHE_FOLDER),
-            maxSize = HTTP_CACHE_SIZE
-        )
-    }
-    private val okHttpClient by lazy {
-        defaultHttpClient
-            .newBuilder()
-            .connectionSpecs(getRestrictedSpecs())
-            .cache(cache)
-            .build()
-    }
-
-    fun verificationService(): VerificationService = verificationService
-    private val verificationService by lazy {
-        Retrofit.Builder()
-            .client(okHttpClient)
-            .baseUrl(verificationCdnUrl)
-            .addConverterFactory(gsonConverterFactory)
-            .build()
-            .create(VerificationService::class.java)
-    }
-
-    fun submissionService(): SubmissionService = submissionService
-    private val submissionService by lazy {
-        Retrofit.Builder()
-            .client(okHttpClient)
-            .baseUrl(submissionCdnUrl)
-            .addConverterFactory(protoConverterFactory)
-            .addConverterFactory(gsonConverterFactory)
-            .build()
-            .create(SubmissionService::class.java)
-    }
-
-    companion object {
-        private const val HTTP_CACHE_SIZE = 10L * 1024L * 1024L // 10 MiB
-        private const val HTTP_CACHE_FOLDER = "http_cache" // <pkg>/cache/http_cache
-
-        /**
-         * For Submission and Verification we want to limit our specifications for TLS.
-         */
-        private fun getRestrictedSpecs(): List<ConnectionSpec> = listOf(
-            ConnectionSpec.Builder(ConnectionSpec.RESTRICTED_TLS)
-                .tlsVersions(
-                    TlsVersion.TLS_1_2,
-                    TlsVersion.TLS_1_3
-                )
-                .cipherSuites(
-                    // TLS 1.2 with Perfect Forward Secrecy (BSI TR-02102-2)
-                    CipherSuite.TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256,
-                    CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384,
-                    CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
-                    CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
-                    CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,
-                    CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384,
-                    CipherSuite.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
-                    CipherSuite.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
-                    CipherSuite.TLS_DHE_DSS_WITH_AES_128_CBC_SHA256,
-                    CipherSuite.TLS_DHE_DSS_WITH_AES_256_CBC_SHA256,
-                    CipherSuite.TLS_DHE_DSS_WITH_AES_128_GCM_SHA256,
-                    CipherSuite.TLS_DHE_DSS_WITH_AES_256_GCM_SHA384,
-                    CipherSuite.TLS_DHE_RSA_WITH_AES_128_CBC_SHA256,
-                    CipherSuite.TLS_DHE_RSA_WITH_AES_256_CBC_SHA256,
-                    CipherSuite.TLS_DHE_RSA_WITH_AES_128_GCM_SHA256,
-                    CipherSuite.TLS_DHE_RSA_WITH_AES_256_GCM_SHA384,
-                    // TLS 1.3 (BSI TR-02102-2)
-                    CipherSuite.TLS_AES_128_GCM_SHA256,
-                    CipherSuite.TLS_AES_256_GCM_SHA384,
-                    CipherSuite.TLS_AES_128_CCM_SHA256
-                )
-                .build()
-        )
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt
deleted file mode 100644
index d0bee06128aef56f255f3da69fdf383a135d8db6..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/WebRequestBuilder.kt
+++ /dev/null
@@ -1,179 +0,0 @@
-/******************************************************************************
- * Corona-Warn-App                                                            *
- *                                                                            *
- * SAP SE and all other contributors /                                        *
- * copyright owners license this file to you under the Apache                 *
- * License, Version 2.0 (the "License"); you may not use this                 *
- * file except in compliance with the License.                                *
- * You may obtain a copy of the License at                                    *
- *                                                                            *
- * http://www.apache.org/licenses/LICENSE-2.0                                 *
- *                                                                            *
- * Unless required by applicable law or agreed to in writing,                 *
- * software distributed under the License is distributed on an                *
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY                     *
- * KIND, either express or implied.  See the License for the                  *
- * specific language governing permissions and limitations                    *
- * under the License.                                                         *
- ******************************************************************************/
-
-package de.rki.coronawarnapp.http
-
-import KeyExportFormat
-import com.google.protobuf.ByteString
-import de.rki.coronawarnapp.http.requests.RegistrationRequest
-import de.rki.coronawarnapp.http.requests.RegistrationTokenRequest
-import de.rki.coronawarnapp.http.requests.TanRequestBody
-import de.rki.coronawarnapp.http.service.SubmissionService
-import de.rki.coronawarnapp.http.service.VerificationService
-import de.rki.coronawarnapp.service.diagnosiskey.DiagnosisKeyConstants
-import de.rki.coronawarnapp.service.submission.KeyType
-import de.rki.coronawarnapp.service.submission.SubmissionConstants
-import de.rki.coronawarnapp.util.di.AppInjector
-import de.rki.coronawarnapp.util.security.HashHelper
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import timber.log.Timber
-import kotlin.math.max
-
-class WebRequestBuilder(
-    private val verificationService: VerificationService,
-    private val submissionService: SubmissionService
-) {
-    companion object {
-        private val TAG: String? = WebRequestBuilder::class.simpleName
-
-        @Volatile
-        private var instance: WebRequestBuilder? = null
-
-        fun getInstance(): WebRequestBuilder {
-            return instance ?: synchronized(this) {
-                instance ?: buildWebRequestBuilder().also { instance = it }
-            }
-        }
-
-        private fun buildWebRequestBuilder(): WebRequestBuilder {
-            val serviceFactory = AppInjector.component.serviceFactory
-            return WebRequestBuilder(
-                serviceFactory.verificationService(),
-                serviceFactory.submissionService()
-            )
-        }
-    }
-
-    suspend fun asyncGetRegistrationToken(
-        key: String,
-        keyType: KeyType
-    ): String = withContext(Dispatchers.IO) {
-        val keyStr = if (keyType == KeyType.GUID) {
-            HashHelper.hash256(key)
-        } else {
-            key
-        }
-
-        val paddingLength = when (keyType) {
-            KeyType.GUID -> SubmissionConstants.PADDING_LENGTH_BODY_REGISTRATION_TOKEN_GUID
-            KeyType.TELETAN -> SubmissionConstants.PADDING_LENGTH_BODY_REGISTRATION_TOKEN_TELETAN
-        }
-
-        verificationService.getRegistrationToken(
-            SubmissionConstants.REGISTRATION_TOKEN_URL,
-            "0",
-            requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_REGISTRATION_TOKEN),
-            RegistrationTokenRequest(keyType.name, keyStr, requestPadding(paddingLength))
-        ).registrationToken
-    }
-
-    suspend fun asyncGetTestResult(
-        registrationToken: String
-    ): Int = withContext(Dispatchers.IO) {
-        verificationService.getTestResult(
-            SubmissionConstants.TEST_RESULT_URL,
-            "0",
-            requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_TEST_RESULT),
-            RegistrationRequest(
-                registrationToken,
-                requestPadding(SubmissionConstants.PADDING_LENGTH_BODY_TEST_RESULT)
-            )
-        ).testResult
-    }
-
-    suspend fun asyncGetTan(
-        registrationToken: String
-    ): String = withContext(Dispatchers.IO) {
-        verificationService.getTAN(
-            SubmissionConstants.TAN_REQUEST_URL,
-            "0",
-            requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_TAN),
-            TanRequestBody(
-                registrationToken,
-                requestPadding(SubmissionConstants.PADDING_LENGTH_BODY_TAN)
-            )
-        ).tan
-    }
-
-    suspend fun asyncFakeVerification() = withContext(Dispatchers.IO) {
-        verificationService.getTAN(
-            SubmissionConstants.TAN_REQUEST_URL,
-            "1",
-            requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_TAN),
-            TanRequestBody(
-                registrationToken = SubmissionConstants.DUMMY_REGISTRATION_TOKEN,
-                requestPadding = requestPadding(SubmissionConstants.PADDING_LENGTH_BODY_TAN_FAKE)
-            )
-        )
-    }
-
-    suspend fun asyncSubmitKeysToServer(
-        authCode: String,
-        keyList: List<KeyExportFormat.TemporaryExposureKey>
-    ) = withContext(Dispatchers.IO) {
-        Timber.d("Writing ${keyList.size} Keys to the Submission Payload.")
-
-        val randomAdditions = 0 // prepare for random addition of keys
-        val fakeKeyCount =
-            max(SubmissionConstants.minKeyCountForSubmission + randomAdditions - keyList.size, 0)
-        val fakeKeyPadding = requestPadding(SubmissionConstants.fakeKeySize * fakeKeyCount)
-
-        val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder()
-            .addAllKeys(keyList)
-            .setPadding(ByteString.copyFromUtf8(fakeKeyPadding))
-            .build()
-        submissionService.submitKeys(
-            DiagnosisKeyConstants.DIAGNOSIS_KEYS_SUBMISSION_URL,
-            authCode,
-            "0",
-            SubmissionConstants.EMPTY_HEADER,
-            submissionPayload
-        )
-        return@withContext
-    }
-
-    suspend fun asyncFakeSubmission() = withContext(Dispatchers.IO) {
-
-        val randomAdditions = 0 // prepare for random addition of keys
-        val fakeKeyCount = SubmissionConstants.minKeyCountForSubmission + randomAdditions
-
-        val fakeKeyPadding =
-            requestPadding(SubmissionConstants.fakeKeySize * fakeKeyCount)
-
-        val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder()
-            .setPadding(ByteString.copyFromUtf8(fakeKeyPadding))
-            .build()
-
-        submissionService.submitKeys(
-            DiagnosisKeyConstants.DIAGNOSIS_KEYS_SUBMISSION_URL,
-            SubmissionConstants.EMPTY_HEADER,
-            "1",
-            requestPadding(SubmissionConstants.PADDING_LENGTH_HEADER_SUBMISSION_FAKE),
-            submissionPayload
-        )
-    }
-
-    private fun requestPadding(length: Int): String {
-        val allowedChars = ('A'..'Z') + ('a'..'z') + ('0'..'9')
-        return (1..length)
-            .map { allowedChars.random() }
-            .joinToString("")
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationRequest.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationRequest.kt
deleted file mode 100644
index 6a20e507c21ae3e0dc1c0358dc78e781af9620d2..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationRequest.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package de.rki.coronawarnapp.http.requests
-
-import com.google.gson.annotations.SerializedName
-
-data class RegistrationRequest(
-    @SerializedName("registrationToken")
-    val registrationToken: String? = null,
-    @SerializedName("requestPadding")
-    val requestPadding: String? = null
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationTokenRequest.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationTokenRequest.kt
deleted file mode 100644
index 9667101c70a328c36fe9d6edbca5e0737d4dc1d6..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/RegistrationTokenRequest.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package de.rki.coronawarnapp.http.requests
-
-import com.google.gson.annotations.SerializedName
-
-data class RegistrationTokenRequest(
-    @SerializedName("keyType")
-    val keyType: String? = null,
-    @SerializedName("key")
-    val key: String? = null,
-    @SerializedName("requestPadding")
-    val requestPadding: String? = null
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/TanRequestBody.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/TanRequestBody.kt
deleted file mode 100644
index ce6c123551516d2c56975f877b451737e8aa2bea..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/requests/TanRequestBody.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package de.rki.coronawarnapp.http.requests
-
-import com.google.gson.annotations.SerializedName
-
-data class TanRequestBody(
-    @SerializedName("registrationToken")
-    val registrationToken: String? = null,
-    @SerializedName("requestPadding")
-    val requestPadding: String? = null
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/RegistrationTokenResponse.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/RegistrationTokenResponse.kt
deleted file mode 100644
index 4ec189715dc431a32c64e0a490815cc2abd4d90a..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/RegistrationTokenResponse.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.rki.coronawarnapp.http.responses
-
-import com.google.gson.annotations.SerializedName
-
-data class RegistrationTokenResponse(
-    @SerializedName("registrationToken")
-    val registrationToken: String
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TanResponse.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TanResponse.kt
deleted file mode 100644
index 91f1ea9b7c00ab4230b442127f84e61edd0e9796..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TanResponse.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.rki.coronawarnapp.http.responses
-
-import com.google.gson.annotations.SerializedName
-
-data class TanResponse(
-    @SerializedName("tan")
-    val tan: String
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TestResultResponse.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TestResultResponse.kt
deleted file mode 100644
index 065a2356f20f1ae6eaa09cd935f07f3bdbd7bfe3..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/responses/TestResultResponse.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.rki.coronawarnapp.http.responses
-
-import com.google.gson.annotations.SerializedName
-
-data class TestResultResponse(
-    @SerializedName("testResult")
-    val testResult: Int
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/VerificationService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/VerificationService.kt
deleted file mode 100644
index 9974fe50fa5666111dace01e73ff0f98e14d5016..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/VerificationService.kt
+++ /dev/null
@@ -1,39 +0,0 @@
-package de.rki.coronawarnapp.http.service
-
-import de.rki.coronawarnapp.http.requests.RegistrationTokenRequest
-import de.rki.coronawarnapp.http.requests.RegistrationRequest
-import de.rki.coronawarnapp.http.requests.TanRequestBody
-import de.rki.coronawarnapp.http.responses.RegistrationTokenResponse
-import de.rki.coronawarnapp.http.responses.TanResponse
-import de.rki.coronawarnapp.http.responses.TestResultResponse
-import retrofit2.http.Body
-import retrofit2.http.Header
-import retrofit2.http.POST
-import retrofit2.http.Url
-
-interface VerificationService {
-
-    @POST
-    suspend fun getRegistrationToken(
-        @Url url: String,
-        @Header("cwa-fake") fake: String,
-        @Header("cwa-header-padding") headerPadding: String?,
-        @Body requestBody: RegistrationTokenRequest
-    ): RegistrationTokenResponse
-
-    @POST
-    suspend fun getTestResult(
-        @Url url: String,
-        @Header("cwa-fake") fake: String,
-        @Header("cwa-header-padding") headerPadding: String?,
-        @Body request: RegistrationRequest
-    ): TestResultResponse
-
-    @POST
-    suspend fun getTAN(
-        @Url url: String,
-        @Header("cwa-fake") fake: String,
-        @Header("cwa-header-padding") headerPadding: String?,
-        @Body requestBody: TanRequestBody
-    ): TanResponse
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/BackgroundNoise.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt
similarity index 72%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/BackgroundNoise.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt
index ea1005d3def6baeec8456de12c4bb9547a421d37..55b7fb55382c84c07c34b7b4279b3e7672cebeef 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/BackgroundNoise.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt
@@ -1,8 +1,7 @@
-package de.rki.coronawarnapp.http.playbook
+package de.rki.coronawarnapp.playbook
 
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.service.submission.SubmissionConstants
 import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.worker.BackgroundConstants
 import de.rki.coronawarnapp.worker.BackgroundWorkScheduler
 import kotlin.random.Random
@@ -21,6 +20,9 @@ class BackgroundNoise {
         }
     }
 
+    private val playbook: Playbook
+        get() = AppInjector.component.playbook
+
     fun scheduleDummyPattern() {
         if (BackgroundConstants.NUMBER_OF_DAYS_TO_RUN_PLAYBOOK > 0)
             BackgroundWorkScheduler.scheduleBackgroundNoisePeriodicWork()
@@ -29,9 +31,8 @@ class BackgroundNoise {
     suspend fun foregroundScheduleCheck() {
         if (LocalData.isAllowedToSubmitDiagnosisKeys() == true) {
             val chance = Random.nextFloat() * 100
-            if (chance < SubmissionConstants.probabilityToExecutePlaybookWhenOpenApp) {
-                PlaybookImpl(WebRequestBuilder.getInstance())
-                    .dummy()
+            if (chance < DefaultPlaybook.PROBABILITY_TO_EXECUTE_PLAYBOOK_ON_APP_OPEN) {
+                playbook.dummy()
             }
         }
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/PlaybookImpl.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt
similarity index 58%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/PlaybookImpl.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt
index e5b3d78caed3af5e366be0dfa662c3150642897e..338a63f34b8fa33fd08ed609aa6e6a41044ae11c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/PlaybookImpl.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt
@@ -1,10 +1,9 @@
-package de.rki.coronawarnapp.http.playbook
+package de.rki.coronawarnapp.playbook
 
-import KeyExportFormat
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.service.submission.KeyType
-import de.rki.coronawarnapp.service.submission.SubmissionConstants
+import de.rki.coronawarnapp.submission.server.SubmissionServer
 import de.rki.coronawarnapp.util.formatter.TestResult
+import de.rki.coronawarnapp.verification.server.VerificationKeyType
+import de.rki.coronawarnapp.verification.server.VerificationServer
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.delay
@@ -12,9 +11,13 @@ import kotlinx.coroutines.launch
 import timber.log.Timber
 import java.util.UUID
 import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
 
-class PlaybookImpl(
-    private val webRequestBuilder: WebRequestBuilder
+@Singleton
+class DefaultPlaybook @Inject constructor(
+    private val verificationServer: VerificationServer,
+    private val submissionServer: SubmissionServer
 ) : Playbook {
 
     private val uid = UUID.randomUUID().toString()
@@ -22,25 +25,30 @@ class PlaybookImpl(
 
     override suspend fun initialRegistration(
         key: String,
-        keyType: KeyType
+        keyType: VerificationKeyType
     ): Pair<String, TestResult> {
         Timber.i("[$uid] New Initial Registration Playbook")
 
         // real registration
         val (registrationToken, registrationException) =
-            executeCapturingExceptions { webRequestBuilder.asyncGetRegistrationToken(key, keyType) }
+            executeCapturingExceptions {
+                verificationServer.retrieveRegistrationToken(
+                    key,
+                    keyType
+                )
+            }
 
         // if the registration succeeded continue with the real test result retrieval
         // if it failed, execute a dummy request to satisfy the required playbook pattern
         val (testResult, testResultException) = if (registrationToken != null) {
-            executeCapturingExceptions { webRequestBuilder.asyncGetTestResult(registrationToken) }
+            executeCapturingExceptions { verificationServer.retrieveTestResults(registrationToken) }
         } else {
-            ignoreExceptions { webRequestBuilder.asyncFakeVerification() }
+            ignoreExceptions { verificationServer.retrieveTanFake() }
             null to null
         }
 
         // fake submission
-        ignoreExceptions { webRequestBuilder.asyncFakeSubmission() }
+        ignoreExceptions { submissionServer.submitKeysToServerFake() }
 
         coroutineScope.launch { followUpPlaybooks() }
 
@@ -57,42 +65,43 @@ class PlaybookImpl(
 
         // real test result
         val (testResult, exception) =
-            executeCapturingExceptions { webRequestBuilder.asyncGetTestResult(registrationToken) }
+            executeCapturingExceptions { verificationServer.retrieveTestResults(registrationToken) }
 
         // fake verification
-        ignoreExceptions { webRequestBuilder.asyncFakeVerification() }
+        ignoreExceptions { verificationServer.retrieveTanFake() }
 
         // fake submission
-        ignoreExceptions { webRequestBuilder.asyncFakeSubmission() }
+        ignoreExceptions { submissionServer.submitKeysToServerFake() }
 
         coroutineScope.launch { followUpPlaybooks() }
 
-        return testResult?.let { TestResult.fromInt(it) }
-            ?: propagateException(exception)
+        return testResult?.let { TestResult.fromInt(it) } ?: propagateException(exception)
     }
 
     override suspend fun submission(
-        registrationToken: String,
-        keys: List<KeyExportFormat.TemporaryExposureKey>
+        data: Playbook.SubmissionData
     ) {
         Timber.i("[$uid] New Submission Playbook")
-
         // real auth code
         val (authCode, exception) = executeCapturingExceptions {
-            webRequestBuilder.asyncGetTan(
-                registrationToken
-            )
+            verificationServer.retrieveTan(data.registrationToken)
         }
 
         // fake verification
-        ignoreExceptions { webRequestBuilder.asyncFakeVerification() }
+        ignoreExceptions { verificationServer.retrieveTanFake() }
 
         // real submission
         if (authCode != null) {
-            webRequestBuilder.asyncSubmitKeysToServer(authCode, keys)
+            val serverSubmissionData = SubmissionServer.SubmissionData(
+                authCode = authCode,
+                keyList = data.temporaryExposureKeys,
+                consentToFederation = data.consentToFederation,
+                visistedCountries = data.visistedCountries
+            )
+            submissionServer.submitKeysToServer(serverSubmissionData)
             coroutineScope.launch { followUpPlaybooks() }
         } else {
-            webRequestBuilder.asyncFakeSubmission()
+            submissionServer.submitKeysToServerFake()
             coroutineScope.launch { followUpPlaybooks() }
             propagateException(exception)
         }
@@ -100,13 +109,13 @@ class PlaybookImpl(
 
     private suspend fun dummy(launchFollowUp: Boolean) {
         // fake verification
-        ignoreExceptions { webRequestBuilder.asyncFakeVerification() }
+        ignoreExceptions { verificationServer.retrieveTanFake() }
 
         // fake verification
-        ignoreExceptions { webRequestBuilder.asyncFakeVerification() }
+        ignoreExceptions { verificationServer.retrieveTanFake() }
 
         // fake submission
-        ignoreExceptions { webRequestBuilder.asyncFakeSubmission() }
+        ignoreExceptions { submissionServer.submitKeysToServerFake() }
 
         if (launchFollowUp)
             coroutineScope.launch { followUpPlaybooks() }
@@ -116,15 +125,15 @@ class PlaybookImpl(
 
     private suspend fun followUpPlaybooks() {
         val runsToExecute = IntRange(
-            SubmissionConstants.minNumberOfSequentialPlaybooks - 1 /* one was already executed */,
-            SubmissionConstants.maxNumberOfSequentialPlaybooks - 1 /* one was already executed */
+            MIN_NUMBER_OF_SEQUENTIAL_PLAYBOOKS - 1 /* one was already executed */,
+            MAX_NUMBER_OF_SEQUENTIAL_PLAYBOOKS - 1 /* one was already executed */
         ).random()
         Timber.i("[$uid] Follow Up: launching $runsToExecute follow up playbooks")
 
         repeat(runsToExecute) {
             val executionDelay = IntRange(
-                SubmissionConstants.minDelayBetweenSequentialPlaybooks,
-                SubmissionConstants.maxDelayBetweenSequentialPlaybooks
+                MIN_DELAY_BETWEEN_SEQUENTIAL_PLAYBOOKS,
+                MAX_DELAY_BETWEEN_SEQUENTIAL_PLAYBOOKS
             ).random()
             Timber.i("[$uid] Follow Up: (${it + 1}/$runsToExecute) waiting $executionDelay[s]...")
             delay(TimeUnit.SECONDS.toMillis(executionDelay.toLong()))
@@ -154,4 +163,12 @@ class PlaybookImpl(
     private fun propagateException(vararg exceptions: Exception?): Nothing {
         throw exceptions.filterNotNull().firstOrNull() ?: IllegalStateException()
     }
+
+    companion object {
+        const val PROBABILITY_TO_EXECUTE_PLAYBOOK_ON_APP_OPEN = 0f
+        const val MIN_NUMBER_OF_SEQUENTIAL_PLAYBOOKS = 1
+        const val MAX_NUMBER_OF_SEQUENTIAL_PLAYBOOKS = 1
+        const val MIN_DELAY_BETWEEN_SEQUENTIAL_PLAYBOOKS = 0
+        const val MAX_DELAY_BETWEEN_SEQUENTIAL_PLAYBOOKS = 0
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/Playbook.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt
similarity index 63%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/Playbook.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt
index 5d37af5df193ba0d77768999d4a90205957bb71c..6babf297d09784ef6d8c4d7c2087ea48e238996a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/playbook/Playbook.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt
@@ -1,8 +1,8 @@
-package de.rki.coronawarnapp.http.playbook
+package de.rki.coronawarnapp.playbook
 
-import KeyExportFormat
-import de.rki.coronawarnapp.service.submission.KeyType
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
 import de.rki.coronawarnapp.util.formatter.TestResult
+import de.rki.coronawarnapp.verification.server.VerificationKeyType
 
 /**
  * The concept of Plausible Deniability aims to hide the existence of a positive test result by always using a defined “playbook pattern” of requests to the Verification Server and CWA Backend so it is impossible for an attacker to identify which communication was done.
@@ -13,17 +13,19 @@ interface Playbook {
 
     suspend fun initialRegistration(
         key: String,
-        keyType: KeyType
+        keyType: VerificationKeyType
     ): Pair<String, TestResult> /* registration token & test result*/
 
-    suspend fun testResult(
-        registrationToken: String
-    ): TestResult
+    suspend fun testResult(registrationToken: String): TestResult
 
-    suspend fun submission(
-        registrationToken: String,
-        keys: List<KeyExportFormat.TemporaryExposureKey>
+    data class SubmissionData(
+        val registrationToken: String,
+        val temporaryExposureKeys: List<KeyExportFormat.TemporaryExposureKey>,
+        val consentToFederation: Boolean,
+        val visistedCountries: List<String>
     )
 
+    suspend fun submission(data: SubmissionData)
+
     suspend fun dummy()
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/PlaybookModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/PlaybookModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d76e5654c2f1548d459db8cceb3db829bacda8d2
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/PlaybookModule.kt
@@ -0,0 +1,13 @@
+package de.rki.coronawarnapp.playbook
+
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class PlaybookModule {
+
+    @Singleton
+    @Provides
+    fun providePlaybook(defaultPlayBook: DefaultPlaybook): Playbook = defaultPlayBook
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstants.kt
deleted file mode 100644
index 8acfd1c5ef294aa6c840e679d4cbf1864b22b51c..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstants.kt
+++ /dev/null
@@ -1,42 +0,0 @@
-/******************************************************************************
- * Corona-Warn-App                                                            *
- *                                                                            *
- * SAP SE and all other contributors /                                        *
- * copyright owners license this file to you under the Apache                 *
- * License, Version 2.0 (the "License"); you may not use this                 *
- * file except in compliance with the License.                                *
- * You may obtain a copy of the License at                                    *
- *                                                                            *
- * http://www.apache.org/licenses/LICENSE-2.0                                 *
- *                                                                            *
- * Unless required by applicable law or agreed to in writing,                 *
- * software distributed under the License is distributed on an                *
- * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY                     *
- * KIND, either express or implied.  See the License for the                  *
- * specific language governing permissions and limitations                    *
- * under the License.                                                         *
- ******************************************************************************/
-
-package de.rki.coronawarnapp.service.diagnosiskey
-
-/**
- * The Diagnosis Key constants
- */
-object DiagnosisKeyConstants {
-    /** version resource variable for REST-like Service Calls */
-    private const val VERSION = "version"
-
-    /** diagnosis keys resource variable for REST-like Service Calls */
-    private const val DIAGNOSIS_KEYS = "diagnosis-keys"
-
-    /** resource variables but non-static context */
-    private var CURRENT_VERSION = "v1"
-
-    /** Submission URL built from CDN URL's and REST resources */
-    private var VERSIONED_SUBMISSION_CDN_URL = "$VERSION/$CURRENT_VERSION"
-
-    /** Diagnosis key Submission URL built from CDN URL's and REST resources */
-    val DIAGNOSIS_KEYS_SUBMISSION_URL = "$VERSIONED_SUBMISSION_CDN_URL/$DIAGNOSIS_KEYS"
-
-    const val SERVER_ERROR_CODE_403 = 403
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionConstants.kt
deleted file mode 100644
index 43de684e2e6535aa2253b3198c82022b8af88991..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionConstants.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package de.rki.coronawarnapp.service.submission
-
-object SubmissionConstants {
-    private const val VERSION = "version"
-    private const val REGISTRATION_TOKEN = "registrationToken"
-    private const val TEST_RESULT = "testresult"
-    private const val TAN = "tan"
-
-    private var CURRENT_VERSION = "v1"
-
-    private val VERSIONED_VERIFICATION_CDN_URL = "$VERSION/$CURRENT_VERSION"
-
-    val REGISTRATION_TOKEN_URL = "$VERSIONED_VERIFICATION_CDN_URL/$REGISTRATION_TOKEN"
-    val TEST_RESULT_URL = "$VERSIONED_VERIFICATION_CDN_URL/$TEST_RESULT"
-    val TAN_REQUEST_URL = "$VERSIONED_VERIFICATION_CDN_URL/$TAN"
-
-    const val SERVER_ERROR_CODE_400 = 400
-
-    const val EMPTY_HEADER = ""
-
-    // padding registration token
-    private const val VERIFICATION_BODY_FILL = 139
-
-    const val PADDING_LENGTH_HEADER_REGISTRATION_TOKEN = 0
-    const val PADDING_LENGTH_BODY_REGISTRATION_TOKEN_TELETAN = 51 + VERIFICATION_BODY_FILL
-    const val PADDING_LENGTH_BODY_REGISTRATION_TOKEN_GUID = 0 + VERIFICATION_BODY_FILL
-
-    // padding test result
-    const val PADDING_LENGTH_HEADER_TEST_RESULT = 7
-    const val PADDING_LENGTH_BODY_TEST_RESULT = 31 + VERIFICATION_BODY_FILL
-
-    // padding tan
-    const val PADDING_LENGTH_HEADER_TAN = 14
-    const val PADDING_LENGTH_BODY_TAN = 31 + VERIFICATION_BODY_FILL
-    const val PADDING_LENGTH_BODY_TAN_FAKE = 31 + VERIFICATION_BODY_FILL
-    const val DUMMY_REGISTRATION_TOKEN = "11111111-2222-4444-8888-161616161616"
-
-    const val PADDING_LENGTH_HEADER_SUBMISSION_FAKE = 36
-
-    const val probabilityToExecutePlaybookWhenOpenApp = 0f
-    const val minNumberOfSequentialPlaybooks = 1
-    const val maxNumberOfSequentialPlaybooks = 1
-    const val minDelayBetweenSequentialPlaybooks = 0
-    const val maxDelayBetweenSequentialPlaybooks = 0
-
-    const val minKeyCountForSubmission = 14
-    const val fakeKeySize = (1 * 16 /* key data*/) + (3 * 4 /* 3x int32*/)
-}
-
-enum class KeyType {
-    GUID, TELETAN;
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionService.kt
index 8695e37cfa9bee099f71465faf9fd4ab52f2160b..be711b0ae28bdf4ac9b2d804691e45c80bd4cd8c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionService.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/SubmissionService.kt
@@ -3,18 +3,22 @@ package de.rki.coronawarnapp.service.submission
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
 import de.rki.coronawarnapp.exception.NoGUIDOrTANSetException
 import de.rki.coronawarnapp.exception.NoRegistrationTokenSetException
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.http.playbook.BackgroundNoise
-import de.rki.coronawarnapp.http.playbook.PlaybookImpl
+import de.rki.coronawarnapp.playbook.BackgroundNoise
+import de.rki.coronawarnapp.playbook.Playbook
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.SubmissionRepository
 import de.rki.coronawarnapp.submission.Symptoms
 import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction
+import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.formatter.TestResult
+import de.rki.coronawarnapp.verification.server.VerificationKeyType
 import de.rki.coronawarnapp.worker.BackgroundWorkScheduler
 
 object SubmissionService {
 
+    private val playbook: Playbook
+        get() = AppInjector.component.playbook
+
     suspend fun asyncRegisterDevice() {
         val testGUID = LocalData.testGUID()
         val testTAN = LocalData.teletan()
@@ -30,9 +34,9 @@ object SubmissionService {
 
     private suspend fun asyncRegisterDeviceViaGUID(guid: String) {
         val (registrationToken, testResult) =
-            PlaybookImpl(WebRequestBuilder.getInstance()).initialRegistration(
+            playbook.initialRegistration(
                 guid,
-                KeyType.GUID
+                VerificationKeyType.GUID
             )
 
         LocalData.registrationToken(registrationToken)
@@ -42,9 +46,9 @@ object SubmissionService {
 
     private suspend fun asyncRegisterDeviceViaTAN(tan: String) {
         val (registrationToken, testResult) =
-            PlaybookImpl(WebRequestBuilder.getInstance()).initialRegistration(
+            playbook.initialRegistration(
                 tan,
-                KeyType.TELETAN
+                VerificationKeyType.TELETAN
             )
 
         LocalData.registrationToken(registrationToken)
@@ -62,7 +66,7 @@ object SubmissionService {
         val registrationToken =
             LocalData.registrationToken() ?: throw NoRegistrationTokenSetException()
 
-        return PlaybookImpl(WebRequestBuilder.getInstance()).testResult(registrationToken)
+        return playbook.testResult(registrationToken)
     }
 
     fun containsValidGUID(scanResult: String): Boolean {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DefaultKeyConverter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DefaultKeyConverter.kt
index e467c0f033231f096dd3710771f18586ab402298..c852c232d35340ec5d6175f440fbbab5e07574e2 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DefaultKeyConverter.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DefaultKeyConverter.kt
@@ -1,8 +1,8 @@
 package de.rki.coronawarnapp.submission
 
-import KeyExportFormat
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
 import com.google.protobuf.ByteString
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
 
 class DefaultKeyConverter : KeyConverter {
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/KeyConverter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/KeyConverter.kt
index f50795ca52f4bdb3a31d6edb8b82aacb064ce721..d5ea977ca11c7aff45db3f95400c21f8629fc952 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/KeyConverter.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/KeyConverter.kt
@@ -1,7 +1,7 @@
 package de.rki.coronawarnapp.submission
 
-import KeyExportFormat
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
 
 interface KeyConverter {
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5ba29e45017611aefb14cc6d5efe489890c95ca9
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionModule.kt
@@ -0,0 +1,60 @@
+package de.rki.coronawarnapp.submission
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import de.rki.coronawarnapp.environment.submission.SubmissionCDNServerUrl
+import de.rki.coronawarnapp.http.HttpClientDefault
+import de.rki.coronawarnapp.http.RestrictedConnectionSpecs
+import de.rki.coronawarnapp.submission.server.SubmissionApiV1
+import de.rki.coronawarnapp.submission.server.SubmissionHttpClient
+import okhttp3.Cache
+import okhttp3.ConnectionSpec
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import retrofit2.converter.protobuf.ProtoConverterFactory
+import java.io.File
+import javax.inject.Singleton
+
+@Module
+class SubmissionModule {
+
+    @Reusable
+    @SubmissionHttpClient
+    @Provides
+    fun cdnHttpClient(
+        @HttpClientDefault defaultHttpClient: OkHttpClient,
+        @RestrictedConnectionSpecs connectionSpecs: List<ConnectionSpec>
+    ): OkHttpClient =
+        defaultHttpClient.newBuilder().connectionSpecs(connectionSpecs).build()
+
+    @Singleton
+    @Provides
+    fun provideSubmissionApi(
+        context: Context,
+        @SubmissionHttpClient client: OkHttpClient,
+        @SubmissionCDNServerUrl url: String,
+        protoConverterFactory: ProtoConverterFactory,
+        gsonConverterFactory: GsonConverterFactory
+    ): SubmissionApiV1 {
+        val cache = Cache(File(context.cacheDir, "http_submission"), DEFAULT_CACHE_SIZE)
+
+        val cachingClient = client.newBuilder().apply {
+            cache(cache)
+        }.build()
+
+        return Retrofit.Builder()
+            .client(cachingClient)
+            .baseUrl(url)
+            .addConverterFactory(protoConverterFactory)
+            .addConverterFactory(gsonConverterFactory)
+            .build()
+            .create(SubmissionApiV1::class.java)
+    }
+
+    companion object {
+        private const val DEFAULT_CACHE_SIZE = 5 * 1024 * 1024L // 5MB
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/SubmissionService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1.kt
similarity index 62%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/SubmissionService.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1.kt
index a2092edea1a5456b5f8949d1ae7afdd8ad595eda..ebb23fe4e97e91070e49d64f3d792f48ad076c1a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/http/service/SubmissionService.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1.kt
@@ -1,19 +1,17 @@
-package de.rki.coronawarnapp.http.service
+package de.rki.coronawarnapp.submission.server
 
-import KeyExportFormat
-import okhttp3.ResponseBody
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
 import retrofit2.http.Body
 import retrofit2.http.Header
 import retrofit2.http.POST
-import retrofit2.http.Url
 
-interface SubmissionService {
-    @POST
+interface SubmissionApiV1 {
+
+    @POST("version/v1/diagnosis-keys")
     suspend fun submitKeys(
-        @Url url: String,
         @Header("cwa-authorization") authCode: String?,
         @Header("cwa-fake") fake: String,
         @Header("cwa-header-padding") headerPadding: String?,
         @Body requestBody: KeyExportFormat.SubmissionPayload
-    ): ResponseBody
+    )
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionHttpClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionHttpClient.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d7c83b129573eff9c62d5f602826fa1eea68ca79
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionHttpClient.kt
@@ -0,0 +1,8 @@
+package de.rki.coronawarnapp.submission.server
+
+import javax.inject.Qualifier
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class SubmissionHttpClient
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionServer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a8c41429d38285a4d8f479905c4db7a610a31443
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/server/SubmissionServer.kt
@@ -0,0 +1,85 @@
+package de.rki.coronawarnapp.submission.server
+
+import com.google.protobuf.ByteString
+import dagger.Lazy
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
+import de.rki.coronawarnapp.util.PaddingTool.requestPadding
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.math.max
+
+@Singleton
+class SubmissionServer @Inject constructor(
+    private val submissionApi: Lazy<SubmissionApiV1>
+) {
+
+    private val api: SubmissionApiV1
+        get() = submissionApi.get()
+
+    data class SubmissionData(
+        val authCode: String,
+        val keyList: List<KeyExportFormat.TemporaryExposureKey>,
+        val consentToFederation: Boolean,
+        val visistedCountries: List<String>
+    )
+
+    suspend fun submitKeysToServer(
+        data: SubmissionData
+    ) = withContext(Dispatchers.IO) {
+        Timber.d("submitKeysToServer()")
+        val authCode = data.authCode
+        val keyList = data.keyList
+        Timber.d("Writing ${keyList.size} Keys to the Submission Payload.")
+
+        val randomAdditions = 0 // prepare for random addition of keys
+        val fakeKeyCount = max(
+            MIN_KEY_COUNT_FOR_SUBMISSION + randomAdditions - keyList.size,
+            0
+        )
+        val fakeKeyPadding = requestPadding(FAKE_KEY_SIZE * fakeKeyCount)
+
+        val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder()
+            .addAllKeys(keyList)
+            .setPadding(ByteString.copyFromUtf8(fakeKeyPadding))
+            .setConsentToFederation(data.consentToFederation)
+            .addAllVisitedCountries(data.visistedCountries)
+            .build()
+
+        api.submitKeys(
+            authCode = authCode,
+            fake = "0",
+            headerPadding = EMPTY_HEADER,
+            requestBody = submissionPayload
+        )
+    }
+
+    suspend fun submitKeysToServerFake() = withContext(Dispatchers.IO) {
+        Timber.d("submitKeysToServerFake()")
+
+        val randomAdditions = 0 // prepare for random addition of keys
+        val fakeKeyCount = MIN_KEY_COUNT_FOR_SUBMISSION + randomAdditions
+
+        val fakeKeyPadding = requestPadding(FAKE_KEY_SIZE * fakeKeyCount)
+
+        val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder()
+            .setPadding(ByteString.copyFromUtf8(fakeKeyPadding))
+            .build()
+
+        api.submitKeys(
+            authCode = EMPTY_HEADER,
+            fake = "1",
+            headerPadding = requestPadding(PADDING_LENGTH_HEADER_SUBMISSION_FAKE),
+            requestBody = submissionPayload
+        )
+    }
+
+    companion object {
+        const val EMPTY_HEADER = ""
+        const val PADDING_LENGTH_HEADER_SUBMISSION_FAKE = 36
+        const val MIN_KEY_COUNT_FOR_SUBMISSION = 14
+        const val FAKE_KEY_SIZE = (1 * 16 /* key data*/) + (3 * 4 /* 3x int32*/)
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt
index 7ddfa7fd1ac4fa01794024f022dc0230c4e68018..2ad1ed84cce1d179850dc2b0f9ce57ad9e2a110e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt
@@ -1,10 +1,14 @@
 package de.rki.coronawarnapp.transaction
 
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.playbook.Playbook
 import javax.inject.Inject
 import javax.inject.Singleton
 
 // TODO Remove once we have refactored the transaction and it's no longer a singleton
 @Singleton
 data class SubmitDiagnosisInjectionHelper @Inject constructor(
-    val transactionScope: TransactionCoroutineScope
+    val transactionScope: TransactionCoroutineScope,
+    val playbook: Playbook,
+    val appConfigProvider: AppConfigProvider
 )
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt
index ea6a4720f187cba0d66d1ea9916ce5152015838a..710a7814c0369bc373fa3bcb0484b40a4a9b8628 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt
@@ -1,11 +1,13 @@
 package de.rki.coronawarnapp.transaction
 
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.http.playbook.PlaybookImpl
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.toNewConfig
+import de.rki.coronawarnapp.playbook.Playbook
+import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration
 import de.rki.coronawarnapp.service.submission.SubmissionService
-import de.rki.coronawarnapp.submission.ExposureKeyHistoryCalculations
 import de.rki.coronawarnapp.submission.DefaultKeyConverter
+import de.rki.coronawarnapp.submission.ExposureKeyHistoryCalculations
 import de.rki.coronawarnapp.submission.Symptoms
 import de.rki.coronawarnapp.submission.TransmissionRiskVectorDeterminator
 import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDiagnosisKeysTransactionState.CLOSE
@@ -13,6 +15,7 @@ import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDia
 import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDiagnosisKeysTransactionState.RETRIEVE_TEMPORARY_EXPOSURE_KEY_HISTORY
 import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDiagnosisKeysTransactionState.STORE_SUCCESS
 import de.rki.coronawarnapp.util.di.AppInjector
+import timber.log.Timber
 
 /**
  * The SubmitDiagnosisKeysTransaction is used to define an atomic Transaction for Key Reports. Its states allow an
@@ -39,6 +42,7 @@ import de.rki.coronawarnapp.util.di.AppInjector
  */
 object SubmitDiagnosisKeysTransaction : Transaction() {
 
+    private const val FALLBACK_COUNTRY = "DE"
     override val TAG: String? = SubmitDiagnosisKeysTransaction::class.simpleName
 
     /** possible transaction states */
@@ -54,39 +58,56 @@ object SubmitDiagnosisKeysTransaction : Transaction() {
         AppInjector.component.transSubmitDiagnosisInjection.transactionScope
     }
 
+    private val playbook: Playbook
+        get() = AppInjector.component.transSubmitDiagnosisInjection.playbook
+
+    private val appConfigProvider: AppConfigProvider
+        get() = AppInjector.component.transSubmitDiagnosisInjection.appConfigProvider
+
     /** initiates the transaction. This suspend function guarantees a successful transaction once completed. */
     suspend fun start(
         registrationToken: String,
         keys: List<TemporaryExposureKey>,
         symptoms: Symptoms
     ) = lockAndExecute(unique = true, scope = transactionScope) {
-        /****************************************************
-         * RETRIEVE TEMPORARY EXPOSURE KEY HISTORY
-         ****************************************************/
+
         val temporaryExposureKeyList = executeState(RETRIEVE_TEMPORARY_EXPOSURE_KEY_HISTORY) {
             ExposureKeyHistoryCalculations(
                 TransmissionRiskVectorDeterminator(),
                 DefaultKeyConverter()
             ).transformToKeyHistoryInExternalFormat(keys, symptoms)
         }
-        /****************************************************
-         * RETRIEVE TAN & SUBMIT KEYS
-         ****************************************************/
+
+        val visistedCountries =
+            appConfigProvider.getAppConfig().performSanityChecks().supportedCountriesList
+
         executeState(RETRIEVE_TAN_AND_SUBMIT_KEYS) {
-            PlaybookImpl(WebRequestBuilder.getInstance()).submission(
-                registrationToken,
-                temporaryExposureKeyList
+            val submissionData = Playbook.SubmissionData(
+                registrationToken = registrationToken,
+                temporaryExposureKeys = temporaryExposureKeyList,
+                consentToFederation = true,
+                visistedCountries = visistedCountries
             )
+            playbook.submission(submissionData)
         }
-        /****************************************************
-         * STORE SUCCESS
-         ****************************************************/
+
         executeState(STORE_SUCCESS) {
             SubmissionService.submissionSuccessful()
         }
-        /****************************************************
-         * CLOSE TRANSACTION
-         ****************************************************/
+
         executeState(CLOSE) {}
     }
+
+    private fun ApplicationConfiguration.performSanityChecks(): ApplicationConfiguration {
+        var sanityChecked = this
+
+        if (sanityChecked.supportedCountriesList.isEmpty()) {
+            sanityChecked = sanityChecked.toNewConfig {
+                addSupportedCountries(FALLBACK_COUNTRY)
+            }
+            Timber.w("Country list was empty, corrected: %s", sanityChecked.supportedCountriesList)
+        }
+
+        return sanityChecked
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
index 63af50f06e1e574c7a09740c299981e97248b7ea..9f468c223fbd4ddbd7788bf9de503b8d5a92787a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
@@ -14,8 +14,8 @@ import dagger.android.AndroidInjector
 import dagger.android.DispatchingAndroidInjector
 import dagger.android.HasAndroidInjector
 import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.http.playbook.BackgroundNoise
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
+import de.rki.coronawarnapp.playbook.BackgroundNoise
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.ui.base.startActivitySafely
 import de.rki.coronawarnapp.ui.viewmodel.SettingsViewModel
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt
index cc614f2797b1f7b66ff248131d4cbc5860716793..4c0373fa49911ef8e3eaac545eeccafa7d61cace 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt
@@ -1,8 +1,24 @@
 package de.rki.coronawarnapp.util
 
+import android.app.Application
 import de.rki.coronawarnapp.BuildConfig
+import de.rki.coronawarnapp.util.debug.FileLogger
+import timber.log.Timber
 
 object CWADebug {
+    var fileLogger: FileLogger? = null
+
+    fun init(application: Application) {
+        if (isDebugBuildOrMode) System.setProperty("kotlinx.coroutines.debug", "on")
+
+        if (BuildConfig.DEBUG) {
+            Timber.plant(Timber.DebugTree())
+        }
+        if ((BuildConfig.FLAVOR == "deviceForTesters" || BuildConfig.DEBUG)) {
+            fileLogger = FileLogger(application)
+        }
+    }
+
     val isDebugBuildOrMode: Boolean
         get() = BuildConfig.DEBUG || BuildConfig.BUILD_VARIANT == "deviceForTesters"
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/KeyFileHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/KeyFileHelper.kt
index 4ad00ef713d2b418944b429ba976ccaba517a340..ce325afe3e8f462095e4c5aea34647b76896a2de 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/KeyFileHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/KeyFileHelper.kt
@@ -1,10 +1,9 @@
 package de.rki.coronawarnapp.util
 
-import KeyExportFormat
-import KeyExportFormat.TEKSignatureList
-import KeyExportFormat.TEKSignatureList.newBuilder
-import KeyExportFormat.TemporaryExposureKeyExport
 import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat.TEKSignature
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat.TEKSignatureList
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat.TemporaryExposureKeyExport
 import de.rki.coronawarnapp.util.ProtoFormatConverterExtensions.convertToGoogleKey
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.logUTCFormat
 import kotlinx.coroutines.Dispatchers
@@ -51,7 +50,7 @@ object KeyFileHelper {
                         .setStartTimestamp(file.header.startTimestamp)
                         .setEndTimestamp(file.header.endTimestamp)
                         .build(),
-                    KeyExportFormat.TEKSignature.newBuilder()
+                    TEKSignature.newBuilder()
                         .setBatchNum(file.header.batchNum)
                         .setBatchSize(file.header.batchSize)
                         .setSignatureInfo(SignatureHelper.clientSig)
@@ -75,7 +74,7 @@ object KeyFileHelper {
     private suspend fun createBinaryFile(
         storageDirectory: File?,
         zipFileName: String,
-        sourceWithTEKSignature: Pair<TemporaryExposureKeyExport, KeyExportFormat.TEKSignature>
+        sourceWithTEKSignature: Pair<TemporaryExposureKeyExport, TEKSignature>
     ): File {
         return withContext(Dispatchers.IO) {
             val exportFile = async {
@@ -88,7 +87,9 @@ object KeyFileHelper {
             val exportSignatureFile = async {
                 generateSignatureFile(
                     storageDirectory,
-                    newBuilder().addAllSignatures(listOf(sourceWithTEKSignature.second)).build()
+                    TEKSignatureList.newBuilder()
+                        .addAllSignatures(listOf(sourceWithTEKSignature.second))
+                        .build()
                 )
             }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/PaddingTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/PaddingTool.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1e80679aacc286e5e594eb53dba9917f5c5d77fb
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/PaddingTool.kt
@@ -0,0 +1,9 @@
+package de.rki.coronawarnapp.util
+
+object PaddingTool {
+    fun requestPadding(length: Int): String = (1..length)
+        .map { PADDING_ITEMS.random() }
+        .joinToString("")
+
+    private val PADDING_ITEMS = ('A'..'Z') + ('a'..'z') + ('0'..'9')
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ProtoFormatConverterExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ProtoFormatConverterExtensions.kt
index 6f3db1758c31fec40607709d11f494f9bd006f25..975d4f17f3e0e7261b2ccc3ea0fdc840c0d66dd9 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ProtoFormatConverterExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ProtoFormatConverterExtensions.kt
@@ -1,7 +1,7 @@
 package de.rki.coronawarnapp.util
 
-import KeyExportFormat
 import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
 
 object ProtoFormatConverterExtensions {
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/SignatureHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/SignatureHelper.kt
index 9ad36fcbdea6b43f843e95679895a7a122b8fa65..9ffa457377cdb14a04ba855e86d6020f2eb1a72f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/SignatureHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/SignatureHelper.kt
@@ -1,9 +1,9 @@
 package de.rki.coronawarnapp.util
 
-import KeyExportFormat.SignatureInfo
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
 
 object SignatureHelper {
-    val clientSig: SignatureInfo = SignatureInfo.newBuilder()
+    val clientSig: KeyExportFormat.SignatureInfo = KeyExportFormat.SignatureInfo.newBuilder()
         .setAndroidPackage("de.rki.coronawarnapp")
         .setAppBundleId("de.rki.coronawarnapp")
         .setSignatureAlgorithm("ECDSA")
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d2e5aa97c9fc787f2874d65145e9c203a8457d9b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt
@@ -0,0 +1,77 @@
+package de.rki.coronawarnapp.util
+
+import android.content.Context
+import android.net.wifi.WifiManager
+import android.os.PowerManager
+import androidx.lifecycle.ProcessLifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction
+import de.rki.coronawarnapp.worker.BackgroundWorkHelper
+import de.rki.coronawarnapp.worker.BackgroundWorkScheduler
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.util.UUID
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WatchdogService @Inject constructor(private val context: Context) {
+
+    private val powerManager by lazy {
+        context.getSystemService(Context.POWER_SERVICE) as PowerManager
+    }
+    private val wifiManager by lazy {
+        context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
+    }
+
+    fun launch() {
+        // Only do this if the background jobs are enabled
+        if (!ConnectivityHelper.autoModeEnabled(context)) {
+            Timber.d("Background jobs are not enabled, aborting.")
+            return
+        }
+
+        Timber.v("Acquiring wakelocks for watchdog routine.")
+        ProcessLifecycleOwner.get().lifecycleScope.launch {
+            // A wakelock as the OS does not handle this for us like in the background job execution
+            val wakeLock = createWakeLock()
+            // A wifi lock to wake up the wifi connection in case the device is dozing
+            val wifiLock = createWifiLock()
+            try {
+                BackgroundWorkHelper.sendDebugNotification(
+                    "Automatic mode is on", "Check if we have downloaded keys already today"
+                )
+                RetrieveDiagnosisKeysTransaction.startWithConstraints()
+            } catch (e: Exception) {
+                BackgroundWorkHelper.sendDebugNotification(
+                    "RetrieveDiagnosisKeysTransaction failed",
+                    (e.localizedMessage
+                        ?: "Unknown exception occurred in onCreate") + "\n\n" + (e.cause
+                        ?: "Cause is unknown").toString()
+                )
+                // retry the key retrieval in case of an error with a scheduled work
+                BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()
+            }
+
+            if (wifiLock.isHeld) wifiLock.release()
+            if (wakeLock.isHeld) wakeLock.release()
+        }
+
+        // if the user is onboarded we will schedule period background jobs
+        // in case the app was force stopped and woken up again by the Google WakeUpService
+        if (LocalData.onboardingCompletedTimestamp() != null) BackgroundWorkScheduler.startWorkScheduler()
+    }
+
+    private fun createWakeLock(): PowerManager.WakeLock = powerManager
+        .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "CWA-WAKE-${UUID.randomUUID()}")
+        .apply { acquire(TEN_MINUTE_TIMEOUT_IN_MS) }
+
+    private fun createWifiLock(): WifiManager.WifiLock = wifiManager
+        .createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "CWA-WIFI-${UUID.randomUUID()}")
+        .apply { acquire() }
+
+    companion object {
+        private const val TEN_MINUTE_TIMEOUT_IN_MS = 10 * 60 * 1000L
+    }
+}
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 3d9b8556e85efb62167e716764fb5607c2943294..7f90b2cf5494101e1063517762db380aa86e1208 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
@@ -12,13 +12,15 @@ import de.rki.coronawarnapp.diagnosiskeys.download.KeyFileDownloader
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.environment.EnvironmentModule
 import de.rki.coronawarnapp.http.HttpModule
-import de.rki.coronawarnapp.http.ServiceFactory
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.ENFModule
+import de.rki.coronawarnapp.playbook.Playbook
+import de.rki.coronawarnapp.playbook.PlaybookModule
 import de.rki.coronawarnapp.receiver.ReceiverBinder
 import de.rki.coronawarnapp.risk.RiskModule
 import de.rki.coronawarnapp.service.ServiceBinder
 import de.rki.coronawarnapp.storage.SettingsRepository
+import de.rki.coronawarnapp.submission.SubmissionModule
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisInjectionHelper
 import de.rki.coronawarnapp.transaction.RiskLevelInjectionHelper
 import de.rki.coronawarnapp.transaction.SubmitDiagnosisInjectionHelper
@@ -28,6 +30,7 @@ import de.rki.coronawarnapp.util.UtilModule
 import de.rki.coronawarnapp.util.device.DeviceModule
 import de.rki.coronawarnapp.util.security.EncryptedPreferencesFactory
 import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool
+import de.rki.coronawarnapp.verification.VerificationModule
 import javax.inject.Singleton
 
 @Singleton
@@ -47,7 +50,9 @@ import javax.inject.Singleton
         EnvironmentModule::class,
         DiagnosisKeysModule::class,
         AppConfigModule::class,
-        EnvironmentModule::class
+        SubmissionModule::class,
+        VerificationModule::class,
+        PlaybookModule::class
     ]
 )
 interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
@@ -63,7 +68,6 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
 
     val keyCacheRepository: KeyCacheRepository
     val keyFileDownloader: KeyFileDownloader
-    val serviceFactory: ServiceFactory
 
     val appConfigProvider: AppConfigProvider
 
@@ -72,6 +76,8 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
     val encryptedPreferencesFactory: EncryptedPreferencesFactory
     val errorResetTool: EncryptionErrorResetTool
 
+    val playbook: Playbook
+
     @Component.Factory
     interface Factory {
         fun create(@BindsInstance app: CoronaWarnApplication): ApplicationComponent
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 83f5eda69b4fc7c6552b1a697ad79c54281e60e5..35ac654628a44f338b4c982bd3ae413cdcdb3507 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
@@ -1,9 +1,9 @@
 package de.rki.coronawarnapp.util.security
 
-import KeyExportFormat
 import android.security.keystore.KeyProperties
 import android.util.Base64
 import de.rki.coronawarnapp.environment.EnvironmentSetup
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
 import timber.log.Timber
 import java.security.KeyFactory
 import java.security.Signature
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/CWAViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/CWAViewModel.kt
index 065a6bc2bf629d194c66dbb9976fd70776ba10b7..deaf4368cafc84eafcd3c6e4a6a5daf4150d93ad 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/CWAViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/CWAViewModel.kt
@@ -2,6 +2,9 @@ package de.rki.coronawarnapp.util.viewmodel
 
 import androidx.annotation.CallSuper
 import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
 import timber.log.Timber
 
 abstract class CWAViewModel : ViewModel() {
@@ -12,6 +15,7 @@ abstract class CWAViewModel : ViewModel() {
 
     @CallSuper
     override fun onCleared() {
+        viewModelScope.launch(context = Dispatchers.Default) { }
         Timber.v("onCleared()")
         super.onCleared()
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/VerificationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/VerificationModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8bd41f974462a36ff85a6d1d3e3b0560960aa85f
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/VerificationModule.kt
@@ -0,0 +1,57 @@
+package de.rki.coronawarnapp.verification
+
+import android.content.Context
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+import de.rki.coronawarnapp.environment.verification.VerificationCDNServerUrl
+import de.rki.coronawarnapp.http.HttpClientDefault
+import de.rki.coronawarnapp.http.RestrictedConnectionSpecs
+import de.rki.coronawarnapp.verification.server.VerificationApiV1
+import de.rki.coronawarnapp.verification.server.VerificationHttpClient
+import okhttp3.Cache
+import okhttp3.ConnectionSpec
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.io.File
+import javax.inject.Singleton
+
+@Module
+class VerificationModule {
+
+    @Reusable
+    @VerificationHttpClient
+    @Provides
+    fun cdnHttpClient(
+        @HttpClientDefault defaultHttpClient: OkHttpClient,
+        @RestrictedConnectionSpecs connectionSpecs: List<ConnectionSpec>
+    ): OkHttpClient =
+        defaultHttpClient.newBuilder().connectionSpecs(connectionSpecs).build()
+
+    @Singleton
+    @Provides
+    fun provideVerificationApi(
+        context: Context,
+        @VerificationHttpClient client: OkHttpClient,
+        @VerificationCDNServerUrl url: String,
+        gsonConverterFactory: GsonConverterFactory
+    ): VerificationApiV1 {
+        val cache = Cache(File(context.cacheDir, "http_verification"), DEFAULT_CACHE_SIZE)
+
+        val cachingClient = client.newBuilder().apply {
+            cache(cache)
+        }.build()
+
+        return Retrofit.Builder()
+            .client(cachingClient)
+            .baseUrl(url)
+            .addConverterFactory(gsonConverterFactory)
+            .build()
+            .create(VerificationApiV1::class.java)
+    }
+
+    companion object {
+        private const val DEFAULT_CACHE_SIZE = 5 * 1024 * 1024L // 5MB
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationApiV1.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a45f6617de515e732f22c1e769bc37a788d7e301
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationApiV1.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.verification.server
+
+import com.google.gson.annotations.SerializedName
+import retrofit2.http.Body
+import retrofit2.http.Header
+import retrofit2.http.POST
+
+interface VerificationApiV1 {
+
+    data class RegistrationTokenRequest(
+        @SerializedName("keyType") val keyType: String? = null,
+        @SerializedName("key") val key: String? = null,
+        @SerializedName("requestPadding") val requestPadding: String? = null
+    )
+
+    data class RegistrationTokenResponse(
+        @SerializedName("registrationToken") val registrationToken: String
+    )
+
+    @POST("version/v1/registrationToken")
+    suspend fun getRegistrationToken(
+        @Header("cwa-fake") fake: String,
+        @Header("cwa-header-padding") headerPadding: String?,
+        @Body requestBody: RegistrationTokenRequest
+    ): RegistrationTokenResponse
+
+    data class RegistrationRequest(
+        @SerializedName("registrationToken") val registrationToken: String? = null,
+        @SerializedName("requestPadding") val requestPadding: String? = null
+    )
+
+    data class TestResultResponse(
+        @SerializedName("testResult") val testResult: Int
+    )
+
+    @POST("version/v1/testresult")
+    suspend fun getTestResult(
+        @Header("cwa-fake") fake: String,
+        @Header("cwa-header-padding") headerPadding: String?,
+        @Body request: RegistrationRequest
+    ): TestResultResponse
+
+    data class TanRequestBody(
+        @SerializedName("registrationToken") val registrationToken: String? = null,
+        @SerializedName("requestPadding") val requestPadding: String? = null
+    )
+
+    data class TanResponse(
+        @SerializedName("tan") val tan: String
+    )
+
+    @POST("version/v1/tan")
+    suspend fun getTAN(
+        @Header("cwa-fake") fake: String,
+        @Header("cwa-header-padding") headerPadding: String?,
+        @Body requestBody: TanRequestBody
+    ): TanResponse
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationHttpClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationHttpClient.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1c244ae61732a1e8ab53ca6acc6ec5303c5b203b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationHttpClient.kt
@@ -0,0 +1,8 @@
+package de.rki.coronawarnapp.verification.server
+
+import javax.inject.Qualifier
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class VerificationHttpClient
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationKeyType.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationKeyType.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f8112264c1a6c5491e6c5f0c7483c02044582f38
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationKeyType.kt
@@ -0,0 +1,5 @@
+package de.rki.coronawarnapp.verification.server
+
+enum class VerificationKeyType {
+    GUID, TELETAN;
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationServer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1411ca60db6ce02dcdbfb997bbe617345ee3b568
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/verification/server/VerificationServer.kt
@@ -0,0 +1,100 @@
+package de.rki.coronawarnapp.verification.server
+
+import dagger.Lazy
+import de.rki.coronawarnapp.util.PaddingTool.requestPadding
+import de.rki.coronawarnapp.util.security.HashHelper
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class VerificationServer @Inject constructor(
+    private val verificationAPI: Lazy<VerificationApiV1>
+) {
+
+    private val api: VerificationApiV1
+        get() = verificationAPI.get()
+
+    suspend fun retrieveRegistrationToken(
+        key: String,
+        keyType: VerificationKeyType
+    ): String = withContext(Dispatchers.IO) {
+        val keyStr = if (keyType == VerificationKeyType.GUID) {
+            HashHelper.hash256(key)
+        } else {
+            key
+        }
+
+        val paddingLength = when (keyType) {
+            VerificationKeyType.GUID -> PADDING_LENGTH_BODY_REGISTRATION_TOKEN_GUID
+            VerificationKeyType.TELETAN -> PADDING_LENGTH_BODY_REGISTRATION_TOKEN_TELETAN
+        }
+
+        api.getRegistrationToken(
+            fake = "0",
+            headerPadding = requestPadding(PADDING_LENGTH_HEADER_REGISTRATION_TOKEN),
+            requestBody = VerificationApiV1.RegistrationTokenRequest(
+                keyType = keyType.name,
+                key = keyStr,
+                requestPadding = requestPadding(paddingLength)
+            )
+        ).registrationToken
+    }
+
+    suspend fun retrieveTestResults(
+        registrationToken: String
+    ): Int = withContext(Dispatchers.IO) {
+        api.getTestResult(
+            fake = "0",
+            headerPadding = requestPadding(PADDING_LENGTH_HEADER_TEST_RESULT),
+            request = VerificationApiV1.RegistrationRequest(
+                registrationToken,
+                requestPadding(PADDING_LENGTH_BODY_TEST_RESULT)
+            )
+        ).testResult
+    }
+
+    suspend fun retrieveTan(
+        registrationToken: String
+    ): String = withContext(Dispatchers.IO) {
+        api.getTAN(
+            fake = "0",
+            headerPadding = requestPadding(PADDING_LENGTH_HEADER_TAN),
+            requestBody = VerificationApiV1.TanRequestBody(
+                registrationToken,
+                requestPadding(PADDING_LENGTH_BODY_TAN)
+            )
+        ).tan
+    }
+
+    suspend fun retrieveTanFake() = withContext(Dispatchers.IO) {
+        api.getTAN(
+            fake = "1",
+            headerPadding = requestPadding(PADDING_LENGTH_HEADER_TAN),
+            requestBody = VerificationApiV1.TanRequestBody(
+                registrationToken = DUMMY_REGISTRATION_TOKEN,
+                requestPadding = requestPadding(PADDING_LENGTH_BODY_TAN_FAKE)
+            )
+        )
+    }
+
+    companion object {
+        // padding registration token
+        private const val VERIFICATION_BODY_FILL = 139
+
+        const val PADDING_LENGTH_HEADER_REGISTRATION_TOKEN = 0
+        const val PADDING_LENGTH_BODY_REGISTRATION_TOKEN_TELETAN = 51 + VERIFICATION_BODY_FILL
+        const val PADDING_LENGTH_BODY_REGISTRATION_TOKEN_GUID = 0 + VERIFICATION_BODY_FILL
+
+        // padding test result
+        const val PADDING_LENGTH_HEADER_TEST_RESULT = 7
+        const val PADDING_LENGTH_BODY_TEST_RESULT = 31 + VERIFICATION_BODY_FILL
+
+        // padding tan
+        const val PADDING_LENGTH_HEADER_TAN = 14
+        const val PADDING_LENGTH_BODY_TAN = 31 + VERIFICATION_BODY_FILL
+        const val PADDING_LENGTH_BODY_TAN_FAKE = 31 + VERIFICATION_BODY_FILL
+        const val DUMMY_REGISTRATION_TOKEN = "11111111-2222-4444-8888-161616161616"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt
index 87e9a8c551721e4ac8c1dec420ed2803dd313744..724ac760cf52bd1843702506d759ca4f1616620d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt
@@ -3,8 +3,8 @@ package de.rki.coronawarnapp.worker
 import android.content.Context
 import androidx.work.CoroutineWorker
 import androidx.work.WorkerParameters
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.http.playbook.PlaybookImpl
+import de.rki.coronawarnapp.playbook.Playbook
+import de.rki.coronawarnapp.util.di.AppInjector
 
 /**
  * One time background noise worker
@@ -17,9 +17,8 @@ class BackgroundNoiseOneTimeWorker(
 ) :
     CoroutineWorker(context, workerParams) {
 
-    companion object {
-        private val TAG: String? = BackgroundNoiseOneTimeWorker::class.simpleName
-    }
+    private val playbook: Playbook
+        get() = AppInjector.component.playbook
 
     /**
      * Work execution
@@ -30,8 +29,7 @@ class BackgroundNoiseOneTimeWorker(
         var result = Result.success()
 
         try {
-            PlaybookImpl(WebRequestBuilder.getInstance())
-                .dummy()
+            playbook.dummy()
         } catch (e: Exception) {
             // TODO: Should we even retry here?
             result = if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
diff --git a/Corona-Warn-App/src/main/res/values-de/strings.xml b/Corona-Warn-App/src/main/res/values-de/strings.xml
index aeebdecde8991a4f5295ae1f7ea2900d1e971d3c..ab68325a65ac1bdcd09e8b386dd2a08b9bf5fcc6 100644
--- a/Corona-Warn-App/src/main/res/values-de/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-de/strings.xml
@@ -1308,7 +1308,7 @@
     <string name="country_name_sk">Slowakei</string>
 
     <!-- XHED: Title of the interoperbaility information view. -->
-    <string name="interoperability_title">Europaweite\nRisiko-Ermittlung</string>
+    <string name="interoperability_title">Länderübergreifende\nRisiko-Ermittlung</string>
 
     <!-- XHED: Setting title of interoperability in the tracing settings view -->
     <string name="settings_interoperability_title">Länderübergreifende Risiko-Ermittlung</string>
@@ -1320,7 +1320,7 @@
     <!-- XTXT: First section after the header of the interoperability information/configuration view -->
     <string name="interoperability_configuration_first_section">Die Corona-Warn-App warnt länderübergreifend vor möglichen Infektionen, indem Daten über eine europäische Infrastruktur ausgetauscht werden.</string>
     <!-- XTXT: Second section after the header of the interoperability information/configuration view -->
-    <string name="interoperability_configuration_second_section">Es werden keine persönlischen Daten ausgetauscht. Für den länderübergreifenden Datenaustausch über die Corona-Warn-App fallen keine zusätzlichen Kosten für Sie an.</string>
+    <string name="interoperability_configuration_second_section">Es werden keine persönlichen Daten ausgetauscht. Für den länderübergreifenden Datenaustausch über die Corona-Warn-App fallen keine zusätzlichen Kosten für Sie an.</string>
     <!-- XHED: Header right above the country list in the interoperability information/configuration view -->
     <string name="interoperability_configuration_list_title">Derzeit nehmen die folgenden Länder teil:</string>
 
@@ -1336,7 +1336,7 @@
     <string name="interoperability_onboarding_list_title">Derzeit nehmen die folgenden Länder teil:</string>
 
     <!-- XHED: Header of the delta onboarding screen for interoperability. If the user opens the app for the first time after the interoperability update -->
-    <string name="interoperability_onboarding_delta_title">Europaweite\nRisiko-Ermittlung</string>
+    <string name="interoperability_onboarding_delta_title">Länderübergreifende\nRisiko-Ermittlung</string>
     <!-- XTXT: Description of the interoperability extension of the app. Below interoperability_onboarding_delta_title -->
     <string name="interoperability_onboarding_delta_subtitle">Die Funktion der Corona-Warn-App wurde erweitert. Es arbeiten nun mehrere Länder in der EU zusammen, um über das gemeinsam betriebene Serversystem länderübergreifende Warnungen zu ermöglichen. So können bei der Risiko-Ermittlung jetzt auch die Kontakte mit Nutzern der offiziellen Corona-Apps anderer teilnehmender Länder berücksichtigt werden.</string>
     <!-- XHED: Header of private data in the delta onboarding interoperability view -->
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/AppConfigProviderTest.kt
similarity index 93%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
index 31df2ce6a15d34bc9a66b19c7433bd13f055d521..b61f6775769bbd8f8055cd59a4c41bf9219ced86 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigServerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
@@ -1,6 +1,5 @@
 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
@@ -21,7 +20,7 @@ import testhelpers.BaseIOTest
 import java.io.File
 import java.io.IOException
 
-class AppConfigServerTest : BaseIOTest() {
+class AppConfigProviderTest : BaseIOTest() {
 
     @MockK lateinit var api: AppConfigApiV1
     @MockK lateinit var verificationKeys: VerificationKeys
@@ -52,7 +51,7 @@ class AppConfigServerTest : BaseIOTest() {
     private fun createDownloadServer(
         homeCountry: LocationCode = defaultHomeCountry
     ) = AppConfigProvider(
-        appConfigAPI = Lazy { api },
+        appConfigAPI = { api },
         verificationKeys = verificationKeys,
         homeCountry = homeCountry,
         configStorage = appConfigStorage
@@ -185,6 +184,17 @@ class AppConfigServerTest : BaseIOTest() {
         }
     }
 
+    // Because the UI requires this to detect when to show alternative UI elements
+    @Test
+    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
+
+        runBlocking {
+            createDownloadServer().getAppConfig().supportedCountriesList shouldBe emptyList()
+        }
+    }
+
     companion object {
         private val APPCONFIG_BUNDLE =
             ("504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" +
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/ApplicationConfigurationExtensionsTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2b0301482234f80c7bf19fca063a893adfeb194e
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt
@@ -0,0 +1,22 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class ApplicationConfigurationExtensionsTest : BaseTest() {
+
+    @Test
+    fun `to new Config`() {
+        val orig = ApplicationConfiguration.newBuilder().addSupportedCountries("NL").build()
+        orig.supportedCountriesList shouldBe listOf("NL")
+
+        orig.toNewConfig {
+            clearSupportedCountries()
+            addSupportedCountries("DE")
+        }.supportedCountriesList shouldBe listOf("DE")
+
+        orig.supportedCountriesList shouldBe listOf("NL")
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/WebRequestBuilderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/WebRequestBuilderTest.kt
deleted file mode 100644
index cb1bb4a51e275f4d1c4e09c43bfd51eecf915985..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/WebRequestBuilderTest.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package de.rki.coronawarnapp.http
-
-import de.rki.coronawarnapp.http.service.SubmissionService
-import de.rki.coronawarnapp.http.service.VerificationService
-import de.rki.coronawarnapp.util.security.VerificationKeys
-import io.mockk.MockKAnnotations
-import io.mockk.impl.annotations.MockK
-import io.mockk.unmockkAll
-import org.junit.After
-import org.junit.Before
-
-class WebRequestBuilderTest {
-    @MockK
-    private lateinit var verificationService: VerificationService
-
-    @MockK
-    private lateinit var submissionService: SubmissionService
-
-    @MockK
-    private lateinit var verificationKeys: VerificationKeys
-
-    private lateinit var webRequestBuilder: WebRequestBuilder
-
-    @Before
-    fun setUp() = run {
-        MockKAnnotations.init(this)
-        webRequestBuilder = WebRequestBuilder(
-            verificationService,
-            submissionService
-        )
-    }
-
-    @After
-    fun tearDown() = unmockkAll()
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/DefaultPlaybookTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/DefaultPlaybookTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0b12d642f1af921448573e95ea908ac221fbb8e1
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/DefaultPlaybookTest.kt
@@ -0,0 +1,240 @@
+package de.rki.coronawarnapp.http.playbook
+
+import de.rki.coronawarnapp.playbook.DefaultPlaybook
+import de.rki.coronawarnapp.playbook.Playbook
+import de.rki.coronawarnapp.submission.server.SubmissionServer
+import de.rki.coronawarnapp.util.formatter.TestResult
+import de.rki.coronawarnapp.verification.server.VerificationKeyType
+import de.rki.coronawarnapp.verification.server.VerificationServer
+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.coVerifySequence
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.exceptions.TestException
+
+class DefaultPlaybookTest : BaseTest() {
+
+    @MockK lateinit var submissionServer: SubmissionServer
+    @MockK lateinit var verificationServer: VerificationServer
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        coEvery { verificationServer.retrieveRegistrationToken(any(), any()) } returns "token"
+        coEvery { verificationServer.retrieveTestResults(any()) } returns 0
+        coEvery { verificationServer.retrieveTanFake() } returns mockk()
+        coEvery { verificationServer.retrieveTan(any()) } returns "tan"
+
+        coEvery { submissionServer.submitKeysToServer(any()) } returns mockk()
+        coEvery { submissionServer.submitKeysToServerFake() } returns mockk()
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createPlaybook() = DefaultPlaybook(
+        verificationServer = verificationServer,
+        submissionServer = submissionServer
+    )
+
+    @Test
+    fun `initial registration pattern matches`(): Unit = runBlocking {
+        coEvery { verificationServer.retrieveRegistrationToken(any(), any()) } returns "response"
+
+        createPlaybook().initialRegistration("9A3B578UMG", VerificationKeyType.TELETAN)
+
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveRegistrationToken(any(), any())
+            verificationServer.retrieveTestResults(any())
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+
+    @Test
+    fun ` registration pattern matches despite token failure`(): Unit = runBlocking {
+        coEvery {
+            verificationServer.retrieveRegistrationToken(any(), any())
+        } throws TestException()
+
+        shouldThrow<TestException> {
+            createPlaybook().initialRegistration("9A3B578UMG", VerificationKeyType.TELETAN)
+        }
+
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveRegistrationToken(any(), any())
+            verificationServer.retrieveTanFake()
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+
+    @Test
+    fun `submission matches request pattern`(): Unit = runBlocking {
+        coEvery { verificationServer.retrieveTan(any()) } returns "tan"
+
+        createPlaybook().submission(
+            Playbook.SubmissionData(
+                registrationToken = "token",
+                temporaryExposureKeys = listOf(),
+                consentToFederation = true,
+                visistedCountries = listOf("DE")
+            )
+        )
+
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveTan(any())
+            verificationServer.retrieveTanFake()
+            submissionServer.submitKeysToServer(any())
+        }
+    }
+
+    @Test
+    fun `submission matches request pattern despite missing authcode`(): Unit = runBlocking {
+        coEvery { verificationServer.retrieveTan(any()) } throws TestException()
+
+        shouldThrow<TestException> {
+            createPlaybook().submission(
+                Playbook.SubmissionData(
+                    registrationToken = "token",
+                    temporaryExposureKeys = listOf(),
+                    consentToFederation = true,
+                    visistedCountries = listOf("DE")
+                )
+            )
+        }
+
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveTan(any())
+            verificationServer.retrieveTanFake()
+            // Only called when null TAN is returned? But when does that happen?
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+
+    @Test
+    fun `test result retrieval matches pattern`(): Unit = runBlocking {
+        coEvery { verificationServer.retrieveTestResults(any()) } returns 0
+
+        createPlaybook().testResult("token")
+
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveTestResults(any())
+            verificationServer.retrieveTanFake()
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+
+    @Test
+    fun `dummy request pattern matches`(): Unit = runBlocking {
+        createPlaybook().dummy()
+
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveTanFake()
+            verificationServer.retrieveTanFake()
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+
+    @Test
+    fun `failures during dummy requests should be ignored`(): Unit = runBlocking {
+        val expectedToken = "token"
+        coEvery { verificationServer.retrieveRegistrationToken(any(), any()) } returns expectedToken
+        val expectedResult = TestResult.PENDING
+        coEvery { verificationServer.retrieveTestResults(expectedToken) } returns expectedResult.value
+        coEvery { submissionServer.submitKeysToServerFake() } throws TestException()
+
+        val (registrationToken, testResult) = createPlaybook()
+            .initialRegistration("key", VerificationKeyType.GUID)
+
+        registrationToken shouldBe expectedToken
+        testResult shouldBe expectedResult
+    }
+
+    @Test
+    fun `registration pattern matches despire token failure`(): Unit = runBlocking {
+        coEvery {
+            verificationServer.retrieveRegistrationToken(any(), any())
+        } throws TestException()
+
+        shouldThrow<TestException> {
+            createPlaybook().initialRegistration("9A3B578UMG", VerificationKeyType.TELETAN)
+        }
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveRegistrationToken(any(), any())
+            verificationServer.retrieveTanFake()
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+
+    @Test
+    fun `registration pattern matches despite test result failure`(): Unit = runBlocking {
+        coEvery { verificationServer.retrieveTestResults(any()) } throws TestException()
+
+        shouldThrow<TestException> {
+            createPlaybook().initialRegistration("9A3B578UMG", VerificationKeyType.TELETAN)
+        }
+
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveRegistrationToken(any(), any())
+            verificationServer.retrieveTestResults(any())
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+
+    @Test
+    fun `test result pattern matches despite failure`(): Unit = runBlocking {
+        coEvery { verificationServer.retrieveTestResults(any()) } throws TestException()
+
+        shouldThrow<TestException> {
+            createPlaybook().testResult("token")
+        }
+
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveTestResults(any())
+            verificationServer.retrieveTanFake()
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+
+    @Test
+    fun `submission pattern matches despite tan failure`(): Unit = runBlocking {
+        coEvery { verificationServer.retrieveTan(any()) } throws TestException()
+
+        shouldThrow<TestException> {
+            createPlaybook().submission(
+                Playbook.SubmissionData(
+                    registrationToken = "token",
+                    temporaryExposureKeys = listOf(),
+                    consentToFederation = true,
+                    visistedCountries = listOf("DE")
+                )
+            )
+        }
+        coVerifySequence {
+            // ensure request order is 2x verification and 1x submission
+            verificationServer.retrieveTan(any())
+            verificationServer.retrieveTanFake()
+            submissionServer.submitKeysToServerFake()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/PlaybookImplTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/PlaybookImplTest.kt
deleted file mode 100644
index 508ed2d00de963e84cf299f0a07687d326c469cb..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/PlaybookImplTest.kt
+++ /dev/null
@@ -1,187 +0,0 @@
-package de.rki.coronawarnapp.http.playbook
-
-import de.rki.coronawarnapp.exception.http.InternalServerErrorException
-import de.rki.coronawarnapp.service.submission.KeyType
-import de.rki.coronawarnapp.util.formatter.TestResult
-import de.rki.coronawarnapp.util.newWebRequestBuilder
-import kotlinx.coroutines.runBlocking
-import okhttp3.mockwebserver.MockResponse
-import okhttp3.mockwebserver.MockWebServer
-import org.hamcrest.MatcherAssert.assertThat
-import org.hamcrest.Matchers
-import org.hamcrest.Matchers.equalTo
-import org.junit.Assert.fail
-import org.junit.Test
-
-class PlaybookImplTest {
-
-    @Test
-    fun hasRequestPattern_initialRegistration(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        server.enqueue(MockResponse().setBody("""{"registrationToken":"response"}"""))
-        server.enqueue(MockResponse().setBody("{}"))
-        server.enqueue(MockResponse().setBody("{}"))
-
-        PlaybookImpl(server.newWebRequestBuilder())
-            .initialRegistration("9A3B578UMG", KeyType.TELETAN)
-
-        // ensure request order is 2x verification and 1x submission
-        assertRequestPattern(server)
-    }
-
-    @Test
-    fun hasRequestPattern_submission(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        server.enqueue(MockResponse().setBody("""{"tan":"response"}"""))
-        server.enqueue(MockResponse().setBody("{}"))
-        server.enqueue(MockResponse().setBody("{}"))
-
-        PlaybookImpl(server.newWebRequestBuilder())
-            .submission("token", listOf())
-
-        // ensure request order is 2x verification and 1x submission
-        assertRequestPattern(server)
-    }
-
-    @Test
-    fun hasRequestPattern_testResult(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        server.enqueue(MockResponse().setBody("""{"testResult":0}"""))
-        server.enqueue(MockResponse().setBody("{}"))
-        server.enqueue(MockResponse().setBody("{}"))
-
-        PlaybookImpl(server.newWebRequestBuilder())
-            .testResult("token")
-
-        // ensure request order is 2x verification and 1x submission
-        assertRequestPattern(server)
-    }
-
-    @Test
-    fun hasRequestPattern_dummy(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        server.enqueue(MockResponse().setBody("{}"))
-        server.enqueue(MockResponse().setBody("{}"))
-        server.enqueue(MockResponse().setBody("{}"))
-
-        PlaybookImpl(server.newWebRequestBuilder())
-            .dummy()
-
-        // ensure request order is 2x verification and 1x submission
-        assertRequestPattern(server)
-    }
-
-    @Test
-    fun shouldIgnoreFailuresForDummyRequests(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        val expectedRegistrationToken = "token"
-        val expectedTestResult = TestResult.PENDING
-        server.enqueue(MockResponse().setBody("""{"registrationToken":"$expectedRegistrationToken"}"""))
-        server.enqueue(MockResponse().setBody("""{"testResult":${expectedTestResult.value}}"""))
-        server.enqueue(MockResponse().setResponseCode(500))
-
-        val (registrationToken, testResult) = PlaybookImpl(server.newWebRequestBuilder())
-            .initialRegistration("key", KeyType.GUID)
-
-        assertThat(registrationToken, equalTo(expectedRegistrationToken))
-        assertThat(testResult, equalTo(expectedTestResult))
-    }
-
-    @Test
-    fun hasRequestPatternWhenRealRequestFails_initialRegistrationFirst(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        server.enqueue(MockResponse().setResponseCode(500))
-        server.enqueue(MockResponse().setBody("{}"))
-        server.enqueue(MockResponse().setBody("{}"))
-
-        try {
-
-            PlaybookImpl(server.newWebRequestBuilder())
-                .initialRegistration("9A3B578UMG", KeyType.TELETAN)
-            fail("exception propagation expected")
-        } catch (e: InternalServerErrorException) {
-        }
-
-        // ensure request order is 2x verification and 1x submission
-        assertRequestPattern(server)
-    }
-
-    @Test
-    fun hasRequestPatternWhenRealRequestFails_initialRegistrationSecond(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        server.enqueue(MockResponse().setBody("""{"registrationToken":"response"}"""))
-        server.enqueue(MockResponse().setResponseCode(500))
-        server.enqueue(MockResponse().setBody("{}"))
-
-        try {
-            PlaybookImpl(server.newWebRequestBuilder())
-                .initialRegistration("9A3B578UMG", KeyType.TELETAN)
-            fail("exception propagation expected")
-        } catch (e: InternalServerErrorException) {
-        }
-
-        // ensure request order is 2x verification and 1x submission
-        assertRequestPattern(server)
-    }
-
-    @Test
-    fun hasRequestPatternWhenRealRequestFails_testResult(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        server.enqueue(MockResponse().setResponseCode(500))
-        server.enqueue(MockResponse().setBody("{}"))
-        server.enqueue(MockResponse().setBody("{}"))
-
-        try {
-
-            PlaybookImpl(server.newWebRequestBuilder())
-                .testResult("token")
-            fail("exception propagation expected")
-        } catch (e: InternalServerErrorException) {
-        }
-
-        // ensure request order is 2x verification and 1x submission
-        assertRequestPattern(server)
-    }
-
-    @Test
-    fun hasRequestPatternWhenRealRequestFails_submission(): Unit = runBlocking {
-        val server = MockWebServer()
-        server.start()
-
-        server.enqueue(MockResponse().setResponseCode(500))
-        server.enqueue(MockResponse().setBody("{}"))
-        server.enqueue(MockResponse().setBody("{}"))
-
-        try {
-            PlaybookImpl(server.newWebRequestBuilder())
-                .submission("token", listOf())
-            fail("exception propagation expected")
-        } catch (e: InternalServerErrorException) {
-        }
-
-        // ensure request order is 2x verification and 1x submission
-        assertRequestPattern(server)
-    }
-
-    private fun assertRequestPattern(server: MockWebServer) {
-        assertThat(server.takeRequest().path, Matchers.startsWith("/verification/"))
-        assertThat(server.takeRequest().path, Matchers.startsWith("/verification/"))
-        assertThat(server.takeRequest().path, Matchers.startsWith("/submission/"))
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/SubmissionServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/SubmissionServiceTest.kt
deleted file mode 100644
index d8eae0c49d434fdee968072fa9afb1302672df73..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/SubmissionServiceTest.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package de.rki.coronawarnapp.http.service
-
-import de.rki.coronawarnapp.util.headerSizeIgnoringContentLength
-import de.rki.coronawarnapp.util.newWebRequestBuilder
-import kotlinx.coroutines.runBlocking
-import okhttp3.mockwebserver.MockResponse
-import okhttp3.mockwebserver.MockWebServer
-import org.junit.Assert
-import org.junit.Test
-
-class SubmissionServiceTest {
-
-    @Test
-    fun allRequestHaveSameFootprintForPlausibleDeniability(): Unit = runBlocking {
-
-        val server = MockWebServer()
-        server.start()
-
-        val webRequestBuilder = server.newWebRequestBuilder()
-
-        val authCodeExample = "39ec4930-7a1f-4d5d-921f-bfad3b6f1269"
-
-        server.enqueue(MockResponse().setBody("{}"))
-        webRequestBuilder.asyncSubmitKeysToServer(authCodeExample, listOf())
-
-        server.enqueue(MockResponse().setBody("{}"))
-        webRequestBuilder.asyncFakeSubmission()
-
-        val requests = listOf(
-            server.takeRequest(),
-            server.takeRequest()
-        )
-
-        // ensure all request have same size (header & body)
-        requests.zipWithNext().forEach { (a, b) ->
-            Assert.assertEquals(
-                "Header size mismatch: ",
-                a.headerSizeIgnoringContentLength(),
-                b.headerSizeIgnoringContentLength()
-            )
-        }
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/VerificationServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/VerificationServiceTest.kt
deleted file mode 100644
index 3dde34e4171d63c625a2d6db9a50acb6f19d2ee2..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/service/VerificationServiceTest.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package de.rki.coronawarnapp.http.service
-
-import de.rki.coronawarnapp.service.submission.KeyType
-import de.rki.coronawarnapp.util.headerSizeIgnoringContentLength
-import de.rki.coronawarnapp.util.newWebRequestBuilder
-import kotlinx.coroutines.runBlocking
-import okhttp3.mockwebserver.MockResponse
-import okhttp3.mockwebserver.MockWebServer
-import org.hamcrest.MatcherAssert.assertThat
-import org.hamcrest.Matchers.equalTo
-import org.junit.Test
-
-class VerificationServiceTest {
-
-    @Test
-    fun allRequestHaveSameFootprintForPlausibleDeniability(): Unit = runBlocking {
-
-        val server = MockWebServer()
-        server.start()
-
-        val webRequestBuilder = server.newWebRequestBuilder()
-
-        val guidExample = "3BF1D4-1C6003DD-733D-41F1-9F30-F85FA7406BF7"
-        val teletanExample = "9A3B578UMG"
-        val registrationTokenExample = "63b4d3ff-e0de-4bd4-90c1-17c2bb683a2f"
-
-        server.enqueue(MockResponse().setBody("{}"))
-        webRequestBuilder.asyncGetRegistrationToken(guidExample, KeyType.GUID)
-
-        server.enqueue(MockResponse().setBody("{}"))
-        webRequestBuilder.asyncGetRegistrationToken(teletanExample, KeyType.TELETAN)
-
-        server.enqueue(MockResponse().setBody("{}"))
-        webRequestBuilder.asyncGetTestResult(registrationTokenExample)
-
-        server.enqueue(MockResponse().setBody("{}"))
-        webRequestBuilder.asyncGetTan(registrationTokenExample)
-
-        server.enqueue(MockResponse().setBody("{}"))
-        webRequestBuilder.asyncFakeVerification()
-
-        val requests = listOf(
-            server.takeRequest(),
-            server.takeRequest(),
-            server.takeRequest(),
-            server.takeRequest(),
-            server.takeRequest()
-        )
-
-        // ensure all request have same size (header & body)
-        requests.forEach { assertThat(it.bodySize, equalTo(250L)) }
-
-        requests.zipWithNext().forEach { (a, b) ->
-            assertThat(
-                a.headerSizeIgnoringContentLength(),
-                equalTo(b.headerSizeIgnoringContentLength())
-            )
-        }
-    }
-}
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 a7fe73eef9a0051ef30f6e1efcb90c52941920ad..bfa68c95fc0416c210b03da08d6126c230b9bba7 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,7 +1,6 @@
 package de.rki.coronawarnapp.service.applicationconfiguration
 
 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
 import de.rki.coronawarnapp.util.di.AppInjector
@@ -25,7 +24,6 @@ class ApplicationConfigurationServiceTest : BaseTest() {
 
         CWADebug.isDebugBuildOrMode shouldBe true
 
-        mockkObject(WebRequestBuilder)
         val appConfig = mockk<ApplicationConfigurationOuterClass.ApplicationConfiguration>()
         val appConfigBuilder =
             mockk<ApplicationConfigurationOuterClass.ApplicationConfiguration.Builder>()
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstantsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstantsTest.kt
deleted file mode 100644
index 2bb68c7fbbf33e2697e2b5b9cf95b6217be86904..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/diagnosiskey/DiagnosisKeyConstantsTest.kt
+++ /dev/null
@@ -1,13 +0,0 @@
-package de.rki.coronawarnapp.service.diagnosiskey
-
-import org.junit.Assert
-import org.junit.Test
-
-class DiagnosisKeyConstantsTest {
-
-    @Test
-    fun allDiagnosisKeyConstants() {
-        Assert.assertEquals(DiagnosisKeyConstants.SERVER_ERROR_CODE_403, 403)
-        Assert.assertEquals(DiagnosisKeyConstants.DIAGNOSIS_KEYS_SUBMISSION_URL, "version/v1/diagnosis-keys")
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt
index b65d00b6985fe7c66fadaf97c33cb4ee90915817..5b070bfb4607c79ee19e4c294493c9043ac02fd3 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt
@@ -7,27 +7,8 @@ class SubmissionConstantsTest {
 
     @Test
     fun allSubmissionConstants() {
-        // TODO: Should we really keep these now?
-        Assert.assertEquals(KeyType.GUID.name, "GUID")
-        Assert.assertEquals(KeyType.TELETAN.name, "TELETAN")
-
-        Assert.assertEquals(
-            SubmissionConstants.REGISTRATION_TOKEN_URL,
-            "version/v1/registrationToken"
-        )
-        Assert.assertEquals(SubmissionConstants.TEST_RESULT_URL, "version/v1/testresult")
-        Assert.assertEquals(SubmissionConstants.TAN_REQUEST_URL, "version/v1/tan")
-
         Assert.assertEquals(QRScanResult.MAX_QR_CODE_LENGTH, 150)
         Assert.assertEquals(QRScanResult.MAX_GUID_LENGTH, 80)
         Assert.assertEquals(QRScanResult.GUID_SEPARATOR, '?')
-
-        Assert.assertEquals(SubmissionConstants.SERVER_ERROR_CODE_400, 400)
-
-        // dummy token passes server verification
-        Assert.assertTrue(
-            Regex("^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89aAbB][a-f0-9]{3}-[a-f0-9]{12}\$")
-                .matches(SubmissionConstants.DUMMY_REGISTRATION_TOKEN)
-        )
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionServiceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionServiceTest.kt
index 4768cb8c0beb02c4c1bc6d820d5b2b6b44ca0740..f5084219d09a5c1c661af6962dce58a69e44ea8a 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionServiceTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionServiceTest.kt
@@ -2,15 +2,21 @@ package de.rki.coronawarnapp.service.submission
 
 import de.rki.coronawarnapp.exception.NoGUIDOrTANSetException
 import de.rki.coronawarnapp.exception.NoRegistrationTokenSetException
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.http.playbook.BackgroundNoise
+import de.rki.coronawarnapp.playbook.BackgroundNoise
+import de.rki.coronawarnapp.playbook.Playbook
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.SubmissionRepository
 import de.rki.coronawarnapp.submission.Symptoms
 import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction
+import de.rki.coronawarnapp.util.di.AppInjector
+import de.rki.coronawarnapp.util.di.ApplicationComponent
 import de.rki.coronawarnapp.util.formatter.TestResult
+import de.rki.coronawarnapp.verification.server.VerificationKeyType
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
 import io.mockk.Runs
+import io.mockk.clearAllMocks
 import io.mockk.coEvery
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
@@ -18,29 +24,30 @@ import io.mockk.just
 import io.mockk.mockkObject
 import io.mockk.verify
 import kotlinx.coroutines.runBlocking
-import org.hamcrest.CoreMatchers.equalTo
-import org.hamcrest.MatcherAssert.assertThat
-import org.junit.Before
-import org.junit.Test
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
 
 class SubmissionServiceTest {
+
     private val guid = "123456-12345678-1234-4DA7-B166-B86D85475064"
     private val registrationToken = "asdjnskjfdniuewbheboqudnsojdff"
     private val testResult = TestResult.PENDING
 
-    @MockK
-    private lateinit var webRequestBuilder: WebRequestBuilder
-
-    @MockK
-    private lateinit var backgroundNoise: BackgroundNoise
+    @MockK lateinit var backgroundNoise: BackgroundNoise
+    @MockK lateinit var mockPlaybook: Playbook
+    @MockK lateinit var appComponent: ApplicationComponent
 
     private val symptoms = Symptoms(Symptoms.StartOf.OneToTwoWeeksAgo, Symptoms.Indication.POSITIVE)
 
-    @Before
+    @BeforeEach
     fun setUp() {
         MockKAnnotations.init(this)
-        mockkObject(WebRequestBuilder.Companion)
-        every { WebRequestBuilder.getInstance() } returns webRequestBuilder
+
+        mockkObject(AppInjector)
+        every { AppInjector.component } returns appComponent
+
+        every { appComponent.playbook } returns mockPlaybook
 
         mockkObject(BackgroundNoise.Companion)
         every { BackgroundNoise.getInstance() } returns backgroundNoise
@@ -56,9 +63,14 @@ class SubmissionServiceTest {
         every { LocalData.registrationToken() } returns null
     }
 
-    @Test(expected = NoGUIDOrTANSetException::class)
-    fun registerDeviceWithoutTANOrGUIDFails() {
-        runBlocking {
+    @AfterEach
+    fun cleanUp() {
+        clearAllMocks()
+    }
+
+    @Test
+    fun registerDeviceWithoutTANOrGUIDFails(): Unit = runBlocking {
+        shouldThrow<NoGUIDOrTANSetException> {
             SubmissionService.asyncRegisterDevice()
         }
     }
@@ -72,9 +84,9 @@ class SubmissionServiceTest {
         every { LocalData.devicePairingSuccessfulTimestamp(any()) } just Runs
 
         coEvery {
-            webRequestBuilder.asyncGetRegistrationToken(any(), KeyType.GUID)
-        } returns registrationToken
-        coEvery { webRequestBuilder.asyncGetTestResult(registrationToken) } returns testResult.value
+            mockPlaybook.initialRegistration(any(), VerificationKeyType.GUID)
+        } returns (registrationToken to TestResult.PENDING)
+        coEvery { mockPlaybook.testResult(registrationToken) } returns testResult
 
         every { backgroundNoise.scheduleDummyPattern() } just Runs
 
@@ -100,9 +112,9 @@ class SubmissionServiceTest {
         every { LocalData.devicePairingSuccessfulTimestamp(any()) } just Runs
 
         coEvery {
-            webRequestBuilder.asyncGetRegistrationToken(any(), KeyType.TELETAN)
-        } returns registrationToken
-        coEvery { webRequestBuilder.asyncGetTestResult(registrationToken) } returns testResult.value
+            mockPlaybook.initialRegistration(any(), VerificationKeyType.TELETAN)
+        } returns (registrationToken to TestResult.PENDING)
+        coEvery { mockPlaybook.testResult(registrationToken) } returns testResult
 
         every { backgroundNoise.scheduleDummyPattern() } just Runs
 
@@ -119,9 +131,9 @@ class SubmissionServiceTest {
         }
     }
 
-    @Test(expected = NoRegistrationTokenSetException::class)
-    fun requestTestResultWithoutRegistrationTokenFails() {
-        runBlocking {
+    @Test
+    fun requestTestResultWithoutRegistrationTokenFails(): Unit = runBlocking {
+        shouldThrow<NoRegistrationTokenSetException> {
             SubmissionService.asyncRequestTestResult()
         }
     }
@@ -129,16 +141,16 @@ class SubmissionServiceTest {
     @Test
     fun requestTestResultSucceeds() {
         every { LocalData.registrationToken() } returns registrationToken
-        coEvery { webRequestBuilder.asyncGetTestResult(registrationToken) } returns TestResult.NEGATIVE.value
+        coEvery { mockPlaybook.testResult(registrationToken) } returns TestResult.NEGATIVE
 
         runBlocking {
-            assertThat(SubmissionService.asyncRequestTestResult(), equalTo(TestResult.NEGATIVE))
+            SubmissionService.asyncRequestTestResult() shouldBe TestResult.NEGATIVE
         }
     }
 
-    @Test(expected = NoRegistrationTokenSetException::class)
-    fun submitExposureKeysWithoutRegistrationTokenFails() {
-        runBlocking {
+    @Test
+    fun submitExposureKeysWithoutRegistrationTokenFails(): Unit = runBlocking {
+        shouldThrow<NoRegistrationTokenSetException> {
             SubmissionService.asyncSubmitExposureKeys(listOf(), symptoms)
         }
     }
@@ -146,7 +158,13 @@ class SubmissionServiceTest {
     @Test
     fun submitExposureKeysSucceeds() {
         every { LocalData.registrationToken() } returns registrationToken
-        coEvery { SubmitDiagnosisKeysTransaction.start(registrationToken, any(), symptoms) } just Runs
+        coEvery {
+            SubmitDiagnosisKeysTransaction.start(
+                registrationToken,
+                any(),
+                symptoms
+            )
+        } just Runs
 
         runBlocking {
             SubmissionService.asyncSubmitExposureKeys(listOf(), symptoms)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/ExposureKeyHistoryCalculationsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/ExposureKeyHistoryCalculationsTest.kt
index eb5801c317ef42a1d5f3fc3cde27787ef1a20227..524d93eaf438ad3a40c5407f3b417ac9268db03c 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/ExposureKeyHistoryCalculationsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/ExposureKeyHistoryCalculationsTest.kt
@@ -1,7 +1,7 @@
 package de.rki.coronawarnapp.submission
 
-import KeyExportFormat
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
 import org.junit.Assert
 import org.junit.Before
 import org.junit.Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/SubmissionModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/SubmissionModuleTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ba8b4b91b1e824534587068ed8408cca176f9a64
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/SubmissionModuleTest.kt
@@ -0,0 +1,85 @@
+package de.rki.coronawarnapp.submission
+
+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 io.mockk.verify
+import okhttp3.ConnectionSpec
+import okhttp3.OkHttpClient
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import retrofit2.converter.gson.GsonConverterFactory
+import retrofit2.converter.protobuf.ProtoConverterFactory
+import testhelpers.BaseIOTest
+import java.io.File
+
+class SubmissionModuleTest : BaseIOTest() {
+
+    @MockK lateinit var context: Context
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val cacheFiles = File(testDir, "cache")
+
+    @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() = SubmissionModule()
+
+    @Test
+    fun `side effect free instantiation`() {
+        shouldNotThrowAny {
+            createModule()
+        }
+    }
+
+    @Test
+    fun `client creation uses connection specs`() {
+        val module = createModule()
+
+        val specs = listOf(ConnectionSpec.MODERN_TLS)
+        val client = OkHttpClient.Builder().build()
+
+        val newClient = module.cdnHttpClient(
+            defaultHttpClient = client,
+            connectionSpecs = specs
+        )
+
+        newClient.apply {
+            connectionSpecs shouldBe specs
+        }
+    }
+
+    @Test
+    fun `api uses a cache`() {
+        val module = createModule()
+
+        val client = OkHttpClient.Builder().build()
+
+        module.provideSubmissionApi(
+            context = context,
+            client = client,
+            url = "https://testurl",
+            gsonConverterFactory = GsonConverterFactory.create(),
+            protoConverterFactory = ProtoConverterFactory.create()
+        )
+
+        verify { context.cacheDir }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1Test.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1Test.kt
new file mode 100644
index 0000000000000000000000000000000000000000..68ac0a71049438798b0f02de956605b2e72d2ac2
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionApiV1Test.kt
@@ -0,0 +1,103 @@
+package de.rki.coronawarnapp.submission.server
+
+import android.content.Context
+import com.google.protobuf.ByteString
+import de.rki.coronawarnapp.http.HttpModule
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
+import de.rki.coronawarnapp.submission.SubmissionModule
+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.runBlocking
+import okhttp3.ConnectionSpec
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+import testhelpers.BaseTest
+import testhelpers.extensions.toJsonResponse
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+class SubmissionApiV1Test : BaseTest() {
+
+    @MockK
+    private lateinit var context: Context
+
+    private lateinit var webServer: MockWebServer
+    private lateinit var serverAddress: String
+
+    private val testDir = File(BaseIOTest.IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val cacheDir = File(testDir, "cache")
+    private val httpCacheDir = File(cacheDir, "http_submission")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { context.cacheDir } returns cacheDir
+
+        webServer = MockWebServer()
+        webServer.start()
+        serverAddress = "http://${webServer.hostName}:${webServer.port}"
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        webServer.shutdown()
+    }
+
+    private fun createAPI(): SubmissionApiV1 {
+        val httpModule = HttpModule()
+        val defaultHttpClient = httpModule.defaultHttpClient()
+
+        return SubmissionModule().let {
+            val downloadHttpClient = it.cdnHttpClient(
+                defaultHttpClient,
+                listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)
+            )
+            it.provideSubmissionApi(
+                context = context,
+                client = downloadHttpClient,
+                url = serverAddress,
+                gsonConverterFactory = httpModule.provideGSONConverter(),
+                protoConverterFactory = httpModule.provideProtoConverter()
+            )
+        }
+    }
+
+    @Test
+    fun `test submitKeys`(): Unit = runBlocking {
+        val api = createAPI()
+
+        """
+            {
+                "tan": "testTan"
+            }
+        """.toJsonResponse().apply { webServer.enqueue(this) }
+
+        val submissionPayload = KeyExportFormat.SubmissionPayload.newBuilder()
+            .setPadding(ByteString.copyFromUtf8("fakeKeyPadding"))
+            .build()
+
+        api.submitKeys(
+            authCode = "testAuthCode",
+            fake = "0",
+            headerPadding = "testPadding",
+            requestBody = submissionPayload
+        )
+
+        webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply {
+            headers["cwa-authorization"] shouldBe "testAuthCode"
+            headers["cwa-fake"] shouldBe "0"
+            headers["cwa-header-padding"] shouldBe "testPadding"
+            path shouldBe "/version/v1/diagnosis-keys"
+            body.readUtf8() shouldBe """fakeKeyPadding"""
+        }
+
+        httpCacheDir.exists() shouldBe true
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionServerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..68031d83b88d7f3ae4dc74c2a57c0358a3117968
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/server/SubmissionServerTest.kt
@@ -0,0 +1,166 @@
+package de.rki.coronawarnapp.submission.server
+
+import android.content.Context
+import com.google.protobuf.ByteString
+import de.rki.coronawarnapp.http.HttpModule
+import de.rki.coronawarnapp.server.protocols.KeyExportFormat
+import de.rki.coronawarnapp.submission.SubmissionModule
+import de.rki.coronawarnapp.util.headerSizeIgnoringContentLength
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.runBlocking
+import okhttp3.ConnectionSpec
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+import testhelpers.BaseTest
+import java.io.File
+
+class SubmissionServerTest : BaseTest() {
+    @MockK lateinit var submissionApi: SubmissionApiV1
+    @MockK lateinit var context: Context
+
+    private lateinit var webServer: MockWebServer
+    private lateinit var serverAddress: String
+
+    private val testDir = File(BaseIOTest.IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val cacheDir = File(testDir, "cache")
+    private val httpCacheDir = File(cacheDir, "http_submission")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+        every { context.cacheDir } returns cacheDir
+
+        webServer = MockWebServer()
+        webServer.start()
+        serverAddress = "http://${webServer.hostName}:${webServer.port}"
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        webServer.shutdown()
+        testDir.deleteRecursively()
+    }
+
+    private fun createServer(
+        customApi: SubmissionApiV1 = submissionApi
+    ) = SubmissionServer(submissionApi = { customApi })
+
+    @Test
+    fun `normal submission`(): Unit = runBlocking {
+        val testKeyData = ByteString.copyFrom("TestKeyDataGoogle", Charsets.UTF_8)
+
+        val server = createServer()
+        coEvery { submissionApi.submitKeys(any(), any(), any(), any()) } answers {
+            arg<String>(0) shouldBe "testAuthCode"
+            arg<String>(1) shouldBe "0"
+            arg<String>(2) shouldBe ""
+            arg<KeyExportFormat.SubmissionPayload>(3).apply {
+                keysList.single().keyData shouldBe testKeyData
+                padding.size() shouldBe 364
+                hasConsentToFederation() shouldBe true
+                visitedCountriesList shouldBe listOf("DE")
+            }
+            Unit
+        }
+
+        val googleKeyList = KeyExportFormat.TemporaryExposureKey
+            .newBuilder()
+            .setKeyData(testKeyData)
+            .build()
+
+        val submissionData = SubmissionServer.SubmissionData(
+            authCode = "testAuthCode",
+            keyList = listOf(googleKeyList),
+            consentToFederation = true,
+            visistedCountries = listOf("DE")
+        )
+        server.submitKeysToServer(submissionData)
+
+        coVerify { submissionApi.submitKeys(any(), any(), any(), any()) }
+    }
+
+    @Test
+    fun `fake submission`(): Unit = runBlocking {
+        val server = createServer()
+        coEvery { submissionApi.submitKeys(any(), any(), any(), any()) } answers {
+            arg<String>(0) shouldBe "" // cwa-authorization
+            arg<String>(1) shouldBe "1" // cwa-fake
+            arg<String>(2).length shouldBe 36 // cwa-header-padding
+            arg<KeyExportFormat.SubmissionPayload>(3).apply {
+                keysList.size shouldBe 0
+                padding.size() shouldBe 392
+                hasConsentToFederation() shouldBe false
+                visitedCountriesList shouldBe emptyList()
+            }
+            Unit
+        }
+
+        server.submitKeysToServerFake()
+
+        coVerify { submissionApi.submitKeys(any(), any(), any(), any()) }
+    }
+
+    private fun createRealApi(): SubmissionApiV1 {
+        val httpModule = HttpModule()
+        val defaultHttpClient = httpModule.defaultHttpClient()
+
+        return SubmissionModule().let {
+            val downloadHttpClient = it.cdnHttpClient(
+                defaultHttpClient,
+                listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)
+            )
+            it.provideSubmissionApi(
+                context = context,
+                client = downloadHttpClient,
+                url = serverAddress,
+                gsonConverterFactory = httpModule.provideGSONConverter(),
+                protoConverterFactory = httpModule.provideProtoConverter()
+            )
+        }
+    }
+
+    @Test
+    fun allRequestHaveSameFootprintForPlausibleDeniability(): Unit = runBlocking {
+        val server = createServer(createRealApi())
+
+        val testKeyData = ByteString.copyFrom("TestKeyDataGoogle", Charsets.UTF_8)
+        val googleKeyList = KeyExportFormat.TemporaryExposureKey
+            .newBuilder()
+            .setKeyData(testKeyData)
+            .build()
+        val submissionData = SubmissionServer.SubmissionData(
+            authCode = "39ec4930-7a1f-4d5d-921f-bfad3b6f1269",
+            keyList = listOf(googleKeyList),
+            consentToFederation = true,
+            visistedCountries = listOf("DE")
+        )
+        webServer.enqueue(MockResponse().setBody("{}"))
+        server.submitKeysToServer(submissionData)
+
+        webServer.enqueue(MockResponse().setBody("{}"))
+        server.submitKeysToServerFake()
+
+        val requests = listOf(
+            webServer.takeRequest(),
+            webServer.takeRequest()
+        )
+
+        // ensure all request have same size (header & body)
+        requests.zipWithNext().forEach { (a, b) ->
+            a.headerSizeIgnoringContentLength() shouldBe b.headerSizeIgnoringContentLength()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt
index 6840c334425ae639fc8357eac807863ba3215bf0..1bdd27360813d85da713af61bb1e6c3e298323df 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt
@@ -1,62 +1,59 @@
 package de.rki.coronawarnapp.transaction
 
-import KeyExportFormat
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.http.playbook.BackgroundNoise
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
+import de.rki.coronawarnapp.playbook.BackgroundNoise
+import de.rki.coronawarnapp.playbook.Playbook
+import de.rki.coronawarnapp.server.protocols.ApplicationConfigurationOuterClass.ApplicationConfiguration
 import de.rki.coronawarnapp.service.submission.SubmissionService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.submission.Symptoms
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.ApplicationComponent
 import de.rki.coronawarnapp.worker.BackgroundWorkScheduler
+import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
 import io.mockk.Runs
+import io.mockk.clearAllMocks
 import io.mockk.coEvery
-import io.mockk.coVerifyOrder
+import io.mockk.coVerifySequence
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
 import io.mockk.just
-import io.mockk.mockk
 import io.mockk.mockkObject
-import io.mockk.slot
-import io.mockk.unmockkAll
 import kotlinx.coroutines.runBlocking
-import org.hamcrest.CoreMatchers.`is`
-import org.hamcrest.MatcherAssert.assertThat
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
 
 class SubmitDiagnosisKeysTransactionTest {
 
-    @MockK
-    private lateinit var webRequestBuilder: WebRequestBuilder
+    @MockK lateinit var backgroundNoise: BackgroundNoise
+    @MockK lateinit var mockPlaybook: Playbook
+    @MockK lateinit var appConfigProvider: AppConfigProvider
+    @MockK lateinit var appComponent: ApplicationComponent
 
-    @MockK
-    private lateinit var backgroundNoise: BackgroundNoise
-
-    private val authString = "authString"
     private val registrationToken = "123"
 
     private val symptoms = Symptoms(Symptoms.StartOf.OneToTwoWeeksAgo, Symptoms.Indication.POSITIVE)
+    private val defaultCountries = listOf("DE", "NL", "FR")
 
-    @Before
+    @BeforeEach
     fun setUp() {
         MockKAnnotations.init(this)
 
+        val appConfig = ApplicationConfiguration.newBuilder()
+            .addAllSupportedCountries(defaultCountries)
+            .build()
+        coEvery { appConfigProvider.getAppConfig() } returns appConfig
+
+        every { appComponent.transSubmitDiagnosisInjection } returns SubmitDiagnosisInjectionHelper(
+            TransactionCoroutineScope(), mockPlaybook, appConfigProvider
+        )
         mockkObject(AppInjector)
-        val appComponent = mockk<ApplicationComponent>().apply {
-            every { transSubmitDiagnosisInjection } returns SubmitDiagnosisInjectionHelper(
-                TransactionCoroutineScope()
-            )
-        }
         every { AppInjector.component } returns appComponent
 
-        mockkObject(WebRequestBuilder.Companion)
-        every { WebRequestBuilder.getInstance() } returns webRequestBuilder
-
         mockkObject(BackgroundNoise.Companion)
         every { BackgroundNoise.getInstance() } returns backgroundNoise
 
@@ -66,54 +63,90 @@ class SubmitDiagnosisKeysTransactionTest {
         mockkObject(BackgroundWorkScheduler)
         every { BackgroundWorkScheduler.stopWorkScheduler() } just Runs
         every { LocalData.numberOfSuccessfulSubmissions(any()) } just Runs
-        coEvery { webRequestBuilder.asyncGetTan(registrationToken) } returns authString
+    }
+
+    @AfterEach
+    fun cleanUp() {
+        clearAllMocks()
     }
 
     @Test
-    fun testTransactionNoKeys() {
+    fun `submission without keys`(): Unit = runBlocking {
+        coEvery { mockPlaybook.submission(any()) } returns Unit
         coEvery { InternalExposureNotificationClient.asyncGetTemporaryExposureKeyHistory() } returns listOf()
-        coEvery { webRequestBuilder.asyncSubmitKeysToServer(authString, listOf()) } just Runs
 
-        runBlocking {
-            SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(), symptoms)
+        SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(), symptoms)
+
+        coVerifySequence {
+            appConfigProvider.getAppConfig()
+            mockPlaybook.submission(
+                Playbook.SubmissionData(
+                    registrationToken = registrationToken,
+                    temporaryExposureKeys = emptyList(),
+                    consentToFederation = true,
+                    visistedCountries = defaultCountries
+                )
+            )
+            SubmissionService.submissionSuccessful()
+        }
+    }
 
-            coVerifyOrder {
-                webRequestBuilder.asyncSubmitKeysToServer(authString, listOf())
-                SubmissionService.submissionSuccessful()
-            }
+    @Test
+    fun `submission without keys and fallback country`(): Unit = runBlocking {
+        val appConfig = ApplicationConfiguration.newBuilder().build()
+        coEvery { appConfigProvider.getAppConfig() } returns appConfig
+        coEvery { mockPlaybook.submission(any()) } returns Unit
+        coEvery { InternalExposureNotificationClient.asyncGetTemporaryExposureKeyHistory() } returns listOf()
+
+        SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(), symptoms)
+
+        coVerifySequence {
+            appConfigProvider.getAppConfig()
+            mockPlaybook.submission(
+                Playbook.SubmissionData(
+                    registrationToken = registrationToken,
+                    temporaryExposureKeys = emptyList(),
+                    consentToFederation = true,
+                    visistedCountries = listOf("DE")
+                )
+            )
+            SubmissionService.submissionSuccessful()
         }
     }
 
     @Test
-    fun testTransactionHasKeys() {
+    fun `submission with keys`(): Unit = runBlocking {
         val key = TemporaryExposureKey.TemporaryExposureKeyBuilder()
             .setKeyData(ByteArray(1))
             .setRollingPeriod(1)
             .setRollingStartIntervalNumber(1)
             .setTransmissionRiskLevel(1)
             .build()
-        val testList = slot<List<KeyExportFormat.TemporaryExposureKey>>()
+
         coEvery { InternalExposureNotificationClient.asyncGetTemporaryExposureKeyHistory() } returns listOf(
             key
         )
-        coEvery {
-            webRequestBuilder.asyncSubmitKeysToServer(authString, capture(testList))
-        } just Runs
-
-        runBlocking {
-            SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(key), symptoms)
-
-            coVerifyOrder {
-                webRequestBuilder.asyncSubmitKeysToServer(authString, any())
-                SubmissionService.submissionSuccessful()
+        coEvery { mockPlaybook.submission(any()) } answers {
+            arg<Playbook.SubmissionData>(0).also {
+                it.registrationToken shouldBe registrationToken
+                it.temporaryExposureKeys.single().apply {
+                    keyData.toByteArray() shouldBe ByteArray(1)
+                    rollingPeriod shouldBe 144
+                    rollingStartIntervalNumber shouldBe 1
+                    transmissionRiskLevel shouldBe 1
+                }
+                it.consentToFederation shouldBe true
+                it.visistedCountries shouldBe defaultCountries
             }
-            assertThat(testList.isCaptured, `is`(true))
-            assertThat(testList.captured.size, `is`(1))
+            Unit
         }
-    }
 
-    @After
-    fun cleanUp() {
-        unmockkAll()
+        SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(key), symptoms)
+
+        coVerifySequence {
+            appConfigProvider.getAppConfig()
+            mockPlaybook.submission(any())
+            SubmissionService.submissionSuccessful()
+        }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt
index 5a93e5ac213f30f811bfc8e57e5b8ece230c71ae..009decb3cf789031e01a271e6daa31dd21fb77f7 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt
@@ -1,10 +1,9 @@
 package de.rki.coronawarnapp.ui.viewmodel
 
-import androidx.arch.core.executor.testing.InstantTaskExecutorRule
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.http.playbook.BackgroundNoise
+import de.rki.coronawarnapp.playbook.BackgroundNoise
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.ui.submission.ScanStatus
+import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
 import io.mockk.Runs
 import io.mockk.every
@@ -12,42 +11,36 @@ import io.mockk.impl.annotations.MockK
 import io.mockk.just
 import io.mockk.mockkObject
 import org.junit.Assert
-import org.junit.Before
-import org.junit.Rule
-import org.junit.Test
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import testhelpers.extensions.InstantExecutorExtension
 
+@ExtendWith(InstantExecutorExtension::class)
 class SubmissionViewModelTest {
-    private var viewModel: SubmissionViewModel = SubmissionViewModel()
 
-    @JvmField
-    @Rule
-    var instantTaskExecutorRule: InstantTaskExecutorRule = InstantTaskExecutorRule()
+    @MockK lateinit var backgroundNoise: BackgroundNoise
 
-    @MockK
-    private lateinit var webRequestBuilder: WebRequestBuilder
-
-    @MockK
-    private lateinit var backgroundNoise: BackgroundNoise
-
-    @Before
+    @BeforeEach
     fun setUp() {
         MockKAnnotations.init(this)
 
         mockkObject(LocalData)
         every { LocalData.testGUID(any()) } just Runs
 
-        mockkObject(WebRequestBuilder.Companion)
-        every { WebRequestBuilder.getInstance() } returns webRequestBuilder
 
         mockkObject(BackgroundNoise.Companion)
         every { BackgroundNoise.getInstance() } returns backgroundNoise
     }
 
+    private fun createViewModel() = SubmissionViewModel()
+
     @Test
     fun scanStatusValid() {
+        val viewModel = createViewModel()
 
         // start
-        viewModel.scanStatus.value?.getContent().let { Assert.assertEquals(ScanStatus.STARTED, it) }
+        viewModel.scanStatus.value!!.getContent() shouldBe ScanStatus.STARTED
 
         // valid guid
         val guid = "123456-12345678-1234-4DA7-B166-B86D85475064"
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/MockWebServerUtil.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/MockWebServerUtil.kt
index 0dd704f73135ef01964264e3e2c9dbf281e69536..6f31074edc2393b995c0f1e87b41356c74fa34c9 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/MockWebServerUtil.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/MockWebServerUtil.kt
@@ -1,38 +1,7 @@
 package de.rki.coronawarnapp.util
 
-import de.rki.coronawarnapp.http.HttpErrorParser
-import de.rki.coronawarnapp.http.WebRequestBuilder
-import de.rki.coronawarnapp.http.interceptor.RetryInterceptor
-import de.rki.coronawarnapp.http.service.SubmissionService
-import de.rki.coronawarnapp.http.service.VerificationService
-import okhttp3.OkHttpClient
-import okhttp3.logging.HttpLoggingInterceptor
-import okhttp3.mockwebserver.MockWebServer
 import okhttp3.mockwebserver.RecordedRequest
 import okio.utf8Size
-import retrofit2.Retrofit
-import retrofit2.converter.gson.GsonConverterFactory
-import retrofit2.converter.protobuf.ProtoConverterFactory
-
-fun MockWebServer.newWebRequestBuilder(): WebRequestBuilder {
-    val httpClient = OkHttpClient.Builder()
-        .addInterceptor(HttpLoggingInterceptor().setLevel(HttpLoggingInterceptor.Level.BODY))
-        .addInterceptor(RetryInterceptor())
-        .addInterceptor(HttpErrorParser())
-        .build()
-
-    val retrofit = Retrofit.Builder()
-        .client(httpClient)
-        .addConverterFactory(ProtoConverterFactory.create())
-        .addConverterFactory(GsonConverterFactory.create())
-
-    return WebRequestBuilder(
-        retrofit.baseUrl(this.url("/verification/")).build()
-            .create(VerificationService::class.java),
-        retrofit.baseUrl(this.url("/submission/")).build()
-            .create(SubmissionService::class.java)
-    )
-}
 
 fun RecordedRequest.requestHeaderWithoutContentLength() =
     listOf(this.requestLine)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/PaddingToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/PaddingToolTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e6cef8427918b00337c31d6e1cda6ed0c2c92d11
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/PaddingToolTest.kt
@@ -0,0 +1,23 @@
+package de.rki.coronawarnapp.util
+
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import kotlin.math.abs
+import kotlin.random.Random
+
+class PaddingToolTest : BaseTest() {
+
+    private val validPattern = "^([A-Za-z0-9]+)$".toRegex()
+
+    @Test
+    fun `verify padding patterns`() {
+        repeat(100) {
+            val randomLength = abs(Random.nextInt(1024))
+            PaddingTool.requestPadding(randomLength).apply {
+                length shouldBe randomLength
+                validPattern.matches(this) shouldBe true
+            }
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/VerificationModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/VerificationModuleTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8ea767b587f35699e874af1e05f6b29094f5a493
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/VerificationModuleTest.kt
@@ -0,0 +1,83 @@
+package de.rki.coronawarnapp.verification
+
+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 io.mockk.verify
+import okhttp3.ConnectionSpec
+import okhttp3.OkHttpClient
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import retrofit2.converter.gson.GsonConverterFactory
+import testhelpers.BaseIOTest
+import java.io.File
+
+class VerificationModuleTest : BaseIOTest() {
+
+    @MockK lateinit var context: Context
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val cacheFiles = File(testDir, "cache")
+
+    @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() = VerificationModule()
+
+    @Test
+    fun `side effect free instantiation`() {
+        shouldNotThrowAny {
+            createModule()
+        }
+    }
+
+    @Test
+    fun `client creation uses connection specs`() {
+        val module = createModule()
+
+        val specs = listOf(ConnectionSpec.MODERN_TLS)
+        val client = OkHttpClient.Builder().build()
+
+        val newClient = module.cdnHttpClient(
+            defaultHttpClient = client,
+            connectionSpecs = specs
+        )
+
+        newClient.apply {
+            connectionSpecs shouldBe specs
+        }
+    }
+
+    @Test
+    fun `api uses a cache`() {
+        val module = createModule()
+
+        val client = OkHttpClient.Builder().build()
+
+        module.provideVerificationApi(
+            context = context,
+            client = client,
+            url = "https://testurl",
+            gsonConverterFactory = GsonConverterFactory.create()
+        )
+
+        verify { context.cacheDir }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationApiV1Test.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationApiV1Test.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3fe5a0c47a1f30a09a681788c97646221d91821e
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationApiV1Test.kt
@@ -0,0 +1,181 @@
+package de.rki.coronawarnapp.verification.server
+
+import android.content.Context
+import de.rki.coronawarnapp.http.HttpModule
+import de.rki.coronawarnapp.verification.VerificationModule
+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.runBlocking
+import okhttp3.ConnectionSpec
+import okhttp3.mockwebserver.MockWebServer
+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 testhelpers.extensions.toJsonResponse
+import java.io.File
+import java.util.concurrent.TimeUnit
+
+class VerificationApiV1Test : BaseIOTest() {
+
+    @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 cacheDir = File(testDir, "cache")
+    private val httpCacheDir = File(cacheDir, "http_verification")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { context.cacheDir } returns cacheDir
+
+        webServer = MockWebServer()
+        webServer.start()
+        serverAddress = "http://${webServer.hostName}:${webServer.port}"
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        webServer.shutdown()
+        testDir.deleteRecursively()
+    }
+
+    private fun createAPI(): VerificationApiV1 {
+        val httpModule = HttpModule()
+        val defaultHttpClient = httpModule.defaultHttpClient()
+        val gsonConverterFactory = httpModule.provideGSONConverter()
+
+        return VerificationModule().let {
+            val downloadHttpClient = it.cdnHttpClient(
+                defaultHttpClient,
+                listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)
+            )
+            it.provideVerificationApi(
+                context = context,
+                client = downloadHttpClient,
+                url = serverAddress,
+                gsonConverterFactory = gsonConverterFactory
+            )
+        }
+    }
+
+    @Test
+    fun `test getRegistrationToken`(): Unit = runBlocking {
+        val api = createAPI()
+
+        """
+            {
+                "registrationToken": "testRegistrationToken"
+            }
+        """.toJsonResponse().apply { webServer.enqueue(this) }
+
+        val requestBody = VerificationApiV1.RegistrationTokenRequest(
+            keyType = "testKeyType",
+            key = "testKey",
+            requestPadding = "testRequestPadding"
+        )
+
+        api.getRegistrationToken(
+            fake = "0",
+            headerPadding = "testPadding",
+            requestBody
+        ) shouldBe VerificationApiV1.RegistrationTokenResponse(
+            registrationToken = "testRegistrationToken"
+        )
+
+        webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply {
+            headers["cwa-fake"] shouldBe "0"
+            headers["cwa-header-padding"] shouldBe "testPadding"
+            path shouldBe "/version/v1/registrationToken"
+            body.readUtf8() shouldBe """
+                {
+                    "keyType": "testKeyType",
+                    "key": "testKey",
+                    "requestPadding": "testRequestPadding"
+                }
+            """.toComparableJson()
+        }
+
+        httpCacheDir.exists() shouldBe true
+    }
+
+    @Test
+    fun `test getTestResult`(): Unit = runBlocking {
+        val api = createAPI()
+
+        """
+            {
+                "testResult": 1
+            }
+        """.toJsonResponse().apply { webServer.enqueue(this) }
+
+        val requestBody = VerificationApiV1.RegistrationRequest(
+            registrationToken = "testRegistrationToken",
+            requestPadding = "testRequestPadding"
+        )
+
+        api.getTestResult(
+            fake = "0",
+            headerPadding = "testPadding",
+            requestBody
+        ) shouldBe VerificationApiV1.TestResultResponse(
+            testResult = 1
+        )
+
+        webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply {
+            headers["cwa-fake"] shouldBe "0"
+            headers["cwa-header-padding"] shouldBe "testPadding"
+            path shouldBe "/version/v1/testresult"
+            body.readUtf8() shouldBe """
+                {
+                    "registrationToken": "testRegistrationToken",
+                    "requestPadding": "testRequestPadding"
+                }
+            """.toComparableJson()
+        }
+    }
+
+    @Test
+    fun `test getTAN`(): Unit = runBlocking {
+        val api = createAPI()
+
+        """
+            {
+                "tan": "testTan"
+            }
+        """.toJsonResponse().apply { webServer.enqueue(this) }
+
+        val requestBody = VerificationApiV1.TanRequestBody(
+            registrationToken = "testRegistrationToken",
+            requestPadding = "testRequestPadding"
+        )
+
+        api.getTAN(
+            fake = "0",
+            headerPadding = "testPadding",
+            requestBody
+        ) shouldBe VerificationApiV1.TanResponse(
+            tan = "testTan"
+        )
+
+        webServer.takeRequest(5, TimeUnit.SECONDS)!!.apply {
+            headers["cwa-fake"] shouldBe "0"
+            headers["cwa-header-padding"] shouldBe "testPadding"
+            path shouldBe "/version/v1/tan"
+            body.readUtf8() shouldBe """
+                {
+                    "registrationToken": "testRegistrationToken",
+                    "requestPadding": "testRequestPadding"
+                }
+            """.toComparableJson()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationServerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..463cb73d8daf556e681efa5fa61c09f099995727
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/verification/server/VerificationServerTest.kt
@@ -0,0 +1,215 @@
+package de.rki.coronawarnapp.verification.server
+
+import android.content.Context
+import de.rki.coronawarnapp.http.HttpModule
+import de.rki.coronawarnapp.util.headerSizeIgnoringContentLength
+import de.rki.coronawarnapp.verification.VerificationModule
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.runBlocking
+import okhttp3.ConnectionSpec
+import okhttp3.mockwebserver.MockResponse
+import okhttp3.mockwebserver.MockWebServer
+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 VerificationServerTest : BaseIOTest() {
+
+    @MockK lateinit var verificationApi: VerificationApiV1
+    @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 cacheDir = File(testDir, "cache")
+    private val httpCacheDir = File(cacheDir, "http_verification")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+        every { context.cacheDir } returns cacheDir
+
+        webServer = MockWebServer()
+        webServer.start()
+        serverAddress = "http://${webServer.hostName}:${webServer.port}"
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        webServer.shutdown()
+        testDir.deleteRecursively()
+    }
+
+    private fun createServer(
+        customApi: VerificationApiV1 = verificationApi
+    ) = VerificationServer(verificationAPI = { customApi })
+
+    @Test
+    fun `get registration token via GUID`(): Unit = runBlocking {
+        val server = createServer()
+        coEvery { verificationApi.getRegistrationToken(any(), any(), any()) } answers {
+            arg<String>(0) shouldBe "0"
+            arg<String>(1) shouldBe ""
+            arg<VerificationApiV1.RegistrationTokenRequest>(2).apply {
+                keyType shouldBe VerificationKeyType.GUID.name
+                key shouldBe "15291f67d99ea7bc578c3544dadfbb991e66fa69cb36ff70fe30e798e111ff5f"
+                requestPadding!!.length shouldBe 139
+            }
+            VerificationApiV1.RegistrationTokenResponse(
+                registrationToken = "testRegistrationToken"
+            )
+        }
+
+        server.retrieveRegistrationToken(
+            "testKey", VerificationKeyType.GUID
+        ) shouldBe "testRegistrationToken"
+
+        coVerify { verificationApi.getRegistrationToken(any(), any(), any()) }
+    }
+
+    @Test
+    fun `get registration token via TELETAN`() = runBlocking {
+        val server = createServer()
+        coEvery { verificationApi.getRegistrationToken(any(), any(), any()) } answers {
+            arg<String>(0) shouldBe "0"
+            arg<String>(1) shouldBe ""
+            arg<VerificationApiV1.RegistrationTokenRequest>(2).apply {
+                keyType shouldBe VerificationKeyType.TELETAN.name
+                key shouldBe "testKey"
+                requestPadding!!.length shouldBe 190
+            }
+            VerificationApiV1.RegistrationTokenResponse(
+                registrationToken = "testRegistrationToken"
+            )
+        }
+
+        server.retrieveRegistrationToken(
+            "testKey", VerificationKeyType.TELETAN
+        ) shouldBe "testRegistrationToken"
+
+        coVerify { verificationApi.getRegistrationToken(any(), any(), any()) }
+    }
+
+    @Test
+    fun `get test result`(): Unit = runBlocking {
+        val server = createServer()
+        coEvery { verificationApi.getTestResult(any(), any(), any()) } answers {
+            arg<String>(0) shouldBe "0"
+            arg<String>(1).length shouldBe 7 // Header-padding
+            arg<VerificationApiV1.RegistrationRequest>(2).apply {
+                registrationToken shouldBe "testRegistrationToken"
+                requestPadding!!.length shouldBe 170
+            }
+            VerificationApiV1.TestResultResponse(testResult = 2)
+        }
+
+        server.retrieveTestResults("testRegistrationToken") shouldBe 2
+
+        coVerify { verificationApi.getTestResult(any(), any(), any()) }
+    }
+
+    @Test
+    fun `get TAN`(): Unit = runBlocking {
+        val server = createServer()
+        coEvery { verificationApi.getTAN(any(), any(), any()) } answers {
+            arg<String>(0) shouldBe "0"
+            arg<String>(1).length shouldBe 14 // Header-padding
+            arg<VerificationApiV1.TanRequestBody>(2).apply {
+                registrationToken shouldBe "testRegistrationToken"
+                requestPadding!!.length shouldBe 170
+            }
+            VerificationApiV1.TanResponse(tan = "testTan")
+        }
+
+        server.retrieveTan("testRegistrationToken") shouldBe "testTan"
+
+        coVerify { verificationApi.getTAN(any(), any(), any()) }
+    }
+
+    @Test
+    fun `get TAN with fake data`(): Unit = runBlocking {
+        val server = createServer()
+        coEvery { verificationApi.getTAN(any(), any(), any()) } answers {
+            arg<String>(0) shouldBe "1"
+            arg<String>(1).length shouldBe 14 // Header-padding
+            arg<VerificationApiV1.TanRequestBody>(2).apply {
+                registrationToken shouldBe "11111111-2222-4444-8888-161616161616"
+                requestPadding!!.length shouldBe 170
+            }
+            VerificationApiV1.TanResponse(tan = "testTan")
+        }
+
+        server.retrieveTanFake() shouldBe VerificationApiV1.TanResponse(tan = "testTan")
+
+        coVerify { verificationApi.getTAN(any(), any(), any()) }
+    }
+
+    private fun createRealApi(): VerificationApiV1 {
+        val httpModule = HttpModule()
+        val defaultHttpClient = httpModule.defaultHttpClient()
+        val gsonConverterFactory = httpModule.provideGSONConverter()
+
+        return VerificationModule().let {
+            val downloadHttpClient = it.cdnHttpClient(
+                defaultHttpClient,
+                listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)
+            )
+            it.provideVerificationApi(
+                context = context,
+                client = downloadHttpClient,
+                url = serverAddress,
+                gsonConverterFactory = gsonConverterFactory
+            )
+        }
+    }
+
+    @Test
+    fun `all requests have the same footprint for pleasible deniability`(): Unit = runBlocking {
+        val guidExample = "3BF1D4-1C6003DD-733D-41F1-9F30-F85FA7406BF7"
+        val teletanExample = "9A3B578UMG"
+        val registrationTokenExample = "63b4d3ff-e0de-4bd4-90c1-17c2bb683a2f"
+
+        val api = createServer(createRealApi())
+        webServer.enqueue(MockResponse().setBody("{}"))
+        api.retrieveRegistrationToken(guidExample, VerificationKeyType.GUID)
+
+        webServer.enqueue(MockResponse().setBody("{}"))
+        api.retrieveRegistrationToken(teletanExample, VerificationKeyType.TELETAN)
+
+        webServer.enqueue(MockResponse().setBody("{}"))
+        api.retrieveTestResults(registrationTokenExample)
+
+        webServer.enqueue(MockResponse().setBody("{}"))
+        api.retrieveTan(registrationTokenExample)
+
+        webServer.enqueue(MockResponse().setBody("{}"))
+        api.retrieveTanFake()
+
+        val requests = listOf(
+            webServer.takeRequest(),
+            webServer.takeRequest(),
+            webServer.takeRequest(),
+            webServer.takeRequest(),
+            webServer.takeRequest()
+        )
+
+        // ensure all request have same size (header & body)
+        requests.forEach { it.bodySize shouldBe 250L }
+
+        requests.zipWithNext().forEach { (a, b) ->
+            a.headerSizeIgnoringContentLength() shouldBe b.headerSizeIgnoringContentLength()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/testhelpers/exceptions/TestException.kt b/Corona-Warn-App/src/test/java/testhelpers/exceptions/TestException.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5b88672366ae8e2b0e4eba1e4e11eae654c52bbf
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/testhelpers/exceptions/TestException.kt
@@ -0,0 +1,5 @@
+package testhelpers.exceptions
+
+import java.util.UUID
+
+class TestException(private val uuid: UUID = UUID.randomUUID()) : Exception()
diff --git a/Corona-Warn-App/src/test/java/testhelpers/extensions/JsonExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/extensions/JsonExtensions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bc1304959208fcebe18ef38a3c8fb7ebfaafd631
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/testhelpers/extensions/JsonExtensions.kt
@@ -0,0 +1,13 @@
+package testhelpers.extensions
+
+import com.google.gson.Gson
+import com.google.gson.JsonObject
+import okhttp3.mockwebserver.MockResponse
+
+fun String.toComparableJson() = try {
+    Gson().fromJson(this, JsonObject::class.java).toString()
+} catch (e: Exception) {
+    throw IllegalArgumentException("'$this' wasn't valid JSON")
+}
+
+fun String.toJsonResponse(): MockResponse = MockResponse().setBody(this.toComparableJson())
diff --git a/Server-Protocol-Buffer/src/main/proto/keyExportFormat.proto b/Server-Protocol-Buffer/src/main/proto/keyExportFormat.proto
index 3ef93b1da8622faedce917cb40d40e41b1f2d612..786af90d06fe4224064f16157ac5fc9067a87f76 100644
--- a/Server-Protocol-Buffer/src/main/proto/keyExportFormat.proto
+++ b/Server-Protocol-Buffer/src/main/proto/keyExportFormat.proto
@@ -1,4 +1,6 @@
 syntax = "proto2";
+package de.rki.coronawarnapp.server.protocols;
+
 message TemporaryExposureKeyExport {
     // Time window of keys in this batch based on arrival to server, in UTC seconds
     optional fixed64 start_timestamp = 1;
@@ -30,6 +32,9 @@ message SignatureInfo {
 message SubmissionPayload {
     repeated TemporaryExposureKey keys = 1;
     optional bytes padding = 2;
+    repeated string visitedCountries = 3;
+    optional string origin = 4;
+    optional bool consentToFederation = 5;
 }
 
 message TemporaryExposureKey {