diff --git a/.circleci/config.yml b/.circleci/config.yml
index 339ff26be21b95084831a6e7594b15f6514c0af9..1f005f286ecff1198d18270136e180513e6f47c4 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -246,6 +246,13 @@ jobs:
       - run:
           name: Decrypt Keystore
           command: openssl enc -aes-256-cbc -d -pbkdf2 -iter 100000 -in $keystore_download_filename -out $keystore_filename -k $keystore_encrypt_key
+      - run:
+          name: Prepare commit hash
+          command: |
+            commit_hash=$(echo $CIRCLE_SHA1 | cut -c1-5)
+            echo $commit_hash
+            echo "" >> "./gradle.properties"
+            echo "commit_hash=$commit_hash" >> "./gradle.properties"
       - run:
           name: Prepare keystore properties for Signing
           command: |
@@ -265,6 +272,12 @@ jobs:
       - store_artifacts:
           path: Corona-Warn-App/build/reports
           destination: reports
+      - run:
+          name: Send to T-System
+          command: |
+            curl --location --request POST $tsystems_upload_url \
+            --header "Authorization: Bearer $tsystems_upload_bearer" \
+            --form 'file=@Corona-Warn-App/build/outputs/apk/deviceForTesters/release/Corona-Warn-App-deviceForTesters-release.apk' \
 workflows:
   version: 2
   quick_build:
diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml
index 098fe846891336dbe9c4465f59ebbba03506e4f1..16ef409c9b743012f330c384b0c9ae0749cb8a39 100644
--- a/.idea/codeStyles/Project.xml
+++ b/.idea/codeStyles/Project.xml
@@ -1,9 +1,25 @@
 <component name="ProjectCodeStyleConfiguration">
   <code_scheme name="Project" version="173">
+    <AndroidXmlCodeStyleSettings>
+      <option name="LAYOUT_SETTINGS">
+        <value>
+          <option name="INSERT_BLANK_LINE_BEFORE_TAG" value="false" />
+        </value>
+      </option>
+    </AndroidXmlCodeStyleSettings>
     <JetCodeStyleSettings>
       <option name="PACKAGES_TO_USE_STAR_IMPORTS">
         <value>
-          <package name="kotlinx.android.synthetic" withSubpackages="true" static="false" />
+          <package name="kotlinx.android.synthetic" alias="false" withSubpackages="true" />
+        </value>
+      </option>
+      <option name="PACKAGES_IMPORT_LAYOUT">
+        <value>
+          <package name="" alias="false" withSubpackages="true" />
+          <package name="java" alias="false" withSubpackages="true" />
+          <package name="javax" alias="false" withSubpackages="true" />
+          <package name="kotlin" alias="false" withSubpackages="true" />
+          <package name="" alias="true" withSubpackages="true" />
         </value>
       </option>
       <option name="NAME_COUNT_TO_USE_STAR_IMPORT" value="99" />
@@ -124,10 +140,12 @@
     </codeStyleSettings>
     <codeStyleSettings language="kotlin">
       <option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
+      <option name="RIGHT_MARGIN" value="120" />
       <option name="KEEP_BLANK_LINES_IN_DECLARATIONS" value="1" />
       <option name="KEEP_BLANK_LINES_IN_CODE" value="1" />
       <option name="KEEP_BLANK_LINES_BEFORE_RBRACE" value="0" />
       <option name="FIELD_ANNOTATION_WRAP" value="1" />
+      <option name="WRAP_ON_TYPING" value="0" />
       <indentOptions>
         <option name="CONTINUATION_INDENT_SIZE" value="4" />
       </indentOptions>
diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle
index 17ea7e62cadb63cc48dcc3b23755dfbb9b167a5c..7cd67f74b4201019176e6559bb946ad15e1042a4 100644
--- a/Corona-Warn-App/build.gradle
+++ b/Corona-Warn-App/build.gradle
@@ -31,6 +31,7 @@ def environmentExtractor = { File path ->
     return "\"${escapedJson}\""
 }
 
+
 android {
     println("Current VERSION_MAJOR: ${VERSION_MAJOR}")
     println("Current VERSION_MINOR: ${VERSION_MINOR}")
@@ -65,6 +66,12 @@ android {
         def prodEnvJson = environmentExtractor(file("../prod_environments.json"))
         buildConfigField "String", "ENVIRONMENT_JSONDATA", prodEnvJson
 
+        if (project.hasProperty("commit_hash")) {
+            buildConfigField "String", "GIT_COMMIT_SHORT_HASH", "\"$commit_hash\""
+        } else {
+            buildConfigField "String", "GIT_COMMIT_SHORT_HASH", "\"no commit hash\""
+        }
+
         def devEnvironmentFile = file("../test_environments.json")
         if (devEnvironmentFile.exists()) {
             def devEnvJson = environmentExtractor(devEnvironmentFile)
@@ -118,6 +125,7 @@ android {
         device {
             dimension "version"
             resValue "string", "app_name", "Corona-Warn"
+            resValue "bool", "extract_native_libs", "false"
 
             ext {
                 envTypeDefault = [debug: "INT", release: "PROD"]
@@ -131,6 +139,7 @@ android {
             // Contains test fragments
             dimension "version"
             resValue "string", "app_name", "CWA TEST"
+            resValue "bool", "extract_native_libs", "true"
             applicationIdSuffix '.test'
 
             ext {
@@ -281,6 +290,12 @@ task jacocoTestReportDeviceRelease(type: JacocoReport, dependsOn: 'testDeviceRel
     getExecutionData().from(fileTree(dir: "$buildDir", includes: ["jacoco/testDeviceReleaseUnitTest.exec"]))
 }
 
+configurations.all {
+    resolutionStrategy {
+        force "androidx.test:monitor:1.3.0"
+    }
+}
+
 dependencies {
     // KOTLIN
     def coroutineVersion = "1.4.0-M1"
@@ -317,6 +332,7 @@ dependencies {
     implementation 'com.google.dagger:dagger-android-support:2.28.1'
     kapt 'com.google.dagger:dagger-compiler:2.28.1'
     kapt 'com.google.dagger:dagger-android-processor:2.28.1'
+    kaptTest 'com.google.dagger:dagger-compiler:2.28.1'
     kaptAndroidTest 'com.google.dagger:dagger-compiler:2.28.1'
 
     compileOnly 'com.squareup.inject:assisted-inject-annotations-dagger2:0.5.2'
@@ -354,6 +370,8 @@ dependencies {
     androidTestImplementation "io.kotest:kotest-assertions-core-jvm:4.3.0"
     androidTestImplementation "io.kotest:kotest-property-jvm:4.3.0"
 
+    testImplementation "io.github.classgraph:classgraph:4.8.90"
+
     // Testing - Instrumentation
     androidTestImplementation 'junit:junit:4.13.1'
     androidTestImplementation 'androidx.test:runner:1.3.0'
@@ -366,6 +384,8 @@ dependencies {
     androidTestImplementation "io.mockk:mockk-android:1.10.2"
     debugImplementation 'androidx.fragment:fragment-testing:1.2.5'
 
+    androidTestImplementation 'tools.fastlane:screengrab:2.0.0'
+
     // Play Services
     implementation 'com.google.android.play:core:1.7.3'
     implementation 'com.google.android.gms:play-services-base:17.3.0'
diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.bugreporting.storage.BugDatabase/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.bugreporting.storage.BugDatabase/1.json
new file mode 100644
index 0000000000000000000000000000000000000000..2ce12fa899dae6730452501187001e24c798eb79
--- /dev/null
+++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.bugreporting.storage.BugDatabase/1.json
@@ -0,0 +1,112 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 1,
+    "identityHash": "0e8995814e331e0253eb0276f2e946a7",
+    "entities": [
+      {
+        "tableName": "BugEventEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `tag` TEXT, `info` TEXT, `exceptionClass` TEXT NOT NULL, `exceptionMessage` TEXT, `stackTrace` TEXT NOT NULL, `deviceInfo` TEXT NOT NULL, `appVersionName` TEXT NOT NULL, `appVersionCode` INTEGER NOT NULL, `apiLevel` INTEGER NOT NULL, `androidVersion` TEXT NOT NULL, `shortCommitHash` TEXT NOT NULL, `logHistory` TEXT NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "info",
+            "columnName": "info",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "exceptionClass",
+            "columnName": "exceptionClass",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "exceptionMessage",
+            "columnName": "exceptionMessage",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "stackTrace",
+            "columnName": "stackTrace",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceInfo",
+            "columnName": "deviceInfo",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "appVersionName",
+            "columnName": "appVersionName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "appVersionCode",
+            "columnName": "appVersionCode",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiLevel",
+            "columnName": "apiLevel",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "androidVersion",
+            "columnName": "androidVersion",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "shortCommitHash",
+            "columnName": "shortCommitHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "logHistory",
+            "columnName": "logHistory",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '0e8995814e331e0253eb0276f2e946a7')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.storage.AppDatabase/2.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.storage.AppDatabase/2.json
new file mode 100644
index 0000000000000000000000000000000000000000..a53d24a6c224fdae463d0afa90b6665929f5a580
--- /dev/null
+++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.storage.AppDatabase/2.json
@@ -0,0 +1,231 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 2,
+    "identityHash": "afee4b1ca6ab9abd7117fe308784bc99",
+    "entities": [
+      {
+        "tableName": "exposure_summary",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `daysSinceLastExposure` INTEGER NOT NULL, `matchedKeyCount` INTEGER NOT NULL, `maximumRiskScore` INTEGER NOT NULL, `summationRiskScore` INTEGER NOT NULL, `attenuationDurationsInMinutes` TEXT NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "daysSinceLastExposure",
+            "columnName": "daysSinceLastExposure",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "matchedKeyCount",
+            "columnName": "matchedKeyCount",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "maximumRiskScore",
+            "columnName": "maximumRiskScore",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summationRiskScore",
+            "columnName": "summationRiskScore",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "attenuationDurationsInMinutes",
+            "columnName": "attenuationDurationsInMinutes",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [
+          {
+            "name": "index_exposure_summary_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_exposure_summary_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "date",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `path` TEXT NOT NULL, `type` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "path",
+            "columnName": "path",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [
+          {
+            "name": "index_date_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_date_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "tracing_interval",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`from` INTEGER NOT NULL, `to` INTEGER NOT NULL, PRIMARY KEY(`from`, `to`))",
+        "fields": [
+          {
+            "fieldPath": "from",
+            "columnName": "from",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "to",
+            "columnName": "to",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "from",
+            "to"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [
+          {
+            "name": "index_tracing_interval_from_to",
+            "unique": false,
+            "columnNames": [
+              "from",
+              "to"
+            ],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_tracing_interval_from_to` ON `${TABLE_NAME}` (`from`, `to`)"
+          }
+        ],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "CrashReportEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceInfo` TEXT NOT NULL, `appVersionName` TEXT NOT NULL, `appVersionCode` INTEGER NOT NULL, `apiLevel` INTEGER NOT NULL, `androidVersion` TEXT NOT NULL, `shortID` TEXT NOT NULL, `message` TEXT NOT NULL, `stackTrace` TEXT NOT NULL, `tag` TEXT, `crashedAt` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceInfo",
+            "columnName": "deviceInfo",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "appVersionName",
+            "columnName": "appVersionName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "appVersionCode",
+            "columnName": "appVersionCode",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "apiLevel",
+            "columnName": "apiLevel",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "androidVersion",
+            "columnName": "androidVersion",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "shortID",
+            "columnName": "shortID",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "message",
+            "columnName": "message",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "stackTrace",
+            "columnName": "stackTrace",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "crashedAt",
+            "columnName": "crashedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'afee4b1ca6ab9abd7117fe308784bc99')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt
index 289a23b9b886e81a787ecd8486ccbf0fd39f50e5..d109adabf7c87a4a5a87aa9f4bb5ab519277d698 100644
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt
@@ -3,7 +3,6 @@ package de.rki.coronawarnapp.ui.main.home
 import androidx.fragment.app.testing.launchFragment
 import androidx.lifecycle.Lifecycle
 import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.SavedStateHandle
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import dagger.Module
 import dagger.android.ContributesAndroidInjector
@@ -34,7 +33,8 @@ class HomeFragmentTest : BaseUITest() {
         every { viewModel.refreshRequiredData() } just Runs
 
         setupMockViewModel(object : HomeFragmentViewModel.Factory {
-            override fun create(handle: SavedStateHandle): HomeFragmentViewModel = viewModel
+            // override fun create(handle: SavedStateHandle){} HomeFragmentViewModel = viewModel}
+            override fun create(): HomeFragmentViewModel = viewModel
         })
     }
 
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionContactFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionContactFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5e262d08eb3ddcf76f2b7b40760a1ab8fa91dc08
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionContactFragmentTest.kt
@@ -0,0 +1,66 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.ui.submission.fragment.SubmissionContactFragment
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionContactViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionContactFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionContactViewModel
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+        setupMockViewModel(object : SubmissionContactViewModel.Factory {
+            override fun create(): SubmissionContactViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionContactFragment>()
+    }
+
+    @Test fun testContactCallClicked() {
+        val scenario = launchFragmentInContainer<SubmissionContactFragment>()
+        onView(withId(R.id.submission_contact_button_call))
+            .perform(click())
+
+        // TODO verify result
+    }
+
+    @Test fun testContactEnterTanClicked() {
+        val scenario = launchFragmentInContainer<SubmissionContactFragment>()
+        onView(withId(R.id.submission_contact_button_enter))
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionContactTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionContactScreen(): SubmissionContactFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionDispatcherFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionDispatcherFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fc417a990b5c53a98167039c839d67bbee6fcb07
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionDispatcherFragmentTest.kt
@@ -0,0 +1,78 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.ui.submission.fragment.SubmissionDispatcherFragment
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionDispatcherViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionDispatcherFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionDispatcherViewModel
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+        setupMockViewModel(object : SubmissionDispatcherViewModel.Factory {
+            override fun create(): SubmissionDispatcherViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionDispatcherFragment>()
+    }
+
+    @Test fun testEventQRClicked() {
+        val scenario = launchFragmentInContainer<SubmissionDispatcherFragment>()
+        onView(withId(R.id.submission_dispatcher_qr))
+            .perform(scrollTo())
+            .perform(click())
+
+        // TODO verify result
+    }
+
+    @Test fun testEventTeleClicked() {
+        val scenario = launchFragmentInContainer<SubmissionDispatcherFragment>()
+        onView(withId(R.id.submission_dispatcher_tan_tele))
+            .perform(scrollTo())
+            .perform(click())
+
+        // TODO verify result
+    }
+
+    @Test fun testEventTanClicked() {
+        val scenario = launchFragmentInContainer<SubmissionDispatcherFragment>()
+        onView(withId(R.id.submission_dispatcher_tan_code))
+            .perform(scrollTo())
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionDispatcherTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionDispatcherScreen(): SubmissionDispatcherFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionDoneFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionDoneFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..989bbcdcbeb17fcc04c3e0df15b236c11da33df8
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionDoneFragmentTest.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.ui.submission.fragment.SubmissionDoneFragment
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionDoneViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionDoneFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionDoneViewModel
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+        setupMockViewModel(object : SubmissionDoneViewModel.Factory {
+            override fun create(): SubmissionDoneViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionDoneFragment>()
+    }
+
+    @Test fun testDoneClicked() {
+        val scenario = launchFragmentInContainer<SubmissionDoneFragment>()
+        onView(withId(R.id.submission_done_button_done))
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionDoneTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionDoneScreen(): SubmissionDoneFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionIntroFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionIntroFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5f3cd05665c1a9e4b49d18ecd496c21a488ce46c
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionIntroFragmentTest.kt
@@ -0,0 +1,69 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.ui.submission.fragment.SubmissionIntroFragment
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionIntroViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+import tools.fastlane.screengrab.Screengrab
+import tools.fastlane.screengrab.locale.LocaleTestRule
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionIntroFragmentTest : BaseUITest() {
+
+    /*@get:Rule
+    var activityRule = ActivityTestRule(MainActivity::class.java)*/
+
+    @Rule @JvmField
+    val localeTestRule = LocaleTestRule()
+
+    @MockK lateinit var viewModel: SubmissionIntroViewModel
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+
+        setupMockViewModel(object : SubmissionIntroViewModel.Factory {
+            override fun create(): SubmissionIntroViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionIntroFragment>()
+        Screengrab.screenshot("submission_Intro_fragment_opened")
+    }
+
+    @Test fun testEventNextClicked() {
+        val scenario = launchFragmentInContainer<SubmissionIntroFragment>()
+        onView(withId(R.id.submission_intro_button_next))
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionIntroTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionIntroScreen(): SubmissionIntroFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionOtherWarningFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionOtherWarningFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..eb4a3dba7d25d21d9461208ff1d5604a5cf1f794
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionOtherWarningFragmentTest.kt
@@ -0,0 +1,83 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.submission.Symptoms
+import de.rki.coronawarnapp.ui.submission.symptoms.calendar.SubmissionSymptomCalendarViewModel
+import de.rki.coronawarnapp.ui.submission.symptoms.introduction.SubmissionSymptomIntroductionViewModel
+import de.rki.coronawarnapp.ui.submission.warnothers.SubmissionResultPositiveOtherWarningFragment
+import de.rki.coronawarnapp.ui.submission.warnothers.SubmissionResultPositiveOtherWarningViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionOtherWarningFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionResultPositiveOtherWarningViewModel
+    @MockK lateinit var symptomIntroViewModel: SubmissionSymptomIntroductionViewModel
+    @MockK lateinit var symptomCalendarViewModel: SubmissionSymptomCalendarViewModel
+
+    //  @MockK lateinit var symptoms: Symptoms
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+        setupMockViewModel(object : SubmissionSymptomIntroductionViewModel.Factory {
+            override fun create(): SubmissionSymptomIntroductionViewModel = symptomIntroViewModel
+        })
+
+        /* every { symptomIntroViewModel.symptomIndication } returns MutableLiveData()
+         every { symptomIntroViewModel. } returns MutableLiveData()
+
+         symptomIntroViewModel.onPositiveSymptomIndication()
+         symptomIntroViewModel.symptomIndication.postValue("tesasdf") */
+
+        setupMockViewModel(object : SubmissionSymptomCalendarViewModel.Factory {
+            override fun create(symptomIndication: Symptoms.Indication): SubmissionSymptomCalendarViewModel =
+                symptomCalendarViewModel
+        })
+
+        setupMockViewModel(object : SubmissionResultPositiveOtherWarningViewModel.Factory {
+            override fun create(symptoms: Symptoms): SubmissionResultPositiveOtherWarningViewModel =
+                viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionResultPositiveOtherWarningFragment>()
+    }
+
+    @Test fun testOtherWarningNextClicked() {
+        val scenario = launchFragmentInContainer<SubmissionResultPositiveOtherWarningFragment>()
+        onView(withId(R.id.submission_positive_other_warning_button_next))
+            .perform(scrollTo())
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionOtherWarningTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionOtherWarningScreen(): SubmissionResultPositiveOtherWarningFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionQrCodeInfoFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionQrCodeInfoFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5e2b2ceaffa72d225586eb2b612a42f985372f14
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionQrCodeInfoFragmentTest.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.ui.submission.qrcode.info.SubmissionQRCodeInfoFragment
+import de.rki.coronawarnapp.ui.submission.qrcode.info.SubmissionQRCodeInfoFragmentViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionQrCodeInfoFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionQRCodeInfoFragmentViewModel
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+        setupMockViewModel(object : SubmissionQRCodeInfoFragmentViewModel.Factory {
+            override fun create(): SubmissionQRCodeInfoFragmentViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionQRCodeInfoFragment>()
+    }
+
+    @Test fun testQRInfoNextClicked() {
+        val scenario = launchFragmentInContainer<SubmissionQRCodeInfoFragment>()
+        onView(withId(R.id.submission_qr_info_button_next))
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionQRInfoFragmentModule {
+    @ContributesAndroidInjector
+    abstract fun submissionQRInfoScreen(): SubmissionQRCodeInfoFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionQrCodeScanFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionQrCodeScanFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..245374a2a1013fd21b244be791f6cd459e5355bf
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionQrCodeScanFragmentTest.kt
@@ -0,0 +1,45 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.ui.submission.qrcode.scan.SubmissionQRCodeScanFragment
+import de.rki.coronawarnapp.ui.submission.qrcode.scan.SubmissionQRCodeScanViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionQrCodeScanFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionQRCodeScanViewModel
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+        setupMockViewModel(object : SubmissionQRCodeScanViewModel.Factory {
+            override fun create(): SubmissionQRCodeScanViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionQRCodeScanFragment>()
+    }
+}
+
+@Module
+abstract class SubmissionQRScanFragmentModule {
+    @ContributesAndroidInjector
+    abstract fun submissionQRScanScreen(): SubmissionQRCodeScanFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionSymptomCalendarFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionSymptomCalendarFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b592443c863b3a5d634d83696455ae1b42252418
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionSymptomCalendarFragmentTest.kt
@@ -0,0 +1,68 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.submission.Symptoms
+import de.rki.coronawarnapp.ui.submission.symptoms.calendar.SubmissionSymptomCalendarFragment
+import de.rki.coronawarnapp.ui.submission.symptoms.calendar.SubmissionSymptomCalendarViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionSymptomCalendarFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionSymptomCalendarViewModel
+
+    /*  private val symptoms = Symptoms(Symptoms.StartOf.OneToTwoWeeksAgo, POSITIVE)
+      private val positiveSymptomIndication = POSITIVE;
+      private val negativeSymptomIndication = Symptoms.Indication.NEGATIVE;
+      private val noSymptomIndication = Symptoms.Indication.NO_INFORMATION;*/
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+
+        setupMockViewModel(object : SubmissionSymptomCalendarViewModel.Factory {
+            override fun create(symptomIndication: Symptoms.Indication): SubmissionSymptomCalendarViewModel =
+                viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionSymptomCalendarFragment>()
+    }
+
+    @Test fun testSymptomCalendarNextClicked() {
+        val scenario = launchFragmentInContainer<SubmissionSymptomCalendarFragment>()
+        onView(withId(R.id.symptom_button_next))
+            .perform(scrollTo())
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionSymptomCalendarFragmentTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionSymptomCalendarScreen(): SubmissionSymptomCalendarFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionSymptomIntroFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionSymptomIntroFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ed959a210f9b3b33f3821b245fc5b0af3900cf1f
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionSymptomIntroFragmentTest.kt
@@ -0,0 +1,60 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.ui.submission.symptoms.introduction.SubmissionSymptomIntroductionFragment
+import de.rki.coronawarnapp.ui.submission.symptoms.introduction.SubmissionSymptomIntroductionViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionSymptomIntroFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionSymptomIntroductionViewModel
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+        setupMockViewModel(object : SubmissionSymptomIntroductionViewModel.Factory {
+            override fun create(): SubmissionSymptomIntroductionViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionSymptomIntroductionFragment>()
+    }
+
+    @Test fun testSymptomNextClicked() {
+        val scenario = launchFragmentInContainer<SubmissionSymptomIntroductionFragment>()
+        onView(withId(R.id.symptom_button_next))
+            .perform(scrollTo())
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionSymptomIntroFragmentTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionSymptomIntroScreen(): SubmissionSymptomIntroductionFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionTanFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionTanFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b2dc3033d936ec5bcbb81754816dcbf80498e5c5
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionTanFragmentTest.kt
@@ -0,0 +1,63 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.ui.submission.tan.SubmissionTanFragment
+import de.rki.coronawarnapp.ui.submission.tan.SubmissionTanViewModel
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionTanFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionTanViewModel
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+
+        setupMockViewModel(object : SubmissionTanViewModel.Factory {
+            override fun create(): SubmissionTanViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionTanFragment>()
+    }
+
+    @Test fun testEventTanNextClicked() {
+        val scenario = launchFragmentInContainer<SubmissionTanFragment>()
+        ViewActions.closeSoftKeyboard()
+        onView(withId(R.id.submission_tan_button_enter))
+            .perform(scrollTo())
+            .perform(click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionTanTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionTanScreen(): SubmissionTanFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionTestResultFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionTestResultFragmentTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ba62622e5fcd3f294fd3036257653624d5ad27b3
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/submission/SubmissionTestResultFragmentTest.kt
@@ -0,0 +1,117 @@
+package de.rki.coronawarnapp.ui.submission
+
+import androidx.fragment.app.testing.launchFragment
+import androidx.fragment.app.testing.launchFragmentInContainer
+import androidx.lifecycle.MutableLiveData
+import androidx.test.espresso.Espresso
+import androidx.test.espresso.action.ViewActions
+import androidx.test.espresso.matcher.ViewMatchers
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.ui.submission.testresult.SubmissionTestResultFragment
+import de.rki.coronawarnapp.ui.submission.testresult.SubmissionTestResultViewModel
+import de.rki.coronawarnapp.ui.submission.testresult.TestResultUIState
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseUITest
+
+@RunWith(AndroidJUnit4::class)
+class SubmissionTestResultFragmentTest : BaseUITest() {
+
+    @MockK lateinit var viewModel: SubmissionTestResultViewModel
+    @MockK lateinit var uiState: TestResultUIState
+
+    @Before
+    fun setup() {
+        MockKAnnotations.init(this, relaxed = true)
+
+        every { viewModel.uiState } returns MutableLiveData()
+
+        setupMockViewModel(object : SubmissionTestResultViewModel.Factory {
+            override fun create(): SubmissionTestResultViewModel = viewModel
+        })
+    }
+
+    @After
+    fun teardown() {
+        clearAllViewModels()
+    }
+
+    @Test
+    fun launch_fragment() {
+        launchFragment<SubmissionTestResultFragment>()
+    }
+
+    @Test
+    fun testEventPendingRefreshClicked() {
+        val scenario = launchFragmentInContainer<SubmissionTestResultFragment>()
+        Espresso.onView(ViewMatchers.withId(R.id.submission_test_result_button_pending_refresh))
+            .perform(ViewActions.scrollTo())
+            .perform(ViewActions.click())
+
+        // TODO verify result
+    }
+
+    @Test
+    fun testEventPendingRemoveClicked() {
+        val scenario = launchFragmentInContainer<SubmissionTestResultFragment>()
+        Espresso.onView(ViewMatchers.withId(R.id.submission_test_result_button_pending_remove_test))
+            .perform(ViewActions.scrollTo())
+            .perform(ViewActions.click())
+
+        // TODO verify result
+    }
+
+    @Test
+    fun testEventInvalidRemoveClicked() {
+        val scenario = launchFragmentInContainer<SubmissionTestResultFragment>()
+        Espresso.onView(ViewMatchers.withId(R.id.submission_test_result_button_invalid_remove_test))
+            .perform(ViewActions.scrollTo())
+            .perform(ViewActions.click())
+
+        // TODO verify result
+    }
+
+    @Test
+    fun testEventPositiveContinueWithSymptomsClicked() {
+        val scenario = launchFragmentInContainer<SubmissionTestResultFragment>()
+        Espresso.onView(ViewMatchers.withId(R.id.submission_test_result_button_positive_continue))
+            .perform(ViewActions.scrollTo())
+            .perform(ViewActions.click())
+
+        // TODO verify result
+    }
+
+    @Test
+    fun testEventPositiveContinueWithoutSymptomsClicked() {
+        val scenario = launchFragmentInContainer<SubmissionTestResultFragment>()
+        Espresso.onView(ViewMatchers.withId(R.id.submission_test_result_button_positive_continue_without_symptoms))
+            .perform(ViewActions.scrollTo())
+            .perform(ViewActions.click())
+
+        // TODO verify result
+    }
+
+    @Test
+    fun testEventNegativeRemoveClicked() {
+        val scenario = launchFragmentInContainer<SubmissionTestResultFragment>()
+        Espresso.onView(ViewMatchers.withId(R.id.submission_test_result_button_negative_remove_test))
+            .perform(ViewActions.scrollTo())
+            .perform(ViewActions.click())
+
+        // TODO verify result
+    }
+}
+
+@Module
+abstract class SubmissionTestResultTestModule {
+    @ContributesAndroidInjector
+    abstract fun submissionTestResultScreen(): SubmissionTestResultFragment
+}
diff --git a/Corona-Warn-App/src/androidTest/java/testhelpers/FragmentTestModuleRegistrar.kt b/Corona-Warn-App/src/androidTest/java/testhelpers/FragmentTestModuleRegistrar.kt
index da725b563e82176894ab4217cd2ae37f699ad42e..e50180f6ccf93867a659830bf1b655a25e592831 100644
--- a/Corona-Warn-App/src/androidTest/java/testhelpers/FragmentTestModuleRegistrar.kt
+++ b/Corona-Warn-App/src/androidTest/java/testhelpers/FragmentTestModuleRegistrar.kt
@@ -8,16 +8,40 @@ import de.rki.coronawarnapp.ui.onboarding.OnboardingNotificationsTestModule
 import de.rki.coronawarnapp.ui.onboarding.OnboardingPrivacyTestModule
 import de.rki.coronawarnapp.ui.onboarding.OnboardingTestFragmentModule
 import de.rki.coronawarnapp.ui.onboarding.OnboardingTracingFragmentTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionContactTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionDispatcherTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionDoneTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionIntroTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionOtherWarningTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionQRInfoFragmentModule
+import de.rki.coronawarnapp.ui.submission.SubmissionQRScanFragmentModule
+import de.rki.coronawarnapp.ui.submission.SubmissionSymptomCalendarFragmentTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionSymptomIntroFragmentTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionTanTestModule
+import de.rki.coronawarnapp.ui.submission.SubmissionTestResultTestModule
 
 @Module(
     includes = [
         HomeFragmentTestModule::class,
+        // Onboarding
         OnboardingFragmentTestModule::class,
         OnboardingDeltaInteroperabilityFragmentTestModule::class,
         OnboardingNotificationsTestModule::class,
         OnboardingPrivacyTestModule::class,
         OnboardingTestFragmentModule::class,
-        OnboardingTracingFragmentTestModule::class
+        OnboardingTracingFragmentTestModule::class,
+        // Submission
+        SubmissionIntroTestModule::class,
+        SubmissionDispatcherTestModule::class,
+        SubmissionTanTestModule::class,
+        SubmissionTestResultTestModule::class,
+        SubmissionOtherWarningTestModule::class,
+        SubmissionSymptomIntroFragmentTestModule::class,
+        SubmissionSymptomCalendarFragmentTestModule::class,
+        SubmissionContactTestModule::class,
+        SubmissionDoneTestModule::class,
+        SubmissionQRInfoFragmentModule::class,
+        SubmissionQRScanFragmentModule::class
     ]
 )
 class FragmentTestModuleRegistrar
diff --git a/Corona-Warn-App/src/debug/AndroidManifest.xml b/Corona-Warn-App/src/debug/AndroidManifest.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ba24510e29ef4b6b35dd0c3ec856f1decd626ca4
--- /dev/null
+++ b/Corona-Warn-App/src/debug/AndroidManifest.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
+
+    <!-- Allows unlocking your device and activating its screen so UI tests can succeed -->
+    <uses-permission android:name="android.permission.DISABLE_KEYGUARD" />
+    <uses-permission android:name="android.permission.WAKE_LOCK" />
+
+    <!-- Allows for storing and retrieving screenshots -->
+    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+
+    <!-- Allows changing locales -->
+    <uses-permission android:name="android.permission.CHANGE_CONFIGURATION" />
+
+</manifest>
diff --git a/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/bugreporting/BugReportingModule.kt b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/bugreporting/BugReportingModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..769b9e9e78558992d255b9eaaaec8358041761ae
--- /dev/null
+++ b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/bugreporting/BugReportingModule.kt
@@ -0,0 +1,28 @@
+package de.rki.coronawarnapp.bugreporting
+
+import dagger.Module
+import dagger.Provides
+import de.rki.coronawarnapp.bugreporting.loghistory.LogHistoryTree
+import timber.log.Timber
+import javax.inject.Singleton
+
+@Module
+class BugReportingModule {
+
+    @Singleton
+    @Provides
+    fun reporter(): BugReporter = object : BugReporter {
+        override fun report(throwable: Throwable, tag: String?, info: String?) {
+            // NOOP
+        }
+    }
+
+    @Singleton
+    @LogHistoryTree
+    @Provides
+    fun loggingHistory(): Timber.Tree = object : Timber.Tree() {
+        override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
+            // NOOP
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/BugReportingModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/BugReportingModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..90bdfde710ad6101dc80a6af94421b954eb19997
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/BugReportingModule.kt
@@ -0,0 +1,41 @@
+package de.rki.coronawarnapp.bugreporting
+
+import dagger.Module
+import dagger.Provides
+import de.rki.coronawarnapp.bugreporting.loghistory.LogHistoryTree
+import de.rki.coronawarnapp.bugreporting.loghistory.RollingLogHistory
+import de.rki.coronawarnapp.bugreporting.processor.BugProcessor
+import de.rki.coronawarnapp.bugreporting.processor.DefaultBugProcessor
+import de.rki.coronawarnapp.bugreporting.reporter.DefaultBugReporter
+import de.rki.coronawarnapp.bugreporting.storage.BugDatabase
+import de.rki.coronawarnapp.bugreporting.storage.dao.DefaultBugDao
+import de.rki.coronawarnapp.bugreporting.storage.repository.BugRepository
+import de.rki.coronawarnapp.bugreporting.storage.repository.DefaultBugRepository
+import timber.log.Timber
+import javax.inject.Singleton
+
+@Module
+class BugReportingModule {
+
+    @Singleton
+    @Provides
+    fun reporter(reporter: DefaultBugReporter): BugReporter = reporter
+
+    @Singleton
+    @Provides
+    fun repository(repository: DefaultBugRepository): BugRepository = repository
+
+    @Singleton
+    @Provides
+    fun processor(processor: DefaultBugProcessor): BugProcessor = processor
+
+    @Singleton
+    @LogHistoryTree
+    @Provides
+    fun loggingHistory(loggingHistory: RollingLogHistory): Timber.Tree = loggingHistory
+
+    @Singleton
+    @Provides
+    fun bugEventDao(bugDatabaseFactory: BugDatabase.Factory): DefaultBugDao =
+        bugDatabaseFactory.create().defaultBugDao()
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/BugEvent.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/BugEvent.kt
new file mode 100644
index 0000000000000000000000000000000000000000..80603278fe0a59f7043f69e1bb41f695bb0f0429
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/BugEvent.kt
@@ -0,0 +1,21 @@
+package de.rki.coronawarnapp.bugreporting.event
+
+import org.joda.time.Instant
+import java.util.UUID
+
+interface BugEvent {
+    val id: UUID
+    val createdAt: Instant
+    val tag: String?
+    val info: String?
+    val exceptionClass: String
+    val exceptionMessage: String?
+    val stackTrace: String
+    val appVersionName: String
+    val appVersionCode: Long
+    val deviceInfo: String
+    val apiLevel: Int
+    val androidVersion: String
+    val shortCommitHash: String
+    val logHistory: List<String>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/BugEventEntity.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/BugEventEntity.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5585cac6ed7d761a29d4d9ed71f1e33a100657b3
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/BugEventEntity.kt
@@ -0,0 +1,24 @@
+package de.rki.coronawarnapp.bugreporting.event
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import org.joda.time.Instant
+import java.util.UUID
+
+@Entity
+data class BugEventEntity(
+    @PrimaryKey override var id: UUID = UUID.randomUUID(),
+    override val createdAt: Instant = Instant.now(),
+    override var tag: String? = null,
+    override val info: String? = null,
+    override val exceptionClass: String,
+    override val exceptionMessage: String? = null,
+    override val stackTrace: String,
+    override val deviceInfo: String,
+    override val appVersionName: String,
+    override val appVersionCode: Long,
+    override val apiLevel: Int,
+    override val androidVersion: String,
+    override val shortCommitHash: String,
+    override val logHistory: List<String>
+) : BugEvent
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/DefaultBugEvent.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/DefaultBugEvent.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d9a0d010623fd30a392927ec89da4a4b6094c06f
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/event/DefaultBugEvent.kt
@@ -0,0 +1,21 @@
+package de.rki.coronawarnapp.bugreporting.event
+
+import org.joda.time.Instant
+import java.util.UUID
+
+class DefaultBugEvent(
+    override val id: UUID = UUID.randomUUID(),
+    override val createdAt: Instant,
+    override val tag: String?,
+    override val info: String?,
+    override val exceptionClass: String,
+    override val exceptionMessage: String?,
+    override val stackTrace: String,
+    override val deviceInfo: String,
+    override val appVersionName: String,
+    override val appVersionCode: Long,
+    override val apiLevel: Int,
+    override val androidVersion: String,
+    override val shortCommitHash: String,
+    override val logHistory: List<String>
+) : BugEvent
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/loghistory/RollingLogHistory.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/loghistory/RollingLogHistory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..97696cca28e4abd2f57f2d7cdf30c8bf17d8343e
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/loghistory/RollingLogHistory.kt
@@ -0,0 +1,64 @@
+package de.rki.coronawarnapp.bugreporting.loghistory
+
+import android.util.Log
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.plus
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.math.min
+
+@Singleton
+class RollingLogHistory @Inject constructor(
+    @AppScope private val scope: CoroutineScope,
+    dispatcherProvider: DispatcherProvider
+) : Timber.DebugTree() {
+
+    private val bufferLock = Mutex()
+    private val buffer: ArrayDeque<String> = ArrayDeque(BUFFER_SIZE)
+    private val logQueue = MutableStateFlow("")
+
+    init {
+        logQueue
+            .filter { it.isNotBlank() }
+            .onEach {
+                bufferLock.withLock {
+                    buffer.addFirst(it)
+                    if (buffer.size >= BUFFER_SIZE) {
+                        buffer.removeLast()
+                    }
+                }
+            }
+            .launchIn(scope + dispatcherProvider.IO)
+    }
+
+    suspend fun getLoglines(count: Int): List<String> = bufferLock.withLock {
+        buffer.subList(0, min(count, buffer.size))
+    }
+
+    override fun log(priority: Int, tag: String?, message: String, t: Throwable?) {
+        val formatLine =
+            "${System.currentTimeMillis()}  ${priorityToString(priority)}/$tag: $message\n"
+        logQueue.value = formatLine
+    }
+
+    companion object {
+        private const val BUFFER_SIZE = 100
+        private fun priorityToString(priority: Int): String = when (priority) {
+            Log.ERROR -> "E"
+            Log.WARN -> "W"
+            Log.INFO -> "I"
+            Log.DEBUG -> "D"
+            Log.VERBOSE -> "V"
+            else -> priority.toString()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/BugProcessor.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/BugProcessor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e9365038253e5d66611358ff6d241216d9b11fe5
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/BugProcessor.kt
@@ -0,0 +1,7 @@
+package de.rki.coronawarnapp.bugreporting.processor
+
+import de.rki.coronawarnapp.bugreporting.event.BugEvent
+
+interface BugProcessor {
+    suspend fun processor(throwable: Throwable, tag: String?, info: String?): BugEvent
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..12c569990d6ca9a63ebccfaa7651240997557846
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt
@@ -0,0 +1,52 @@
+package de.rki.coronawarnapp.bugreporting.processor
+
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import de.rki.coronawarnapp.BuildConfig
+import de.rki.coronawarnapp.bugreporting.event.BugEvent
+import de.rki.coronawarnapp.bugreporting.event.DefaultBugEvent
+import de.rki.coronawarnapp.bugreporting.loghistory.RollingLogHistory
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.tryFormattedError
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DefaultBugProcessor @Inject constructor(
+    @AppContext private val context: Context,
+    private val timeStamper: TimeStamper,
+    private val rollingLogHistory: RollingLogHistory
+) : BugProcessor {
+
+    override suspend fun processor(throwable: Throwable, tag: String?, info: String?): BugEvent {
+        val crashedAt = timeStamper.nowUTC
+        val exceptionMessage = throwable.tryFormattedError(context)
+        val exceptionClass = throwable::class.java.simpleName
+        val stacktrace = Log.getStackTraceString(throwable)
+        val deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})"
+        val appVersionName = BuildConfig.VERSION_NAME
+        val appVersionCode = BuildConfig.VERSION_CODE
+        val apiLevel = Build.VERSION.SDK_INT
+        val androidVersion = Build.VERSION.RELEASE
+        val shortID = BuildConfig.GIT_COMMIT_SHORT_HASH
+        val logHistory = rollingLogHistory.getLoglines(50)
+
+        return DefaultBugEvent(
+            createdAt = crashedAt,
+            tag = tag,
+            info = info,
+            exceptionClass = exceptionClass,
+            exceptionMessage = exceptionMessage,
+            stackTrace = stacktrace,
+            deviceInfo = deviceInfo,
+            appVersionName = appVersionName,
+            appVersionCode = appVersionCode.toLong(),
+            apiLevel = apiLevel,
+            androidVersion = androidVersion,
+            shortCommitHash = shortID,
+            logHistory = logHistory
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/reporter/DefaultBugReporter.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/reporter/DefaultBugReporter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..50bf3b51d4bdd295fc4b0d231ddbf9b0d86d5a86
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/reporter/DefaultBugReporter.kt
@@ -0,0 +1,29 @@
+package de.rki.coronawarnapp.bugreporting.reporter
+
+import de.rki.coronawarnapp.bugreporting.BugReporter
+import de.rki.coronawarnapp.bugreporting.processor.BugProcessor
+import de.rki.coronawarnapp.bugreporting.storage.repository.BugRepository
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DefaultBugReporter @Inject constructor(
+    private val repository: BugRepository,
+    private val processor: BugProcessor,
+    @AppScope private val scope: CoroutineScope,
+    private val dispatcherProvider: DispatcherProvider
+) : BugReporter {
+
+    override fun report(throwable: Throwable, tag: String?, info: String?) {
+        Timber.e(throwable, "Processing reported bug (info=$info) from $tag.")
+        scope.launch(context = dispatcherProvider.IO) {
+            val event = processor.processor(throwable, tag, info)
+            repository.save(event)
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/BugDatabase.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/BugDatabase.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ab7bd2db9206c736664b68352a1e5b7d2f64561b
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/BugDatabase.kt
@@ -0,0 +1,33 @@
+package de.rki.coronawarnapp.bugreporting.storage
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import de.rki.coronawarnapp.bugreporting.event.BugEventEntity
+import de.rki.coronawarnapp.bugreporting.storage.dao.DefaultBugDao
+import de.rki.coronawarnapp.util.database.CommonConverters
+import de.rki.coronawarnapp.util.di.AppContext
+import javax.inject.Inject
+
+@Database(
+    entities = [BugEventEntity::class],
+    version = 1,
+    exportSchema = true
+)
+@TypeConverters(CommonConverters::class)
+abstract class BugDatabase : RoomDatabase() {
+
+    abstract fun defaultBugDao(): DefaultBugDao
+
+    class Factory @Inject constructor(@AppContext private val context: Context) {
+        fun create(): BugDatabase = Room
+            .databaseBuilder(context, BugDatabase::class.java, BUG_DATABASE_NAME)
+            .build()
+    }
+
+    companion object {
+        private const val BUG_DATABASE_NAME = "bugreport-db"
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/dao/BugDao.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/dao/BugDao.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f613d95fa604929430388c6cbb1e838592a3cfc5
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/dao/BugDao.kt
@@ -0,0 +1,12 @@
+package de.rki.coronawarnapp.bugreporting.storage.dao
+
+import de.rki.coronawarnapp.bugreporting.event.BugEvent
+import kotlinx.coroutines.flow.Flow
+
+interface BugDao<T : BugEvent> {
+    suspend fun insertBugEvent(bugEvent: T)
+    fun getBugEvent(id: Long): Flow<T>
+    fun getAllBugEvents(): Flow<List<T>>
+    suspend fun deleteBugEvent(id: Long)
+    suspend fun deleteAllBugEvents()
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/dao/DefaultBugDao.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/dao/DefaultBugDao.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1162899c79aa29c394261bf171d57a2a46aead73
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/dao/DefaultBugDao.kt
@@ -0,0 +1,26 @@
+package de.rki.coronawarnapp.bugreporting.storage.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.Query
+import de.rki.coronawarnapp.bugreporting.event.BugEventEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface DefaultBugDao : BugDao<BugEventEntity> {
+
+    @Insert
+    override suspend fun insertBugEvent(bugEvent: BugEventEntity)
+
+    @Query("SELECT * FROM BugEventEntity WHERE id = :id")
+    override fun getBugEvent(id: Long): Flow<BugEventEntity>
+
+    @Query("SELECT * FROM BugEventEntity")
+    override fun getAllBugEvents(): Flow<List<BugEventEntity>>
+
+    @Query("DELETE FROM BugEventEntity")
+    override suspend fun deleteAllBugEvents()
+
+    @Query("DELETE FROM BugEventEntity WHERE id = :id")
+    override suspend fun deleteBugEvent(id: Long)
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/repository/BugRepository.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/repository/BugRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3b06c71d828d09518ddd4270d5813f5dd6a2045b
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/repository/BugRepository.kt
@@ -0,0 +1,11 @@
+package de.rki.coronawarnapp.bugreporting.storage.repository
+
+import de.rki.coronawarnapp.bugreporting.event.BugEvent
+import kotlinx.coroutines.flow.Flow
+
+interface BugRepository {
+    fun getAll(): Flow<List<BugEvent>>
+    fun get(id: Long): Flow<BugEvent>
+    suspend fun save(bugEvent: BugEvent)
+    suspend fun clear()
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/repository/DefaultBugRepository.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/repository/DefaultBugRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4b75a49be75c0a60712b6ecf636123e702b456fb
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/storage/repository/DefaultBugRepository.kt
@@ -0,0 +1,50 @@
+package de.rki.coronawarnapp.bugreporting.storage.repository
+
+import de.rki.coronawarnapp.bugreporting.event.BugEvent
+import de.rki.coronawarnapp.bugreporting.event.BugEventEntity
+import de.rki.coronawarnapp.bugreporting.storage.dao.DefaultBugDao
+import kotlinx.coroutines.flow.Flow
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class DefaultBugRepository @Inject constructor(
+    private val bugDao: DefaultBugDao
+) : BugRepository {
+
+    override fun getAll(): Flow<List<BugEvent>> = bugDao.getAllBugEvents()
+
+    override fun get(id: Long): Flow<BugEvent> = bugDao.getBugEvent(id)
+
+    override suspend fun save(bugEvent: BugEvent) {
+        val bugEventEntity: BugEventEntity = bugEvent.mapToBugEventEntity()
+        bugDao.insertBugEvent(bugEventEntity)
+    }
+
+    private fun BugEvent.mapToBugEventEntity(): BugEventEntity =
+        when (this is BugEventEntity) {
+            true -> this
+            else -> BugEventEntity(
+                id = id,
+                createdAt = createdAt,
+                tag = tag,
+                info = info,
+                exceptionClass = exceptionClass,
+                exceptionMessage = exceptionMessage,
+                stackTrace = stackTrace,
+                deviceInfo = deviceInfo,
+                appVersionName = appVersionName,
+                appVersionCode = appVersionCode,
+                apiLevel = apiLevel,
+                androidVersion = androidVersion,
+                shortCommitHash = shortCommitHash,
+                logHistory = logHistory
+            )
+        }
+
+    override suspend fun clear() {
+        Timber.d("Deleting all bug events!")
+        bugDao.deleteAllBugEvents()
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/DeviceForTestersModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/DeviceForTestersModule.kt
index 554f4f82c58966fb0969199dfa6d349e060af6b2..64f9befbc354757dc3b60b3c3b3bec8131853e39 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/DeviceForTestersModule.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/DeviceForTestersModule.kt
@@ -1,11 +1,13 @@
 package de.rki.coronawarnapp.test
 
 import dagger.Module
+import de.rki.coronawarnapp.test.crash.ui.SettingsCrashReportFragmentModule
 import de.rki.coronawarnapp.test.tasks.TaskControllerTestModule
 
 @Module(
     includes = [
-        TaskControllerTestModule::class
+        TaskControllerTestModule::class,
+        SettingsCrashReportFragmentModule::class
     ]
 )
 class DeviceForTestersModule
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/RiskLevelAndKeyRetrievalBenchmark.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/RiskLevelAndKeyRetrievalBenchmark.kt
index e92c287573feaf421a6913cfd9eb95298ddaa549..76a5bb2f119b9ec2556b0d9e9c6d52291b407cb8 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/RiskLevelAndKeyRetrievalBenchmark.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/RiskLevelAndKeyRetrievalBenchmark.kt
@@ -1,15 +1,19 @@
-package de.rki.coronawarnapp
+package de.rki.coronawarnapp.test
 
 import android.content.Context
 import android.text.format.Formatter
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.TransactionException
 import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.risk.RiskLevelTask
+import de.rki.coronawarnapp.task.Task
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction
 import de.rki.coronawarnapp.util.di.AppInjector
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.map
 import timber.log.Timber
-import kotlin.system.measureTimeMillis
+import java.util.UUID
 
 class RiskLevelAndKeyRetrievalBenchmark(
     private val context: Context,
@@ -29,7 +33,7 @@ class RiskLevelAndKeyRetrievalBenchmark(
      */
     suspend fun start(
         callCount: Int,
-        callback: (resultInfo: String) -> Unit
+        onBenchmarkCompletedListener: OnBenchmarkCompletedListener
     ) {
 
         var resultInfo = StringBuilder()
@@ -40,7 +44,7 @@ class RiskLevelAndKeyRetrievalBenchmark(
             .append("Result: \n\n")
             .append("#\t Combined \t Download \t Sub \t Risk \t File # \t  F. size\n")
 
-        callback(resultInfo.toString())
+        onBenchmarkCompletedListener(resultInfo.toString())
 
         repeat(callCount) { index ->
 
@@ -70,45 +74,58 @@ class RiskLevelAndKeyRetrievalBenchmark(
             var calculationDuration: Long = -1
             var calculationError = ""
 
-            try {
-                calculationDuration = measureKeyCalculation("#$index")
-            } catch (e: TransactionException) {
-                calculationError = e.message.toString()
-            }
+            measureKeyCalculation("#$index") {
+                if (it != null) calculationDuration = it
 
-            // build result entry for current iteration with all gathered data
-            resultInfo.append(
-                "${index + 1}. \t ${calculationDuration + keyFileDownloadDuration + apiSubmissionDuration} ms \t " +
+                // build result entry for current iteration with all gathered data
+                resultInfo.append(
+                    "${index + 1}. \t ${calculationDuration + keyFileDownloadDuration + apiSubmissionDuration} ms \t " +
                         "$keyFileDownloadDuration ms " + "\t $apiSubmissionDuration ms" +
                         "\t $calculationDuration ms \t $keyFileCount \t " +
                         "${Formatter.formatFileSize(context, keyFilesSize)}\n"
-            )
+                )
 
-            if (keyRetrievalError.isNotEmpty()) {
-                resultInfo.append("Key Retrieval Error: $keyRetrievalError\n")
-            }
+                if (keyRetrievalError.isNotEmpty()) {
+                    resultInfo.append("Key Retrieval Error: $keyRetrievalError\n")
+                }
 
-            if (calculationError.isNotEmpty()) {
-                resultInfo.append("Calculation Error: $calculationError\n")
-            }
+                if (calculationError.isNotEmpty()) {
+                    resultInfo.append("Calculation Error: $calculationError\n")
+                }
 
-            callback(resultInfo.toString())
+                onBenchmarkCompletedListener(resultInfo.toString())
+            }
         }
     }
 
-    private suspend fun measureKeyCalculation(label: String): Long {
-        try {
-            Timber.v("MEASURE [Risk Level Calculation] $label started")
-            // start risk level calculation and get duration
-            return measureTimeMillis {
-                RiskLevelTransaction.start()
-            }.also {
-                Timber.v("MEASURE [Risk Level Calculation] $label finished")
+    private suspend fun measureKeyCalculation(label: String, callback: (Long?) -> Unit) {
+        val uuid = UUID.randomUUID()
+        val t0 = System.currentTimeMillis()
+        AppInjector.component.taskController.tasks
+            .map {
+                it
+                    .map { taskInfo -> taskInfo.taskState }
+                    .filter { taskState -> taskState.request.id == uuid && taskState.isFinished }
             }
-        } catch (e: TransactionException) {
-            e.report(ExceptionCategory.INTERNAL)
-            throw e
-        }
+            .collect {
+                it.firstOrNull()?.also { state ->
+                    Timber.v("MEASURE [Risk Level Calculation] $label finished")
+                    callback.invoke(
+                        if (state.error != null)
+                            null
+                        else
+                            System.currentTimeMillis() - t0
+                    )
+                }
+            }
+        Timber.v("MEASURE [Risk Level Calculation] $label started")
+        AppInjector.component.taskController.submit(
+            DefaultTaskRequest(
+                RiskLevelTask::class,
+                object : Task.Arguments {},
+                uuid
+            )
+        )
     }
 
     private suspend fun measureDiagnosticKeyRetrieval(
@@ -149,3 +166,5 @@ class RiskLevelAndKeyRetrievalBenchmark(
         }
     }
 }
+
+typealias OnBenchmarkCompletedListener = (resultInfo: String) -> Unit
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 35db106d56b60d910fedace6c9803c7adc156187..fbdd56b5352c37f695c0ffa31727a7074bc68af1 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt
@@ -13,11 +13,7 @@ import android.view.ViewGroup
 import android.view.inputmethod.EditorInfo
 import android.view.inputmethod.InputMethodManager
 import android.widget.ImageView
-import android.widget.RadioButton
-import android.widget.RadioGroup
 import android.widget.Toast
-import androidx.core.view.ViewCompat.generateViewId
-import androidx.core.view.children
 import androidx.fragment.app.Fragment
 import androidx.lifecycle.lifecycleScope
 import androidx.recyclerview.widget.RecyclerView
@@ -34,7 +30,6 @@ import com.google.zxing.integration.android.IntentIntegrator
 import com.google.zxing.integration.android.IntentResult
 import com.google.zxing.qrcode.QRCodeWriter
 import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.RiskLevelAndKeyRetrievalBenchmark
 import de.rki.coronawarnapp.databinding.FragmentTestForAPIBinding
 import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
 import de.rki.coronawarnapp.exception.ExceptionCategory
@@ -46,18 +41,17 @@ import de.rki.coronawarnapp.nearby.InternalExposureNotificationPermissionHelper
 import de.rki.coronawarnapp.receiver.ExposureStateUpdateReceiver
 import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.sharing.ExposureSharingService
 import de.rki.coronawarnapp.storage.AppDatabase
 import de.rki.coronawarnapp.storage.ExposureSummaryRepository
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.tracing.TracingIntervalRepository
+import de.rki.coronawarnapp.test.RiskLevelAndKeyRetrievalBenchmark
 import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
 import de.rki.coronawarnapp.util.KeyFileHelper
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.AutoInject
 import de.rki.coronawarnapp.util.ui.observe2
-import de.rki.coronawarnapp.util.ui.setGone
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
 import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
@@ -131,75 +125,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
         qrPagerAdapter = QRPagerAdapter()
         qrPager.adapter = qrPagerAdapter
 
-        // Debug card
-        binding.hourlyKeyPkgMode.apply {
-            setOnClickListener { vm.setHourlyKeyPkgMode(isChecked) }
-        }
-
-        binding.backgroundNotificationsToggle.apply {
-            setOnClickListener { vm.setBackgroundNotifications(isChecked) }
-        }
-        vm.backgroundNotificationsToggleEvent.observe2(this@TestForAPIFragment) {
-            showToast("Background Notifications are activated: $it")
-        }
-        vm.debugOptionsState.observe2(this) { state ->
-            binding.apply {
-                backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled
-                hourlyKeyPkgMode.isChecked = state.isHourlyTestingMode
-            }
-        }
-        binding.testLogfileToggle.apply {
-            setOnClickListener { vm.setLoggerEnabled(isChecked) }
-        }
-        vm.loggerState.observe2(this) { state ->
-            binding.apply {
-                testLogfileToggle.isChecked = state.isLogging
-                testLogfileShare.setGone(!state.isLogging)
-            }
-        }
-        binding.testLogfileShare.setOnClickListener { vm.shareLogFile() }
-        vm.logShareEvent.observe2(this) { showToast("Logfile copied to $it") }
-
-        // Server environment card
-        binding.environmentToggleGroup.apply {
-            setOnCheckedChangeListener { group, checkedId ->
-                val chip = group.findViewById<RadioButton>(checkedId)
-                if (!chip.isPressed) return@setOnCheckedChangeListener
-                vm.selectEnvironmentTytpe(chip.text.toString())
-            }
-        }
-
-        vm.environmentState.observe2(this) { state ->
-            binding.apply {
-                if (environmentToggleGroup.childCount != state.available.size) {
-                    environmentToggleGroup.removeAllViews()
-                    state.available.forEach { type ->
-                        RadioButton(requireContext()).apply {
-                            id = generateViewId()
-                            text = type.rawKey
-                            layoutParams = RadioGroup.LayoutParams(
-                                RadioGroup.LayoutParams.MATCH_PARENT,
-                                RadioGroup.LayoutParams.WRAP_CONTENT
-                            )
-                            environmentToggleGroup.addView(this)
-                        }
-                    }
-                }
-
-                environmentToggleGroup.children.forEach {
-                    it as RadioButton
-                    it.isChecked = it.text == state.current.rawKey
-                }
-
-                environmentCdnurlDownload.text = "Download CDN:\n${state.urlDownload}"
-                environmentCdnurlSubmission.text = "Submission CDN:\n${state.urlSubmission}"
-                environmentCdnurlVerification.text = "Verification CDN:\n${state.urlVerification}"
-            }
-        }
-        vm.environmentChangeEvent.observe2(this) {
-            showSnackBar("Environment changed to: $it\nForce stop & restart the app!")
-        }
-
         // GMS Info card
         vm.gmsState.observe2(this) { state ->
             binding.googlePlayServicesVersionInfo.text =
@@ -292,9 +217,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
         // Load countries from App config and update Country UI element states
         lifecycleScope.launch {
             lastSetCountries =
-                ApplicationConfigurationService.asyncRetrieveApplicationConfiguration()
-                    .supportedCountriesList
-
+                AppInjector.component.appConfigProvider.getAppConfig().supportedCountries
             binding.inputCountryCodesEditText.setText(
                 lastSetCountries?.joinToString(",")
             )
@@ -469,7 +392,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i),
                     // only testing implementation: this is used to wait for the broadcastreceiver of the OS / EN API
                     enfClient.provideDiagnosisKeys(
                         googleFileList,
-                        ApplicationConfigurationService.asyncRetrieveExposureConfiguration(),
+                        AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration,
                         token!!
                     )
                     showToast("Provided ${appleKeyList.size} keys to Google API with token $token")
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt
index d18f0f8149c9901f22f70a81de4cc7012139c6e6..f2ebfd26e4c413d2b3c80cb8cce605669310ee03 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt
@@ -2,110 +2,23 @@ package de.rki.coronawarnapp.test.api.ui
 
 import android.content.Context
 import androidx.core.content.pm.PackageInfoCompat
-import androidx.lifecycle.viewModelScope
 import com.google.android.gms.common.GoogleApiAvailability
 import com.squareup.inject.assisted.AssistedInject
-import de.rki.coronawarnapp.environment.EnvironmentSetup
-import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType
-import de.rki.coronawarnapp.exception.ExceptionCategory
-import de.rki.coronawarnapp.exception.TransactionException
-import de.rki.coronawarnapp.exception.reporting.report
-import de.rki.coronawarnapp.storage.LocalData
-import de.rki.coronawarnapp.storage.TestSettings
-import de.rki.coronawarnapp.test.api.ui.EnvironmentState.Companion.toEnvironmentState
-import de.rki.coronawarnapp.test.api.ui.LoggerState.Companion.toLoggerState
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction
-import de.rki.coronawarnapp.util.CWADebug
+import de.rki.coronawarnapp.risk.RiskLevelTask
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
 import de.rki.coronawarnapp.util.di.AppContext
-import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.ui.smartLiveData
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.launch
-import java.io.File
 
 class TestForApiFragmentViewModel @AssistedInject constructor(
     @AppContext private val context: Context,
-    private val envSetup: EnvironmentSetup,
-    private val testSettings: TestSettings
+    private val taskController: TaskController
 ) : CWAViewModel() {
 
-    val debugOptionsState by smartLiveData {
-        DebugOptionsState(
-            areNotificationsEnabled = LocalData.backgroundNotification(),
-            isHourlyTestingMode = testSettings.isHourKeyPkgMode
-        )
-    }
-
-    fun setHourlyKeyPkgMode(enabled: Boolean) {
-        debugOptionsState.update {
-            testSettings.isHourKeyPkgMode = enabled
-            it.copy(isHourlyTestingMode = enabled)
-        }
-    }
-
-    val environmentState by smartLiveData {
-        envSetup.toEnvironmentState()
-    }
-    val environmentChangeEvent = SingleLiveEvent<EnvironmentSetup.Type>()
-
-    fun selectEnvironmentTytpe(type: String) {
-        environmentState.update {
-            envSetup.currentEnvironment = type.toEnvironmentType()
-            environmentChangeEvent.postValue(envSetup.currentEnvironment)
-            envSetup.toEnvironmentState()
-        }
-    }
-
-    val backgroundNotificationsToggleEvent = SingleLiveEvent<Boolean>()
-
-    fun setBackgroundNotifications(enabled: Boolean) {
-        debugOptionsState.update {
-            LocalData.backgroundNotification(enabled)
-            it.copy(areNotificationsEnabled = enabled)
-        }
-        backgroundNotificationsToggleEvent.postValue(enabled)
-    }
-
-    val loggerState by smartLiveData {
-        CWADebug.toLoggerState()
-    }
-
-    fun setLoggerEnabled(enable: Boolean) {
-        CWADebug.fileLogger?.let {
-            if (enable) it.start() else it.stop()
-        }
-        loggerState.update { CWADebug.toLoggerState() }
-    }
-
     fun calculateRiskLevelClicked() {
-        viewModelScope.launch {
-            try {
-                RiskLevelTransaction.start()
-            } catch (e: TransactionException) {
-                e.report(ExceptionCategory.INTERNAL)
-            }
-        }
-    }
-
-    val logShareEvent = SingleLiveEvent<File?>()
-
-    fun shareLogFile() {
-        CWADebug.fileLogger?.let {
-            viewModelScope.launch(context = Dispatchers.Default) {
-                if (!it.logFile.exists()) return@launch
-
-                val externalPath = File(
-                    context.getExternalFilesDir(null),
-                    "LogFile-${System.currentTimeMillis()}.log"
-                )
-
-                it.logFile.copyTo(externalPath)
-
-                logShareEvent.postValue(externalPath)
-            }
-        }
+        taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
     }
 
     val gmsState by smartLiveData {
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..feedc56bc7d28d2f0be692f7410a2e026c6662b7
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.test.appconfig.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.View
+import android.widget.Toast
+import androidx.fragment.app.Fragment
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.FragmentTestAppconfigBinding
+import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
+import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.viewBindingLazy
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
+import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
+import org.joda.time.DateTimeZone
+import org.joda.time.format.ISODateTimeFormat
+import javax.inject.Inject
+
+@SuppressLint("SetTextI18n")
+class AppConfigTestFragment : Fragment(R.layout.fragment_test_appconfig), AutoInject {
+
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val vm: AppConfigTestFragmentViewModel by cwaViewModels { viewModelFactory }
+
+    private val binding: FragmentTestAppconfigBinding by viewBindingLazy()
+
+    private val timeFormatter = ISODateTimeFormat.dateTime()
+        .withZone(DateTimeZone.forID("Europe/Berlin"))
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        vm.currentConfig.observe2(this) { data ->
+            binding.currentConfiguration.text =
+                data?.rawConfig?.toString() ?: "No config available."
+            binding.lastUpdate.text = data?.updatedAt?.let { timeFormatter.print(it) } ?: "n/a"
+            binding.timeOffset.text = data?.let {
+                "${it.localOffset.millis}ms (isFallbackConfig=${it.isFallback})"
+            } ?: "n/a"
+        }
+
+        vm.errorEvent.observe2(this) {
+            Toast.makeText(requireContext(), it.toString(), Toast.LENGTH_LONG).show()
+        }
+
+        binding.downloadAction.setOnClickListener { vm.download() }
+        binding.deleteAction.setOnClickListener { vm.clearConfig() }
+    }
+
+    companion object {
+        val MENU_ITEM = TestMenuItem(
+            title = "Remote Config Data",
+            description = "View & Control the remote config.",
+            targetId = R.id.test_appconfig_fragment
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0ebba5dd4c2b262da0530e469a23571a2d44f009
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt
@@ -0,0 +1,18 @@
+package de.rki.coronawarnapp.test.appconfig.ui
+
+import dagger.Binds
+import dagger.Module
+import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey
+
+@Module
+abstract class AppConfigTestFragmentModule {
+    @Binds
+    @IntoMap
+    @CWAViewModelKey(AppConfigTestFragmentViewModel::class)
+    abstract fun testTaskControllerFragment(
+        factory: AppConfigTestFragmentViewModel.Factory
+    ): CWAViewModelFactory<out CWAViewModel>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..98dfaf2ec999083114c0b47ac8224d127406a206
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt
@@ -0,0 +1,39 @@
+package de.rki.coronawarnapp.test.appconfig.ui
+
+import androidx.lifecycle.asLiveData
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import timber.log.Timber
+
+class AppConfigTestFragmentViewModel @AssistedInject constructor(
+    dispatcherProvider: DispatcherProvider,
+    private val appConfigProvider: AppConfigProvider
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    val currentConfig = appConfigProvider.currentConfig.asLiveData()
+    val errorEvent = SingleLiveEvent<Exception>()
+
+    fun download() {
+        launch {
+            try {
+                appConfigProvider.getAppConfig()
+            } catch (e: Exception) {
+                Timber.e(e, "Failed to get app config.")
+                errorEvent.postValue(e)
+            }
+        }
+    }
+
+    fun clearConfig() {
+        launch {
+            appConfigProvider.clear()
+        }
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<AppConfigTestFragmentViewModel>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/CrashReportAdapter.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/CrashReportAdapter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..afdfb4358aa590da76289fb57cf592c9b0efc2f1
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/CrashReportAdapter.kt
@@ -0,0 +1,47 @@
+package de.rki.coronawarnapp.test.crash.ui
+
+import android.view.LayoutInflater
+import android.view.ViewGroup
+import androidx.recyclerview.widget.RecyclerView
+import de.rki.coronawarnapp.bugreporting.event.BugEvent
+
+import de.rki.coronawarnapp.databinding.ViewCrashreportListItemBinding
+import org.joda.time.DateTimeZone
+
+class CrashReportAdapter(private val itemClickListener: (bugEvent: BugEvent) -> Unit) :
+    RecyclerView.Adapter<CrashReportAdapter.CrashHolder>() {
+
+    private var crashReports = listOf<BugEvent>()
+
+    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): CrashHolder {
+        val inflater = LayoutInflater.from(parent.context)
+        val binding = ViewCrashreportListItemBinding.inflate(inflater)
+        return CrashHolder(
+            binding
+        )
+    }
+
+    override fun onBindViewHolder(holder: CrashHolder, position: Int) {
+        val crashReport = crashReports[position]
+        holder.bind(crashReport, position)
+        holder.itemView.setOnClickListener { itemClickListener(crashReport) }
+    }
+
+    override fun getItemCount() = crashReports.size
+
+    fun updateCrashReports(crashReportList: List<BugEvent>) {
+        crashReports = crashReportList
+        notifyDataSetChanged()
+    }
+
+    class CrashHolder(private val binding: ViewCrashreportListItemBinding) :
+        RecyclerView.ViewHolder(binding.root) {
+        fun bind(bugEvent: BugEvent, pos: Int) {
+            binding.crashReportTitle = "Error #${pos + 1}"
+            binding.message = bugEvent.exceptionMessage
+            binding.crashReportDateFormatted =
+                bugEvent.createdAt.toDateTime(DateTimeZone.getDefault()).toString()
+                    .replace("T", "  ")
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportDetailsFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportDetailsFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f7b9ce4dbc002399282e8297029ed970fa3ec50b
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportDetailsFragment.kt
@@ -0,0 +1,55 @@
+package de.rki.coronawarnapp.test.crash.ui
+
+import android.os.Bundle
+import android.view.View
+import androidx.core.app.ShareCompat
+import androidx.fragment.app.Fragment
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.FragmentSettingsCrashReportDetailsBinding
+import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.viewBindingLazy
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
+import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
+import javax.inject.Inject
+
+class SettingsCrashReportDetailsFragment :
+    Fragment(R.layout.fragment_settings_crash_report_details), AutoInject {
+
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val vm: SettingsCrashReportViewModel by cwaViewModels(
+        ownerProducer = { requireActivity().viewModelStore },
+        factoryProducer = { viewModelFactory }
+    )
+    private val fragmentSettingsCrashReportDetailsBinding: FragmentSettingsCrashReportDetailsBinding by viewBindingLazy()
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        vm.selectedCrashReport.observe2(this) {
+            fragmentSettingsCrashReportDetailsBinding.buttonCrashReportShare.visibility = View.VISIBLE
+            fragmentSettingsCrashReportDetailsBinding.buttonCrashReportShare.setOnClickListener { shareCrashReport() }
+        }
+
+        vm.selectedCrashReportFormattedText.observe2(this) {
+            fragmentSettingsCrashReportDetailsBinding.selectedCrashReportFormattedText = it
+        }
+    }
+
+    private fun shareCrashReport() {
+        activity?.let { activity ->
+            val shareIntent = ShareCompat.IntentBuilder
+                .from(activity)
+                .setType("text/plain")
+                .setText(fragmentSettingsCrashReportDetailsBinding.textViewCrashReportDetails.text)
+                .createChooserIntent()
+
+            if (shareIntent.resolveActivity(activity.packageManager) != null) {
+                startActivity(shareIntent)
+            }
+        }
+    }
+
+    companion object {
+        private val TAG = SettingsCrashReportDetailsFragment::class.java.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9268d0cdefb3d4c4250d39ae10dea7bced9c412d
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportFragment.kt
@@ -0,0 +1,67 @@
+package de.rki.coronawarnapp.test.crash.ui
+
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.Fragment
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.bugreporting.event.BugEvent
+import de.rki.coronawarnapp.databinding.FragmentCrashreporterOverviewBinding
+import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
+import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.ui.doNavigate
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.viewBindingLazy
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
+import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
+import timber.log.Timber
+import javax.inject.Inject
+
+class SettingsCrashReportFragment : Fragment(R.layout.fragment_crashreporter_overview), AutoInject {
+
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val vm: SettingsCrashReportViewModel by cwaViewModels(
+        ownerProducer = { requireActivity().viewModelStore },
+        factoryProducer = { viewModelFactory }
+    )
+
+    private val fragmentCrashreporterOverviewBinding: FragmentCrashreporterOverviewBinding by viewBindingLazy()
+    private lateinit var adapter: CrashReportAdapter
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        adapter = CrashReportAdapter { crashReportClicked(it) }
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        fragmentCrashreporterOverviewBinding.list.adapter = adapter
+
+        vm.crashReports.observe2(this) {
+            adapter.updateCrashReports(it)
+        }
+
+        fragmentCrashreporterOverviewBinding.buttonClearCrashReportList.setOnClickListener {
+            vm.deleteAllCrashReports()
+        }
+
+        fragmentCrashreporterOverviewBinding.buttonTestItemForCrashReport.setOnClickListener {
+            vm.simulateException()
+        }
+    }
+
+    private fun crashReportClicked(crashReport: BugEvent) {
+        Timber.d("Clicked on crash report ${crashReport.id}")
+        vm.selectCrashReport(crashReport)
+        doNavigate(SettingsCrashReportFragmentDirections.actionCrashReportFragmentToSettingsCrashReportDetailsFragment())
+    }
+
+    companion object {
+        val TAG = SettingsCrashReportFragment::class.java.simpleName
+        val MENU_ITEM = TestMenuItem(
+            title = "Bug & Problem Reporter",
+            description = "List of Bugs & Exceptions with share option.",
+            targetId = R.id.action_testMenuFragment_to_settingsCrashReportFragment
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportFragmentModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ecde683f1ec9ec04ed81cf9eb5002894d0c2fd78
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportFragmentModule.kt
@@ -0,0 +1,24 @@
+package de.rki.coronawarnapp.test.crash.ui
+
+import dagger.Binds
+import dagger.Module
+import dagger.android.ContributesAndroidInjector
+import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey
+
+@Module
+abstract class SettingsCrashReportFragmentModule {
+
+    @Binds
+    @IntoMap
+    @CWAViewModelKey(SettingsCrashReportViewModel::class)
+    abstract fun settingsCrashReportFragment(factory: SettingsCrashReportViewModel.Factory): CWAViewModelFactory<out CWAViewModel>
+
+    @ContributesAndroidInjector
+    abstract fun settingsCrashReportFragment(): SettingsCrashReportFragment
+
+    @ContributesAndroidInjector
+    abstract fun settingsCrashReportDetailsFragment(): SettingsCrashReportDetailsFragment
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..234022907e2c603253e6ac70c8abffa1dd72143d
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/crash.ui/SettingsCrashReportViewModel.kt
@@ -0,0 +1,62 @@
+package de.rki.coronawarnapp.test.crash.ui
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asLiveData
+import androidx.lifecycle.map
+import androidx.lifecycle.viewModelScope
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.bugreporting.event.BugEvent
+import de.rki.coronawarnapp.bugreporting.reportProblem
+import de.rki.coronawarnapp.bugreporting.storage.repository.BugRepository
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.lang.Exception
+
+class SettingsCrashReportViewModel @AssistedInject constructor(
+    private val crashReportRepository: BugRepository
+) : CWAViewModel() {
+
+    val crashReports = crashReportRepository.getAll().asLiveData()
+
+    private val selectedCrashReportMutable: MutableLiveData<BugEvent> = MutableLiveData()
+    val selectedCrashReport: LiveData<BugEvent> = selectedCrashReportMutable
+    val selectedCrashReportFormattedText: LiveData<String> = selectedCrashReportMutable.map {
+        createBugEventFormattedText(it)
+    }
+
+    fun deleteAllCrashReports() = viewModelScope.launch(Dispatchers.IO) {
+        crashReportRepository.clear()
+    }
+
+    fun simulateException() {
+        try {
+            val a = 2 / 0
+        } catch (e: Exception) {
+            Timber.e(e, "Msg: ${e.message}")
+            e.reportProblem(SettingsCrashReportViewModel::class.java.simpleName, e.message)
+        }
+    }
+
+    fun selectCrashReport(bugEvent: BugEvent) {
+        selectedCrashReportMutable.postValue(bugEvent)
+    }
+
+    private fun createBugEventFormattedText(bugEvent: BugEvent): String =
+        "Selected crash report ${bugEvent.id} \n" +
+            " # appeared at: ${bugEvent.createdAt} \n\n" +
+            " # Device: ${bugEvent.deviceInfo} \n" +
+            " # Android Version ${bugEvent.androidVersion} \n" +
+            " # Android API-Level ${bugEvent.apiLevel} \n\n" +
+            " # AppVersion: ${bugEvent.appVersionName} \n" +
+            " # AppVersionCode ${bugEvent.appVersionCode} \n" +
+            " # C-Hash ${bugEvent.shortCommitHash} \n\n\n" +
+            " ${bugEvent.stackTrace}\n\n" +
+            " # Corresponding Log: \n\n ${bugEvent.logHistory}"
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<SettingsCrashReportViewModel>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8015debc73d4333c59d03accd9e61e6e823e9005
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt
@@ -0,0 +1,115 @@
+package de.rki.coronawarnapp.test.debugoptions.ui
+
+import android.annotation.SuppressLint
+import android.os.Bundle
+import android.view.View
+import android.widget.RadioButton
+import android.widget.RadioGroup
+import androidx.core.view.ViewCompat
+import androidx.core.view.children
+import androidx.fragment.app.Fragment
+import com.google.android.material.snackbar.Snackbar
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.FragmentTestDebugoptionsBinding
+import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
+import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.setGone
+import de.rki.coronawarnapp.util.ui.viewBindingLazy
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
+import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
+import javax.inject.Inject
+
+@SuppressLint("SetTextI18n")
+class DebugOptionsFragment : Fragment(R.layout.fragment_test_debugoptions), AutoInject {
+
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val vm: DebugOptionsFragmentViewModel by cwaViewModels { viewModelFactory }
+
+    private val binding: FragmentTestDebugoptionsBinding by viewBindingLazy()
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        // Debug card
+        binding.hourlyKeyPkgMode.apply {
+            setOnClickListener { vm.setHourlyKeyPkgMode(isChecked) }
+        }
+
+        binding.backgroundNotificationsToggle.apply {
+            setOnClickListener { vm.setBackgroundNotifications(isChecked) }
+        }
+        vm.backgroundNotificationsToggleEvent.observe2(this@DebugOptionsFragment) {
+            showSnackBar("Background Notifications are activated: $it")
+        }
+        vm.debugOptionsState.observe2(this) { state ->
+            binding.apply {
+                backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled
+                hourlyKeyPkgMode.isChecked = state.isHourlyTestingMode
+            }
+        }
+        binding.testLogfileToggle.apply {
+            setOnClickListener { vm.setLoggerEnabled(isChecked) }
+        }
+        vm.loggerState.observe2(this) { state ->
+            binding.apply {
+                testLogfileToggle.isChecked = state.isLogging
+                testLogfileShare.setGone(!state.isLogging)
+            }
+        }
+        binding.testLogfileShare.setOnClickListener { vm.shareLogFile() }
+        vm.logShareEvent.observe2(this) { showSnackBar("Logfile copied to $it") }
+
+        // Server environment card
+        binding.environmentToggleGroup.apply {
+            setOnCheckedChangeListener { group, checkedId ->
+                val chip = group.findViewById<RadioButton>(checkedId)
+                if (!chip.isPressed) return@setOnCheckedChangeListener
+                vm.selectEnvironmentTytpe(chip.text.toString())
+            }
+        }
+
+        vm.environmentState.observe2(this) { state ->
+            binding.apply {
+                if (environmentToggleGroup.childCount != state.available.size) {
+                    environmentToggleGroup.removeAllViews()
+                    state.available.forEach { type ->
+                        RadioButton(requireContext()).apply {
+                            id = ViewCompat.generateViewId()
+                            text = type.rawKey
+                            layoutParams = RadioGroup.LayoutParams(
+                                RadioGroup.LayoutParams.MATCH_PARENT,
+                                RadioGroup.LayoutParams.WRAP_CONTENT
+                            )
+                            environmentToggleGroup.addView(this)
+                        }
+                    }
+                }
+
+                environmentToggleGroup.children.forEach {
+                    it as RadioButton
+                    it.isChecked = it.text == state.current.rawKey
+                }
+
+                environmentCdnurlDownload.text = "Download CDN:\n${state.urlDownload}"
+                environmentCdnurlSubmission.text = "Submission CDN:\n${state.urlSubmission}"
+                environmentCdnurlVerification.text = "Verification CDN:\n${state.urlVerification}"
+            }
+        }
+        vm.environmentChangeEvent.observe2(this) {
+            showSnackBar("Environment changed to: $it\nForce stop & restart the app!")
+        }
+    }
+
+    private fun showSnackBar(message: String) {
+        Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show()
+    }
+
+    companion object {
+        val MENU_ITEM = TestMenuItem(
+            title = "Debug options",
+            description = "Server environment, logging, hourly mode...",
+            targetId = R.id.test_debugoptions_fragment
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f24dda5b1559bf054dd0cd7fadefb1af0c73e864
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt
@@ -0,0 +1,18 @@
+package de.rki.coronawarnapp.test.debugoptions.ui
+
+import dagger.Binds
+import dagger.Module
+import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey
+
+@Module
+abstract class DebugOptionsFragmentModule {
+    @Binds
+    @IntoMap
+    @CWAViewModelKey(DebugOptionsFragmentViewModel::class)
+    abstract fun testTaskControllerFragment(
+        factory: DebugOptionsFragmentViewModel.Factory
+    ): CWAViewModelFactory<out CWAViewModel>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..784c9731edd17cf949a8bb4d22d8898732fb8691
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt
@@ -0,0 +1,108 @@
+package de.rki.coronawarnapp.test.debugoptions.ui
+
+import android.content.Context
+import androidx.lifecycle.viewModelScope
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.environment.EnvironmentSetup
+import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType
+import de.rki.coronawarnapp.risk.RiskLevelTask
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.storage.TestSettings
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
+import de.rki.coronawarnapp.test.api.ui.DebugOptionsState
+import de.rki.coronawarnapp.test.api.ui.EnvironmentState.Companion.toEnvironmentState
+import de.rki.coronawarnapp.test.api.ui.LoggerState.Companion.toLoggerState
+import de.rki.coronawarnapp.util.CWADebug
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.ui.smartLiveData
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import java.io.File
+
+class DebugOptionsFragmentViewModel @AssistedInject constructor(
+    @AppContext private val context: Context,
+    private val envSetup: EnvironmentSetup,
+    private val testSettings: TestSettings,
+    private val taskController: TaskController,
+    dispatcherProvider: DispatcherProvider
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    val debugOptionsState by smartLiveData {
+        DebugOptionsState(
+            areNotificationsEnabled = LocalData.backgroundNotification(),
+            isHourlyTestingMode = testSettings.isHourKeyPkgMode
+        )
+    }
+
+    fun setHourlyKeyPkgMode(enabled: Boolean) {
+        debugOptionsState.update {
+            testSettings.isHourKeyPkgMode = enabled
+            it.copy(isHourlyTestingMode = enabled)
+        }
+    }
+
+    val environmentState by smartLiveData {
+        envSetup.toEnvironmentState()
+    }
+    val environmentChangeEvent = SingleLiveEvent<EnvironmentSetup.Type>()
+
+    fun selectEnvironmentTytpe(type: String) {
+        environmentState.update {
+            envSetup.currentEnvironment = type.toEnvironmentType()
+            environmentChangeEvent.postValue(envSetup.currentEnvironment)
+            envSetup.toEnvironmentState()
+        }
+    }
+
+    val backgroundNotificationsToggleEvent = SingleLiveEvent<Boolean>()
+
+    fun setBackgroundNotifications(enabled: Boolean) {
+        debugOptionsState.update {
+            LocalData.backgroundNotification(enabled)
+            it.copy(areNotificationsEnabled = enabled)
+        }
+        backgroundNotificationsToggleEvent.postValue(enabled)
+    }
+
+    val loggerState by smartLiveData {
+        CWADebug.toLoggerState()
+    }
+
+    fun setLoggerEnabled(enable: Boolean) {
+        CWADebug.fileLogger?.let {
+            if (enable) it.start() else it.stop()
+        }
+        loggerState.update { CWADebug.toLoggerState() }
+    }
+
+    fun calculateRiskLevelClicked() {
+        taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
+    }
+
+    val logShareEvent = SingleLiveEvent<File?>()
+
+    fun shareLogFile() {
+        CWADebug.fileLogger?.let {
+            viewModelScope.launch(context = Dispatchers.Default) {
+                if (!it.logFile.exists()) return@launch
+
+                val externalPath = File(
+                    context.getExternalFilesDir(null),
+                    "LogFile-${System.currentTimeMillis()}.log"
+                )
+
+                it.logFile.copyTo(externalPath)
+
+                logShareEvent.postValue(externalPath)
+            }
+        }
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<DebugOptionsFragmentViewModel>
+}
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt
index 7e6439dae9d7c53260b5bf13a10c8915c9c17f8f..d3316873e6c0bb8d17fc9cf190189b9e1eb4faed 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt
@@ -3,6 +3,9 @@ package de.rki.coronawarnapp.test.menu.ui
 import androidx.lifecycle.MutableLiveData
 import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.test.api.ui.TestForAPIFragment
+import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment
+import de.rki.coronawarnapp.test.crash.ui.SettingsCrashReportFragment
+import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment
 import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment
 import de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
@@ -13,9 +16,12 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() {
 
     val testMenuData by lazy {
         listOf(
+            DebugOptionsFragment.MENU_ITEM,
+            AppConfigTestFragment.MENU_ITEM,
             TestForAPIFragment.MENU_ITEM,
             TestRiskLevelCalculationFragment.MENU_ITEM,
-            TestTaskControllerFragment.MENU_ITEM
+            TestTaskControllerFragment.MENU_ITEM,
+            SettingsCrashReportFragment.MENU_ITEM
         ).let { MutableLiveData(it) }
     }
     val showTestScreenEvent = SingleLiveEvent<TestMenuItem>()
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
index 8513f470caa8b3826af3a554c686554cbce80ab9..0644ec8261959aac9dd0a9428a133b11b15d01f3 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
@@ -71,7 +71,6 @@ class TestRiskLevelCalculationFragment : Fragment(R.layout.fragment_test_risk_le
             binding.labelBackendParameters.text = state.backendParameters
             binding.labelExposureSummary.text = state.exposureSummary
             binding.labelFormula.text = state.formula
-            binding.labelFullConfig.text = state.fullConfig
             binding.labelExposureInfo.text = state.exposureInfo
         }
         vm.startENFObserver()
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
index 611ede80dfec28a20a073a2d6ef82bd146dba716..add8047c5e6ceb4138286da92f5ebf02ec1bf4d0 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
@@ -8,25 +8,28 @@ import androidx.lifecycle.viewModelScope
 import com.google.android.gms.nearby.exposurenotification.ExposureInformation
 import com.squareup.inject.assisted.Assisted
 import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
-import de.rki.coronawarnapp.risk.DefaultRiskLevelCalculation
 import de.rki.coronawarnapp.risk.RiskLevel
+import de.rki.coronawarnapp.risk.RiskLevelTask
+import de.rki.coronawarnapp.risk.RiskLevels
 import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.storage.AppDatabase
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.RiskLevelRepository
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction
 import de.rki.coronawarnapp.ui.tracing.card.TracingCardStateProvider
 import de.rki.coronawarnapp.util.KeyFileHelper
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.security.SecurityHelper
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
@@ -49,6 +52,8 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
     @AppContext private val context: Context, // App context
     dispatcherProvider: DispatcherProvider,
     private val enfClient: ENFClient,
+    private val riskLevels: RiskLevels,
+    private val taskController: TaskController,
     private val keyCacheRepository: KeyCacheRepository,
     tracingCardStateProvider: TracingCardStateProvider
 ) : CWAViewModel(
@@ -82,13 +87,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
     }
 
     fun calculateRiskLevel() {
-        viewModelScope.launch {
-            try {
-                RiskLevelTransaction.start()
-            } catch (e: Exception) {
-                e.report(ExceptionCategory.INTERNAL)
-            }
-        }
+        taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
     }
 
     fun resetRiskLevel() {
@@ -110,7 +109,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                     e.report(ExceptionCategory.INTERNAL)
                 }
             }
-            RiskLevelTransaction.start()
+            taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
             riskLevelResetEvent.postValue(Unit)
         }
     }
@@ -120,7 +119,6 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
         val backendParameters: String = "",
         val exposureSummary: String = "",
         val formula: String = "",
-        val fullConfig: String = "",
         val exposureInfo: String = ""
     )
 
@@ -133,11 +131,11 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                 val exposureSummary =
                     InternalExposureNotificationClient.asyncGetExposureSummary(googleToken)
 
-                val appConfig =
-                    ApplicationConfigurationService.asyncRetrieveApplicationConfiguration()
+                val expDetectConfig: RiskCalculationConfig =
+                    AppInjector.component.appConfigProvider.getAppConfig()
 
-                val riskLevelScore = DefaultRiskLevelCalculation().calculateRiskScore(
-                    appConfig.attenuationDuration,
+                val riskLevelScore = riskLevels.calculateRiskScore(
+                    expDetectConfig.attenuationDuration,
                     exposureSummary
                 )
 
@@ -155,17 +153,17 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                 workState = workState.copy(riskScoreMsg = riskAsString)
 
                 val lowClass =
-                    appConfig.riskScoreClasses?.riskClassesList?.find { low -> low.label == "LOW" }
+                    expDetectConfig.riskScoreClasses.riskClassesList?.find { low -> low.label == "LOW" }
                 val highClass =
-                    appConfig.riskScoreClasses?.riskClassesList?.find { high -> high.label == "HIGH" }
+                    expDetectConfig.riskScoreClasses.riskClassesList?.find { high -> high.label == "HIGH" }
 
                 val configAsString =
-                    "Attenuation Weight Low: ${appConfig.attenuationDuration?.weights?.low}\n" +
-                        "Attenuation Weight Mid: ${appConfig.attenuationDuration?.weights?.mid}\n" +
-                        "Attenuation Weight High: ${appConfig.attenuationDuration?.weights?.high}\n\n" +
-                        "Attenuation Offset: ${appConfig.attenuationDuration?.defaultBucketOffset}\n" +
+                    "Attenuation Weight Low: ${expDetectConfig.attenuationDuration.weights?.low}\n" +
+                        "Attenuation Weight Mid: ${expDetectConfig.attenuationDuration.weights?.mid}\n" +
+                        "Attenuation Weight High: ${expDetectConfig.attenuationDuration.weights?.high}\n\n" +
+                        "Attenuation Offset: ${expDetectConfig.attenuationDuration.defaultBucketOffset}\n" +
                         "Attenuation Normalization: " +
-                        "${appConfig.attenuationDuration?.riskScoreNormalizationDivisor}\n\n" +
+                        "${expDetectConfig.attenuationDuration.riskScoreNormalizationDivisor}\n\n" +
                         "Risk Score Low Class: ${lowClass?.min ?: 0} - ${lowClass?.max ?: 0}\n" +
                         "Risk Score High Class: ${highClass?.min ?: 0} - ${highClass?.max ?: 0}"
 
@@ -176,9 +174,9 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                         "Matched Key Count: ${exposureSummary.matchedKeyCount}\n" +
                         "Maximum Risk Score: ${exposureSummary.maximumRiskScore}\n" +
                         "Attenuation Durations: [${
-                            exposureSummary.attenuationDurationsInMinutes?.get(
-                                0
-                            )
+                        exposureSummary.attenuationDurationsInMinutes?.get(
+                            0
+                        )
                         }," +
                         "${exposureSummary.attenuationDurationsInMinutes?.get(1)}," +
                         "${exposureSummary.attenuationDurationsInMinutes?.get(2)}]\n" +
@@ -187,19 +185,18 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                 workState = workState.copy(exposureSummary = summaryAsString)
 
                 val maxRisk = exposureSummary.maximumRiskScore
-                val atWeights = appConfig.attenuationDuration?.weights
+                val atWeights = expDetectConfig.attenuationDuration.weights
                 val attenuationDurationInMin =
                     exposureSummary.attenuationDurationsInMinutes
-                val attenuationConfig = appConfig.attenuationDuration
+                val attenuationConfig = expDetectConfig.attenuationDuration
                 val formulaString =
-                    "($maxRisk / ${attenuationConfig?.riskScoreNormalizationDivisor}) * " +
+                    "($maxRisk / ${attenuationConfig.riskScoreNormalizationDivisor}) * " +
                         "(${attenuationDurationInMin?.get(0)} * ${atWeights?.low} " +
                         "+ ${attenuationDurationInMin?.get(1)} * ${atWeights?.mid} " +
                         "+ ${attenuationDurationInMin?.get(2)} * ${atWeights?.high} " +
-                        "+ ${attenuationConfig?.defaultBucketOffset})"
+                        "+ ${attenuationConfig.defaultBucketOffset})"
 
-                workState =
-                    workState.copy(formula = formulaString, fullConfig = appConfig.toString())
+                workState = workState.copy(formula = formulaString)
 
                 val token = LocalData.googleApiToken()
                 if (token != null) {
@@ -275,7 +272,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
                 // only testing implementation: this is used to wait for the broadcastreceiver of the OS / EN API
                 enfClient.provideDiagnosisKeys(
                     googleFileList,
-                    ApplicationConfigurationService.asyncRetrieveExposureConfiguration(),
+                    AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration,
                     token
                 )
                 apiKeysProvidedEvent.postValue(
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt
index cdf98cf1aa010d2e993173b7209eceda722c256e..dae7827378498fa470ebb0df278741e4d1e00522 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt
@@ -4,6 +4,10 @@ import dagger.Module
 import dagger.android.ContributesAndroidInjector
 import de.rki.coronawarnapp.test.api.ui.TestForAPIFragment
 import de.rki.coronawarnapp.test.api.ui.TestForApiFragmentModule
+import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment
+import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragmentModule
+import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment
+import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragmentModule
 import de.rki.coronawarnapp.test.menu.ui.TestMenuFragment
 import de.rki.coronawarnapp.test.menu.ui.TestMenuFragmentModule
 import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment
@@ -25,4 +29,10 @@ abstract class MainActivityTestModule {
 
     @ContributesAndroidInjector(modules = [TestTaskControllerFragmentModule::class])
     abstract fun testTaskControllerFragment(): TestTaskControllerFragment
+
+    @ContributesAndroidInjector(modules = [AppConfigTestFragmentModule::class])
+    abstract fun appConfigTestFragment(): AppConfigTestFragment
+
+    @ContributesAndroidInjector(modules = [DebugOptionsFragmentModule::class])
+    abstract fun debugOptions(): DebugOptionsFragment
 }
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_crashreporter_overview.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_crashreporter_overview.xml
new file mode 100644
index 0000000000000000000000000000000000000000..31bd7395a73e64aa03d8909449f5adf2d9ae243a
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_crashreporter_overview.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context=".ui.main.MainActivity">
+
+    <androidx.appcompat.widget.Toolbar
+        android:id="@+id/toolbarErrorReport"
+        style="@style/Widget.AppCompat.Toolbar"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:navigationIcon="@drawable/ic_bug"
+        app:subtitle="For testers and developers"
+        app:title="Error Report" />
+
+    <Button
+        android:id="@+id/buttonClearCrashReportList"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginStart="8dp"
+        android:text="Clear List"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintHorizontal_chainStyle="packed"
+        app:layout_constraintStart_toEndOf="@+id/buttonTestItemForCrashReport"
+        app:layout_constraintTop_toBottomOf="@+id/toolbarErrorReport"
+        app:layout_constraintVertical_chainStyle="packed" />
+
+    <Button
+        android:id="@+id/buttonTestItemForCrashReport"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_marginEnd="1dp"
+        android:text="Test Item (+1)"
+        app:layout_constraintEnd_toStartOf="@+id/buttonClearCrashReportList"
+        app:layout_constraintHorizontal_chainStyle="packed"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/toolbarErrorReport"
+        app:layout_constraintVertical_chainStyle="packed" />
+
+    <TextView
+        android:id="@+id/topinfo"
+        style="@style/TextAppearance.AppCompat.Caption"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/colorCalendarLayoutFocusOn"
+        android:gravity="center"
+        android:padding="8dp"
+        android:text="Note: Select a card to view and share details."
+        android:textColor="@color/colorTextSixteenWhite"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/buttonTestItemForCrashReport" />
+
+    <androidx.recyclerview.widget.RecyclerView
+        android:id="@+id/list"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:background="@color/colorCalendarLayoutFocusOn"
+        android:paddingBottom="8dp"
+        app:layoutManager="LinearLayoutManager"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintEnd_toEndOf="parent"
+        app:layout_constraintStart_toStartOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/topinfo"
+        tools:context="SettingsCrashReporterFragment"
+        tools:listitem="@layout/view_crashreport_list_item" />
+
+
+</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_settings_crash_report_details.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_settings_crash_report_details.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d5d49dc2094f7bf586f72876918e04b9802a9f50
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_settings_crash_report_details.xml
@@ -0,0 +1,50 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <data>
+        <variable
+            name="selectedCrashReportFormattedText"
+            type="String" />
+    </data>
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        tools:context=".test.crash.ui.SettingsCrashReportDetailsFragment">
+
+
+        <ScrollView
+            android:layout_width="match_parent"
+            android:layout_height="0dp"
+            android:layout_margin="5dp"
+            android:scrollbars="vertical"
+            app:layout_constraintBottom_toTopOf="@+id/buttonCrashReportShare"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent">
+
+            <TextView
+                android:id="@+id/textViewCrashReportDetails"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="@{selectedCrashReportFormattedText}" />
+
+        </ScrollView>
+
+        <Button
+            android:id="@+id/buttonCrashReportShare"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="10dp"
+            android:text="Share"
+            android:background="@color/colorCalendarLayoutFocusOn"
+            android:visibility="invisible"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent" />
+
+    </androidx.constraintlayout.widget.ConstraintLayout>
+</layout>
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml
new file mode 100644
index 0000000000000000000000000000000000000000..fb4d95036ce1e7f5e7e101beead64984209dc373
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml
@@ -0,0 +1,93 @@
+<?xml version="1.0" encoding="utf-8"?>
+
+<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:ignore="HardcodedText">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_margin="8dp"
+        android:orientation="vertical"
+        android:paddingBottom="32dp">
+
+        <LinearLayout
+            style="@style/card"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/spacing_tiny"
+            android:layout_marginStart="8dp"
+            android:layout_marginEnd="8dp"
+            android:orientation="vertical">
+
+            <TextView
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Last update" />
+
+            <TextView
+                android:id="@+id/last_update"
+                style="@style/body1"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Last update: ??" />
+
+            <TextView
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/spacing_tiny"
+                android:text="Time offset to server" />
+
+            <TextView
+                android:id="@+id/time_offset"
+                style="@style/body1"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:text="Last update: ??" />
+
+            <LinearLayout
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/spacing_tiny"
+                android:orientation="horizontal">
+
+                <Button
+                    android:id="@+id/delete_action"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginEnd="@dimen/spacing_tiny"
+                    android:layout_weight="1"
+                    android:text="Delete" />
+
+                <Button
+                    android:id="@+id/download_action"
+                    android:layout_width="match_parent"
+                    android:layout_marginStart="@dimen/spacing_tiny"
+                    android:layout_height="wrap_content"
+                    android:layout_weight="1"
+                    android:text="Download" />
+            </LinearLayout>
+        </LinearLayout>
+
+        <LinearLayout
+            style="@style/card"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/spacing_tiny"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/current_configuration"
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                tools:text="@string/lorem_ipsum" />
+
+        </LinearLayout>
+
+    </LinearLayout>
+</androidx.core.widget.NestedScrollView>
\ No newline at end of file
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml
new file mode 100644
index 0000000000000000000000000000000000000000..628201557567cccfde46d6928854209b410860e2
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml
@@ -0,0 +1,146 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    tools:ignore="HardcodedText">
+
+    <androidx.core.widget.NestedScrollView
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:fillViewport="true">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/spacing_tiny"
+            android:orientation="vertical">
+
+            <androidx.constraintlayout.widget.ConstraintLayout
+                android:id="@+id/debug_container"
+                style="@style/card"
+                android:layout_margin="@dimen/spacing_tiny"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content">
+
+                <TextView
+                    android:id="@+id/debug_container_title"
+                    style="@style/headline6"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:text="Debug options"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toTopOf="parent" />
+
+                <Switch
+                    android:id="@+id/hourly_key_pkg_mode"
+                    style="@style/body1"
+                    android:layout_width="match_parent"
+                    android:layout_height="0dp"
+                    android:layout_marginTop="@dimen/spacing_small"
+                    android:text="Hourly keyfile mode (last 24)"
+                    android:theme="@style/switchBase"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/debug_container_title" />
+
+                <Switch
+                    android:id="@+id/background_notifications_toggle"
+                    style="@style/body1"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    android:text="@string/test_api_switch_background_notifications"
+                    android:theme="@style/switchBase"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/hourly_key_pkg_mode" />
+
+                <Switch
+                    android:id="@+id/test_logfile_toggle"
+                    style="@style/body1"
+                    android:layout_width="0dp"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    android:layout_weight="1"
+                    android:text="Logfile enabled"
+                    android:theme="@style/switchBase"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/background_notifications_toggle" />
+
+                <Button
+                    android:id="@+id/test_logfile_share"
+                    android:layout_width="wrap_content"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    android:text="Share log"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/test_logfile_toggle" />
+            </androidx.constraintlayout.widget.ConstraintLayout>
+
+            <androidx.constraintlayout.widget.ConstraintLayout
+                android:id="@+id/environment_container"
+                style="@style/card"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_margin="@dimen/spacing_tiny">
+
+                <TextView
+                    android:id="@+id/environment_title"
+                    style="@style/headline6"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:text="Server environment"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toTopOf="parent" />
+
+                <TextView
+                    android:id="@+id/environment_cdnurl_download"
+                    style="@style/body2"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/environment_title"
+                    tools:text="Download: ?" />
+
+                <TextView
+                    android:id="@+id/environment_cdnurl_submission"
+                    style="@style/body2"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_download"
+                    tools:text="Submission: ?" />
+
+                <TextView
+                    android:id="@+id/environment_cdnurl_verification"
+                    style="@style/body2"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_submission"
+                    tools:text="Verification: ?" />
+
+                <RadioGroup
+                    android:id="@+id/environment_toggle_group"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
+                    android:layout_marginTop="@dimen/spacing_tiny"
+                    android:orientation="vertical"
+                    app:layout_constraintBottom_toBottomOf="parent"
+                    app:layout_constraintEnd_toEndOf="parent"
+                    app:layout_constraintStart_toStartOf="parent"
+                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_verification" />
+            </androidx.constraintlayout.widget.ConstraintLayout>
+
+        </LinearLayout>
+    </androidx.core.widget.NestedScrollView>
+</layout>
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml
index 767e5f68d4038a3f1fde9349218f8f834b6c482b..a72eafd72f6db23a094411021dbf8dd578e9cfc2 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml
@@ -15,132 +15,6 @@
             android:layout_margin="@dimen/spacing_tiny"
             android:orientation="vertical">
 
-            <androidx.constraintlayout.widget.ConstraintLayout
-                android:id="@+id/debug_container"
-                style="@style/card"
-                android:layout_margin="@dimen/spacing_tiny"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content">
-
-                <TextView
-                    android:id="@+id/debug_container_title"
-                    style="@style/headline6"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:text="Debug options"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toTopOf="parent" />
-
-                <Switch
-                    android:id="@+id/hourly_key_pkg_mode"
-                    style="@style/body1"
-                    android:layout_width="match_parent"
-                    android:layout_height="0dp"
-                    android:layout_marginTop="@dimen/spacing_small"
-                    android:text="Hourly keyfile mode (last 24)"
-                    android:theme="@style/switchBase"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/debug_container_title" />
-
-                <Switch
-                    android:id="@+id/background_notifications_toggle"
-                    style="@style/body1"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    android:text="@string/test_api_switch_background_notifications"
-                    android:theme="@style/switchBase"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/hourly_key_pkg_mode" />
-
-                <Switch
-                    android:id="@+id/test_logfile_toggle"
-                    style="@style/body1"
-                    android:layout_width="0dp"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    android:layout_weight="1"
-                    android:text="Logfile enabled"
-                    android:theme="@style/switchBase"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/background_notifications_toggle" />
-
-                <Button
-                    android:id="@+id/test_logfile_share"
-                    android:layout_width="wrap_content"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    android:text="Share log"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/test_logfile_toggle" />
-            </androidx.constraintlayout.widget.ConstraintLayout>
-
-            <androidx.constraintlayout.widget.ConstraintLayout
-                android:id="@+id/environment_container"
-                style="@style/card"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_margin="@dimen/spacing_tiny">
-
-                <TextView
-                    android:id="@+id/environment_title"
-                    style="@style/headline6"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:text="Server environment"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toTopOf="parent" />
-
-                <TextView
-                    android:id="@+id/environment_cdnurl_download"
-                    style="@style/body2"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/environment_title"
-                    tools:text="Download: ?" />
-
-                <TextView
-                    android:id="@+id/environment_cdnurl_submission"
-                    style="@style/body2"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_download"
-                    tools:text="Submission: ?" />
-
-                <TextView
-                    android:id="@+id/environment_cdnurl_verification"
-                    style="@style/body2"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_submission"
-                    tools:text="Verification: ?" />
-
-                <RadioGroup
-                    android:id="@+id/environment_toggle_group"
-                    android:layout_width="match_parent"
-                    android:layout_height="wrap_content"
-                    android:layout_marginTop="@dimen/spacing_tiny"
-                    android:orientation="vertical"
-                    app:layout_constraintBottom_toBottomOf="parent"
-                    app:layout_constraintEnd_toEndOf="parent"
-                    app:layout_constraintStart_toStartOf="parent"
-                    app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_verification" />
-            </androidx.constraintlayout.widget.ConstraintLayout>
-
             <androidx.constraintlayout.widget.ConstraintLayout
                 android:id="@+id/gms_container"
                 style="@style/card"
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml
index 32e387a62f941248a3155341818bf9ad30c82d6c..807e881a75173a9c0229d1a547d2b588ec679a5c 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml
@@ -10,16 +10,17 @@
         android:layout_width="match_parent"
         android:layout_height="wrap_content"
         app:layout_constraintEnd_toEndOf="parent"
-        app:navigationIcon="@drawable/ic_coffee"
         app:layout_constraintStart_toStartOf="parent"
-        app:subtitle="For testers ;)"
         app:layout_constraintTop_toTopOf="parent"
+        app:navigationIcon="@drawable/ic_coffee"
+        app:subtitle="For testers &amp; QA &lt;3"
         app:title="Test Menu" />
 
     <androidx.recyclerview.widget.RecyclerView
         android:id="@+id/test_menu_list"
         android:layout_width="match_parent"
         android:layout_height="0dp"
+        android:layout_marginTop="12dp"
         app:layout_constraintBottom_toBottomOf="parent"
         app:layout_constraintEnd_toEndOf="parent"
         app:layout_constraintStart_toStartOf="parent"
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
index 06f6d5af7843901137e191926078cad0a1066b5a..9e29d88d479a233ef358cbe19976dfd67bd4798f 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
@@ -191,21 +191,6 @@
                 android:layout_height="wrap_content"
                 android:text="-" />
 
-            <TextView
-                android:id="@+id/label_full_config_title"
-                style="@style/headline6"
-                android:accessibilityHeading="true"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:text="Full Backend Configuration" />
-
-            <TextView
-                android:id="@+id/label_full_config"
-                android:layout_width="match_parent"
-                android:layout_height="wrap_content"
-                android:layout_marginBottom="@dimen/spacing_normal"
-                android:text="-" />
-
         </LinearLayout>
     </ScrollView>
 </layout>
\ No newline at end of file
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/view_crashreport_list_item.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/view_crashreport_list_item.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d820d54e752ee1ab99c20c29f5fcf07054638567
--- /dev/null
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/view_crashreport_list_item.xml
@@ -0,0 +1,74 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <data>
+
+        <variable
+            name="crashReportTitle"
+            type="String" />
+
+        <variable
+            name="crashReportDateFormatted"
+            type="String" />
+
+        <variable
+            name="message"
+            type="String" />
+    </data>
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_margin="8dp"
+        android:orientation="vertical"
+        android:paddingBottom="2dp">
+
+        <androidx.constraintlayout.widget.ConstraintLayout
+            style="@style/card"
+            android:layout_width="390dp"
+            android:layout_height="wrap_content"
+            android:layout_margin="@dimen/spacing_tiny"
+            android:orientation="vertical">
+
+            <TextView
+                android:id="@+id/textViewCrashReportTitle"
+                style="@style/body1"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:text="@{crashReportTitle}"
+                android:textStyle="bold"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toTopOf="parent" />
+
+            <TextView
+                android:id="@+id/textViewCrashReportDate"
+                style="@style/body1"
+                android:layout_width="@dimen/match_constraint"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="4dp"
+                android:text="@{crashReportDateFormatted}"
+                android:textAppearance="?attr/textAppearanceListItem"
+                android:textStyle="italic"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@+id/textViewCrashReportTitle" />
+
+            <TextView
+                android:id="@+id/textViewCrashReportShortMessage"
+                style="@style/body1"
+                android:layout_width="0dp"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="24dp"
+                android:ellipsize="end"
+                android:inputType="none"
+                android:maxLines="1"
+                android:text="@{message}"
+                app:layout_constraintEnd_toEndOf="parent"
+                app:layout_constraintStart_toStartOf="parent"
+                app:layout_constraintTop_toBottomOf="@+id/textViewCrashReportDate" />
+
+        </androidx.constraintlayout.widget.ConstraintLayout>
+    </LinearLayout>
+</layout>
\ No newline at end of file
diff --git a/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml b/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml
index b86da2f4908b0bd7958f4fb0091f1c46adafcaf0..1c53c77729006536c92d18efecd0b547370bb3cf 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml
@@ -8,7 +8,11 @@
     <fragment
         android:id="@+id/test_menu_fragment"
         android:name="de.rki.coronawarnapp.test.menu.ui.TestMenuFragment"
-        android:label="TestMenuFragment">
+        android:label="TestMenuFragment"
+        tools:layout="@layout/fragment_test_menu">
+        <action
+            android:id="@+id/action_testMenuFragment_to_settingsCrashReportFragment"
+            app:destination="@id/test_bug_report_fragment" />
         <action
             android:id="@+id/action_testMenuFragment_to_testForAPIFragment"
             app:destination="@id/test_for_api_fragment" />
@@ -18,6 +22,12 @@
         <action
             android:id="@+id/action_test_menu_fragment_to_testTaskControllerFragment"
             app:destination="@id/test_taskcontroller_fragment" />
+        <action
+            android:id="@+id/action_test_menu_fragment_to_appConfigTestFragment"
+            app:destination="@id/test_appconfig_fragment" />
+        <action
+            android:id="@+id/action_test_menu_fragment_to_debugOptionsFragment"
+            app:destination="@id/test_debugoptions_fragment" />
     </fragment>
 
     <fragment
@@ -26,6 +36,22 @@
         android:label="@layout/fragment_test_for_a_p_i"
         tools:layout="@layout/fragment_test_for_a_p_i" />
 
+    <fragment
+        android:id="@+id/test_bug_report_fragment"
+        android:name="de.rki.coronawarnapp.test.crash.ui.SettingsCrashReportFragment"
+        android:label="crashreporter"
+        tools:layout="@layout/fragment_crashreporter_overview">
+        <action
+            android:id="@+id/action_crashReportFragment_to_settingsCrashReportDetailsFragment"
+            app:destination="@id/settingsCrashReportDetailsFragment" />
+    </fragment>
+
+    <fragment
+        android:id="@+id/settingsCrashReportDetailsFragment"
+        android:name="de.rki.coronawarnapp.test.crash.ui.SettingsCrashReportDetailsFragment"
+        android:label="fragment_settings_crash_report_details"
+        tools:layout="@layout/fragment_settings_crash_report_details" />
+
     <fragment
         android:id="@+id/test_risklevel_calculation_fragment"
         android:name="de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment"
@@ -33,15 +59,24 @@
         tools:layout="@layout/fragment_test_risk_level_calculation">
         <argument
             android:name="exampleArgument"
-            app:argType="string"
             android:defaultValue="null"
+            app:argType="string"
             app:nullable="true" />
     </fragment>
     <fragment
         android:id="@+id/test_taskcontroller_fragment"
         android:name="de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment"
-
-        tools:layout="@layout/fragment_test_task_controller"
-        android:label="TestTaskControllerFragment" />
+        android:label="TestTaskControllerFragment"
+        tools:layout="@layout/fragment_test_task_controller" />
+    <fragment
+        android:id="@+id/test_appconfig_fragment"
+        android:name="de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment"
+        android:label="AppConfigTestFragment"
+        tools:layout="@layout/fragment_test_appconfig" />
+    <fragment
+        android:id="@+id/test_debugoptions_fragment"
+        android:name="de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment"
+        android:label="DebugOptionsFragment"
+        tools:layout="@layout/fragment_test_debugoptions" />
 
 </navigation>
diff --git a/Corona-Warn-App/src/main/AndroidManifest.xml b/Corona-Warn-App/src/main/AndroidManifest.xml
index 543b20f88642feaadf4cdf0d9ed89052e824a96e..c4906d42e94beacd8d93e1454cc46327332be173 100644
--- a/Corona-Warn-App/src/main/AndroidManifest.xml
+++ b/Corona-Warn-App/src/main/AndroidManifest.xml
@@ -28,6 +28,7 @@
         android:allowBackup="false"
         android:icon="@mipmap/ic_launcher"
         android:label="@string/app_name"
+        android:extractNativeLibs="@bool/extract_native_libs"
         android:networkSecurityConfig="@xml/network_security_config"
         android:roundIcon="@mipmap/ic_launcher_round"
         android:supportsRtl="true"
diff --git a/Corona-Warn-App/src/main/assets/terms_de.html b/Corona-Warn-App/src/main/assets/terms_de.html
index 03458bc785114d5a938ab96335998898be4184d4..49dfc65a29b23d2ce05571516b824266dfe0b9bd 100644
--- a/Corona-Warn-App/src/main/assets/terms_de.html
+++ b/Corona-Warn-App/src/main/assets/terms_de.html
@@ -477,7 +477,7 @@
     Beendigung der App müssen Sie auch die App neu starten.
 </p>
 <p>
-    Einstellungen im Smartphone
+    Einstellungen auf dem Smartphone
 </p>
 <p>
     Für die Nutzung der App müssen Sie ferner die Bluetooth (BLE)-Funktionen
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 922c0ab30e5db8e345fe228a722d3b54ff73a9af..473252103438e393733130f36590eb7841fb2b36 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
@@ -7,11 +7,10 @@ import android.content.IntentFilter
 import android.os.Bundle
 import android.view.WindowManager
 import androidx.localbroadcastmanager.content.LocalBroadcastManager
-import androidx.work.Configuration
-import androidx.work.WorkManager
 import dagger.android.AndroidInjector
 import dagger.android.DispatchingAndroidInjector
 import dagger.android.HasAndroidInjector
+import de.rki.coronawarnapp.bugreporting.loghistory.LogHistoryTree
 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
@@ -21,6 +20,7 @@ import de.rki.coronawarnapp.util.ForegroundState
 import de.rki.coronawarnapp.util.WatchdogService
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.ApplicationComponent
+import de.rki.coronawarnapp.util.worker.WorkManagerSetup
 import de.rki.coronawarnapp.worker.BackgroundWorkHelper
 import kotlinx.coroutines.GlobalScope
 import kotlinx.coroutines.flow.launchIn
@@ -35,11 +35,14 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
     @Inject lateinit var component: ApplicationComponent
 
     @Inject lateinit var androidInjector: DispatchingAndroidInjector<Any>
+
     override fun androidInjector(): AndroidInjector<Any> = androidInjector
 
     @Inject lateinit var watchdogService: WatchdogService
     @Inject lateinit var taskController: TaskController
     @Inject lateinit var foregroundState: ForegroundState
+    @Inject lateinit var workManagerSetup: WorkManagerSetup
+    @LogHistoryTree @Inject lateinit var rollingLogHistory: Timber.Tree
 
     override fun onCreate() {
         instance = this
@@ -49,10 +52,10 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
         Timber.v("onCreate(): Initializing Dagger")
         AppInjector.init(this)
 
+        Timber.plant(rollingLogHistory)
+
         Timber.v("onCreate(): Initializing WorkManager")
-        Configuration.Builder()
-            .apply { setMinimumLoggingLevel(android.util.Log.DEBUG) }.build()
-            .let { WorkManager.initialize(this, it) }
+        workManagerSetup.setup()
 
         NotificationHelper.createNotificationChannel()
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt
index 9216419bb2197d6e3736d33fe5da863ded200dbf..82936e52b38862e1288652fe250952d4e5669375 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt
@@ -3,6 +3,12 @@ package de.rki.coronawarnapp.appconfig
 import android.content.Context
 import dagger.Module
 import dagger.Provides
+import de.rki.coronawarnapp.appconfig.download.AppConfigApiV1
+import de.rki.coronawarnapp.appconfig.download.AppConfigHttpCache
+import de.rki.coronawarnapp.appconfig.mapping.CWAConfigMapper
+import de.rki.coronawarnapp.appconfig.mapping.DownloadConfigMapper
+import de.rki.coronawarnapp.appconfig.mapping.ExposureDetectionConfigMapper
+import de.rki.coronawarnapp.appconfig.mapping.RiskCalculationConfigMapper
 import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient
 import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl
 import de.rki.coronawarnapp.util.di.AppContext
@@ -54,6 +60,19 @@ class AppConfigModule {
             .create(AppConfigApiV1::class.java)
     }
 
+    @Provides
+    fun cwaMapper(mapper: CWAConfigMapper): CWAConfig.Mapper = mapper
+
+    @Provides
+    fun downloadMapper(mapper: DownloadConfigMapper): KeyDownloadConfig.Mapper = mapper
+
+    @Provides
+    fun exposurMapper(mapper: ExposureDetectionConfigMapper): ExposureDetectionConfig.Mapper =
+        mapper
+
+    @Provides
+    fun riskMapper(mapper: RiskCalculationConfigMapper): RiskCalculationConfig.Mapper = mapper
+
     companion object {
         private val HTTP_TIMEOUT_APPCONFIG = Duration.standardSeconds(10)
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
index 4cbedfc9303397c2436237a51cdd51f4a515c854..91866abb5c8d0d302038a0ffce536ab629246140 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt
@@ -1,149 +1,45 @@
 package de.rki.coronawarnapp.appconfig
 
-import androidx.annotation.VisibleForTesting
-import dagger.Lazy
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
-import de.rki.coronawarnapp.util.ZipHelper.unzip
-import de.rki.coronawarnapp.util.security.VerificationKeys
-import kotlinx.coroutines.Dispatchers
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withContext
-import okhttp3.Cache
 import timber.log.Timber
 import javax.inject.Inject
 import javax.inject.Singleton
 
 @Singleton
 class AppConfigProvider @Inject constructor(
-    private val appConfigAPI: Lazy<AppConfigApiV1>,
-    private val verificationKeys: VerificationKeys,
-    @DownloadCDNHomeCountry private val homeCountry: LocationCode,
-    private val configStorage: AppConfigStorage,
-    @AppConfigHttpCache private val cache: Cache
+    private val source: AppConfigSource,
+    private val dispatcherProvider: DispatcherProvider,
+    @AppScope private val scope: CoroutineScope
 ) {
 
     private val mutex = Mutex()
-    private val configApi: AppConfigApiV1
-        get() = appConfigAPI.get()
+    private val currentConfigInternal = MutableStateFlow<ConfigData?>(null)
 
-    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
-    internal suspend fun downloadAppConfig(): ByteArray? {
-        Timber.tag(TAG).d("Fetching app config.")
-        var exportBinary: ByteArray? = null
-        var exportSignature: ByteArray? = null
-        configApi.getApplicationConfiguration(homeCountry.identifier).byteStream()
-            .unzip { entry, entryContent ->
-                if (entry.name == EXPORT_BINARY_FILE_NAME) exportBinary =
-                    entryContent.copyOf()
-                if (entry.name == EXPORT_SIGNATURE_FILE_NAME) exportSignature =
-                    entryContent.copyOf()
-            }
-        if (exportBinary == null || exportSignature == null) {
-            throw ApplicationConfigurationInvalidException()
-        }
-
-        if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) {
-            throw ApplicationConfigurationCorruptException()
-        }
-
-        return exportBinary!!
-    }
-
-    private fun tryParseConfig(byteArray: ByteArray?): ApplicationConfiguration? {
-        Timber.v("Parsing config (size=%dB)", byteArray?.size)
-        if (byteArray == null) return null
-        return ApplicationConfiguration.parseFrom(byteArray)
-    }
-
-    private suspend fun getNewAppConfig(): ApplicationConfiguration? {
-        val newConfigRaw = try {
-            downloadAppConfig()
-        } catch (e: Exception) {
-            Timber.w(e, "Failed to download latest AppConfig.")
-            if (configStorage.isAppConfigAvailable()) {
-                null
-            } else {
-                Timber.e("No fallback available, rethrowing!")
-                throw e
-            }
-        }
-
-        val newConfigParsed = try {
-            tryParseConfig(newConfigRaw)
-        } catch (e: Exception) {
-            Timber.w(e, "Failed to parse latest AppConfig.")
-            null
-        }
-
-        return newConfigParsed?.also {
-            Timber.d("Saving new valid config.")
-            Timber.v("New Config.supportedCountries: %s", it.supportedCountriesList)
-            configStorage.setAppConfigRaw(newConfigRaw)
-        }
-    }
-
-    private suspend fun getFallback(): ApplicationConfiguration {
-        val lastValidConfig = tryParseConfig(configStorage.getAppConfigRaw())
-        return if (lastValidConfig != null) {
-            Timber.d("Using fallback AppConfig.")
-            lastValidConfig
-        } else {
-            Timber.e("No valid fallback AppConfig available.")
-            throw ApplicationConfigurationInvalidException()
-        }
-    }
-
-    suspend fun getAppConfig(): ApplicationConfiguration = mutex.withLock {
-        withContext(Dispatchers.IO) {
-
-            val newAppConfig = getNewAppConfig()
-
-            return@withContext if (newAppConfig != null) {
-                newAppConfig
-            } else {
-                Timber.w("No new config available, using last valid.")
-                getFallback()
-            }
-        }.performSanityChecks()
-    }
+    val currentConfig: Flow<ConfigData?> = currentConfigInternal
 
     suspend fun clear() = mutex.withLock {
-        withContext(Dispatchers.IO) {
-            configStorage.setAppConfigRaw(null)
-
-            // We are using Dispatchers IO to make it appropriate
-            @Suppress("BlockingMethodInNonBlockingContext")
-            cache.evictAll()
-        }
+        Timber.tag(TAG).v("clear()")
+        source.clear()
+        currentConfigInternal.value = null
     }
 
-    private fun ApplicationConfiguration.performSanityChecks(): ApplicationConfiguration {
-        var sanityChecked = this
-
-        if (sanityChecked.supportedCountriesList == null) {
-            sanityChecked = sanityChecked.toNewConfig {
-                clearSupportedCountries()
-                addAllSupportedCountries(emptyList<String>())
-            }
-        }
-
-        val countryCheck = sanityChecked.supportedCountriesList
-        if (countryCheck.size == 1 && !VALID_CC.matches(countryCheck.single())) {
-            Timber.w("Invalid country data, clearing. (%s)", this.supportedCountriesList)
-            sanityChecked = sanityChecked.toNewConfig {
-                clearSupportedCountries()
+    suspend fun getAppConfig(): ConfigData = mutex.withLock {
+        Timber.tag(TAG).v("getAppConfig()")
+        withContext(context = scope.coroutineContext + dispatcherProvider.IO) {
+            source.retrieveConfig().also {
+                currentConfigInternal.emit(it)
             }
         }
-        return sanityChecked
     }
 
     companion object {
-        private val VALID_CC = "^([A-Z]{2,3})$".toRegex()
-        private const val EXPORT_BINARY_FILE_NAME = "export.bin"
-        private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig"
-        private val TAG = AppConfigProvider::class.java.simpleName
+        private const val TAG = "AppConfigProvider"
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fd31afca8ef1e6ce15beef7927dab09473d752ca
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt
@@ -0,0 +1,82 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.download.AppConfigServer
+import de.rki.coronawarnapp.appconfig.download.AppConfigStorage
+import de.rki.coronawarnapp.appconfig.download.ApplicationConfigurationInvalidException
+import de.rki.coronawarnapp.appconfig.mapping.ConfigParser
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.withContext
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AppConfigSource @Inject constructor(
+    private val server: AppConfigServer,
+    private val storage: AppConfigStorage,
+    private val parser: ConfigParser,
+    private val dispatcherProvider: DispatcherProvider
+) {
+
+    suspend fun retrieveConfig(): ConfigData = withContext(dispatcherProvider.IO) {
+        Timber.v("retrieveConfig()")
+        val (serverBytes, serverError) = try {
+            server.downloadAppConfig() to null
+        } catch (e: Exception) {
+            Timber.tag(TAG).w(e, "Failed to download AppConfig from server .")
+            null to e
+        }
+
+        var parsedConfig: ConfigData? = serverBytes?.let { configDownload ->
+            try {
+                parser.parse(configDownload.rawData).let {
+                    Timber.tag(TAG).d("Got a valid AppConfig from server, saving.")
+                    storage.setStoredConfig(configDownload)
+                    DefaultConfigData(
+                        mappedConfig = it,
+                        serverTime = configDownload.serverTime,
+                        localOffset = configDownload.localOffset,
+                        isFallback = false
+                    )
+                }
+            } catch (e: Exception) {
+                Timber.tag(TAG).e(e, "Failed to parse AppConfig from server, trying fallback.")
+                null
+            }
+        }
+
+        if (parsedConfig == null) {
+            parsedConfig = storage.getStoredConfig()?.let { storedDownloadConfig ->
+                try {
+                    storedDownloadConfig.let {
+                        DefaultConfigData(
+                            mappedConfig = parser.parse(it.rawData),
+                            serverTime = it.serverTime,
+                            localOffset = it.localOffset,
+                            isFallback = true
+                        )
+                    }
+                } catch (e: Exception) {
+                    Timber.tag(TAG).e(e, "Fallback config exists but could not be parsed!")
+                    throw e
+                }
+            }
+        }
+
+        if (parsedConfig == null) {
+            throw ApplicationConfigurationInvalidException(serverError)
+        }
+
+        return@withContext parsedConfig
+    }
+
+    suspend fun clear() {
+        storage.setStoredConfig(null)
+
+        server.clearCache()
+    }
+
+    companion object {
+        private const val TAG = "AppConfigRetriever"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt
deleted file mode 100644
index 54b6351b104cec7b0d98305dee5f7ba040f5572b..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-package de.rki.coronawarnapp.appconfig
-
-import android.content.Context
-import de.rki.coronawarnapp.util.di.AppContext
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import timber.log.Timber
-import java.io.File
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class AppConfigStorage @Inject constructor(
-    @AppContext context: Context
-) {
-    private val configDir = File(context.filesDir, "appconfig_storage")
-    private val configFile = File(configDir, "appconfig")
-    private val mutex = Mutex()
-
-    suspend fun isAppConfigAvailable(): Boolean = mutex.withLock {
-        configFile.exists() && configFile.length() > MIN_VALID_CONFIG_BYTES
-    }
-
-    suspend fun getAppConfigRaw(): ByteArray? = mutex.withLock {
-        Timber.v("get() AppConfig")
-        if (!configFile.exists()) return null
-
-        val value = configFile.readBytes()
-        Timber.v("Read AppConfig of size %s and date %s", value.size, configFile.lastModified())
-        return value
-    }
-
-    suspend fun setAppConfigRaw(value: ByteArray?): Unit = mutex.withLock {
-        Timber.v("set(...) AppConfig: %dB", value?.size)
-
-        if (configDir.mkdirs()) Timber.v("Parent folder created.")
-
-        if (configFile.exists()) {
-            Timber.v("Overwriting %d from %s", configFile.length(), configFile.lastModified())
-        }
-        if (value != null) {
-            configFile.writeBytes(value)
-        } else {
-            configFile.delete()
-        }
-    }
-
-    companion object {
-        // The normal config is ~512B+, we just need to check for a non 0 value, 128 is fine.
-        private const val MIN_VALID_CONFIG_BYTES = 128
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt
deleted file mode 100644
index 153435397fcff0fa237e32723413fd5aae3f9c30..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package de.rki.coronawarnapp.appconfig
-
-import de.rki.coronawarnapp.exception.reporting.ErrorCodes
-import de.rki.coronawarnapp.exception.reporting.ReportedException
-
-class ApplicationConfigurationInvalidException : ReportedException(
-    ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code, "the application configuration is invalid"
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0a11bf822114ca77b02a3bdd283c0a83b6dad69a
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
+import de.rki.coronawarnapp.server.protocols.internal.AppFeaturesOuterClass
+import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig
+
+interface CWAConfig {
+
+    val appVersion: AppVersionConfig.ApplicationVersionConfiguration
+
+    val supportedCountries: List<String>
+
+    val appFeatureus: AppFeaturesOuterClass.AppFeatures
+
+    interface Mapper : ConfigMapper<CWAConfig>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6845e3d29be4db488fb1fe29e5bfdd9102040672
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt
@@ -0,0 +1,24 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapping
+import org.joda.time.Duration
+import org.joda.time.Instant
+
+interface ConfigData : ConfigMapping {
+
+    /**
+     * serverTime + localOffset = updatedAt
+     */
+    val updatedAt: Instant
+
+    /**
+     * If **[isFallback]** returns true,
+     * you should probably ignore the time offset.
+     */
+    val localOffset: Duration
+
+    /**
+     * Returns true if this is not a fresh config, e.g. server could not be reached.
+     */
+    val isFallback: Boolean
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5fc918d095771bf9df33b81ec5cf591595ed46b5
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt
@@ -0,0 +1,14 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapping
+import org.joda.time.Duration
+import org.joda.time.Instant
+
+data class DefaultConfigData(
+    val serverTime: Instant,
+    val mappedConfig: ConfigMapping,
+    override val localOffset: Duration,
+    override val isFallback: Boolean
+) : ConfigData, ConfigMapping by mappedConfig {
+    override val updatedAt: Instant = serverTime.plus(localOffset)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5281c51ede2e8a7d12d55e56be4ccb92b8155031
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt
@@ -0,0 +1,13 @@
+package de.rki.coronawarnapp.appconfig
+
+import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
+import de.rki.coronawarnapp.server.protocols.internal.ExposureDetectionParameters
+
+interface ExposureDetectionConfig {
+
+    val exposureDetectionConfiguration: ExposureConfiguration
+    val exposureDetectionParameters: ExposureDetectionParameters.ExposureDetectionParametersAndroid
+
+    interface Mapper : ConfigMapper<ExposureDetectionConfig>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d82a6cd3a3ecc741986426e6de9013ee8a3626f6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
@@ -0,0 +1,11 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
+import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters
+
+interface KeyDownloadConfig {
+
+    val keyDownloadParameters: KeyDownloadParameters.KeyDownloadParametersAndroid
+
+    interface Mapper : ConfigMapper<KeyDownloadConfig>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2c19c0be637e843d0137d3db4983f1ccce8de97c
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper
+import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass
+import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass
+
+interface RiskCalculationConfig {
+
+    val minRiskScore: Int
+
+    val attenuationDuration: AttenuationDurationOuterClass.AttenuationDuration
+
+    val riskScoreClasses: RiskScoreClassificationOuterClass.RiskScoreClassification
+
+    interface Mapper : ConfigMapper<RiskCalculationConfig>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt
similarity index 71%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt
index ae8898f1aabacd8f602ffc16065bcefe7040fb6a..0c3f61077cc884adf45aa8a02a78e2ed5fe7154d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt
@@ -1,6 +1,7 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import okhttp3.ResponseBody
+import retrofit2.Response
 import retrofit2.http.GET
 import retrofit2.http.Path
 
@@ -9,5 +10,5 @@ interface AppConfigApiV1 {
     @GET("/version/v1/configuration/country/{country}/app_config")
     suspend fun getApplicationConfiguration(
         @Path("country") country: String
-    ): ResponseBody
+    ): Response<ResponseBody>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt
similarity index 74%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt
index a3aff4add4b1df85c9093fa97efe839f2f57a924..253ac97d3fbedc4a7c0c5429cad471f77cac0837 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import javax.inject.Qualifier
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1d707c26bb248982443582fdba7ed672c640fe1e
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt
@@ -0,0 +1,99 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import dagger.Lazy
+import dagger.Reusable
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.ZipHelper.readIntoMap
+import de.rki.coronawarnapp.util.ZipHelper.unzip
+import de.rki.coronawarnapp.util.security.VerificationKeys
+import okhttp3.Cache
+import org.joda.time.Duration
+import org.joda.time.Instant
+import org.joda.time.format.DateTimeFormat
+import retrofit2.HttpException
+import retrofit2.Response
+import timber.log.Timber
+import java.util.Locale
+import javax.inject.Inject
+
+@Reusable
+class AppConfigServer @Inject constructor(
+    private val api: Lazy<AppConfigApiV1>,
+    private val verificationKeys: VerificationKeys,
+    private val timeStamper: TimeStamper,
+    @DownloadCDNHomeCountry private val homeCountry: LocationCode,
+    @AppConfigHttpCache private val cache: Cache
+) {
+
+    internal suspend fun downloadAppConfig(): ConfigDownload {
+        Timber.tag(TAG).d("Fetching app config.")
+
+        val response = api.get().getApplicationConfiguration(homeCountry.identifier)
+        if (!response.isSuccessful) throw HttpException(response)
+
+        // If this is a cached response, we need the original timestamp to calculate the time offset
+        val localTime = response.getCacheTimestamp() ?: timeStamper.nowUTC
+
+        val rawConfig = with(
+            requireNotNull(response.body()) { "Response was successful but body was null" }
+        ) {
+            val fileMap = byteStream().unzip().readIntoMap()
+
+            val exportBinary = fileMap[EXPORT_BINARY_FILE_NAME]
+            val exportSignature = fileMap[EXPORT_SIGNATURE_FILE_NAME]
+
+            if (exportBinary == null || exportSignature == null) {
+                throw ApplicationConfigurationInvalidException(message = "Unknown files: ${fileMap.keys}")
+            }
+
+            if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) {
+                throw ApplicationConfigurationCorruptException()
+            }
+
+            exportBinary
+        }
+
+        val serverTime = response.getServerDate() ?: localTime
+        val offset = Duration(serverTime, localTime)
+        Timber.tag(TAG).v("Time offset was %dms", offset.millis)
+
+        return ConfigDownload(
+            rawData = rawConfig,
+            serverTime = serverTime,
+            localOffset = offset
+        )
+    }
+
+    private fun <T> Response<T>.getServerDate(): Instant? = try {
+        val rawDate = headers()["Date"] ?: throw IllegalArgumentException(
+            "Server date unavailable: ${headers()}"
+        )
+        Instant.parse(rawDate, DATE_FORMAT)
+    } catch (e: Exception) {
+        Timber.e("Failed to get server time.")
+        null
+    }
+
+    private fun <T> Response<T>.getCacheTimestamp(): Instant? {
+        val cacheResponse = raw().cacheResponse
+        return cacheResponse?.sentRequestAtMillis?.let {
+            Instant.ofEpochMilli(it)
+        }
+    }
+
+    internal fun clearCache() {
+        Timber.tag(TAG).v("clearCache()")
+        cache.evictAll()
+    }
+
+    companion object {
+        private const val EXPORT_BINARY_FILE_NAME = "export.bin"
+        private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig"
+        private val DATE_FORMAT = DateTimeFormat
+            .forPattern("EEE, dd MMM yyyy HH:mm:ss zzz")
+            .withLocale(Locale.ROOT)
+        private val TAG = AppConfigServer::class.java.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c5a86a92887ab5c26dd0afe1dd54ee4220186e6f
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt
@@ -0,0 +1,87 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import android.content.Context
+import com.google.gson.Gson
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.serialization.BaseGson
+import de.rki.coronawarnapp.util.serialization.adapter.DurationAdapter
+import de.rki.coronawarnapp.util.serialization.adapter.InstantAdapter
+import de.rki.coronawarnapp.util.serialization.fromJson
+import de.rki.coronawarnapp.util.serialization.toJson
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.joda.time.Duration
+import org.joda.time.Instant
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AppConfigStorage @Inject constructor(
+    @AppContext context: Context,
+    private val timeStamper: TimeStamper,
+    @BaseGson private val baseGson: Gson
+) {
+
+    private val gson by lazy {
+        baseGson.newBuilder()
+            .registerTypeAdapter(Instant::class.java, InstantAdapter())
+            .registerTypeAdapter(Duration::class.java, DurationAdapter())
+            .create()
+    }
+    private val configDir = File(context.filesDir, "appconfig_storage")
+
+    // This is just the raw protobuf data
+    private val legacyConfigFile = File(configDir, "appconfig")
+    private val configFile = File(configDir, "appconfig.json")
+    private val mutex = Mutex()
+
+    suspend fun getStoredConfig(): ConfigDownload? = mutex.withLock {
+        Timber.v("get() AppConfig")
+
+        if (!configFile.exists() && legacyConfigFile.exists()) {
+            Timber.i("Returning legacy config.")
+            return@withLock try {
+                ConfigDownload(
+                    rawData = legacyConfigFile.readBytes(),
+                    serverTime = timeStamper.nowUTC,
+                    localOffset = Duration.ZERO
+                )
+            } catch (e: Exception) {
+                Timber.e(e, "Legacy config exits but couldn't be read.")
+                null
+            }
+        }
+
+        return@withLock try {
+            gson.fromJson<ConfigDownload>(configFile)
+        } catch (e: Exception) {
+            Timber.e(e, "Couldn't load config.")
+            null
+        }
+    }
+
+    suspend fun setStoredConfig(value: ConfigDownload?): Unit = mutex.withLock {
+        Timber.v("set(...) AppConfig: %s", value)
+
+        if (configDir.mkdirs()) Timber.v("Parent folder created.")
+
+        if (configFile.exists()) {
+            Timber.v("Overwriting %d from %s", configFile.length(), configFile.lastModified())
+        }
+
+        if (value != null) {
+            gson.toJson(value, configFile)
+
+            if (legacyConfigFile.exists()) {
+                if (legacyConfigFile.delete()) {
+                    Timber.i("Legacy config file deleted, superseeded.")
+                }
+            }
+        } else {
+            configFile.delete()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt
similarity index 86%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt
index 51c5dfb8933f457956bf161cd29acc5defef8c94..bd6940034f8af15de4d110ab65ece6eb9a34cd94 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import de.rki.coronawarnapp.exception.reporting.ErrorCodes
 import de.rki.coronawarnapp.exception.reporting.ReportedException
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt
new file mode 100644
index 0000000000000000000000000000000000000000..63cb1069e92febfd84f5a894a8d9dbf3c26ea618
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt
@@ -0,0 +1,13 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import de.rki.coronawarnapp.exception.reporting.ErrorCodes
+import de.rki.coronawarnapp.exception.reporting.ReportedException
+
+class ApplicationConfigurationInvalidException(
+    cause: Exception? = null,
+    message: String? = null
+) : ReportedException(
+    code = ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code,
+    message = message,
+    cause = cause
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1f9ba9050b61f2a5d802e2f5d692cdeba3881369
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt
@@ -0,0 +1,31 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import com.google.gson.annotations.SerializedName
+import org.joda.time.Duration
+import org.joda.time.Instant
+
+data class ConfigDownload(
+    @SerializedName("rawData") val rawData: ByteArray,
+    @SerializedName("serverTime") val serverTime: Instant,
+    @SerializedName("localOffset") val localOffset: Duration
+) {
+    override fun equals(other: Any?): Boolean {
+        if (this === other) return true
+        if (javaClass != other?.javaClass) return false
+
+        other as ConfigDownload
+
+        if (!rawData.contentEquals(other.rawData)) return false
+        if (serverTime != other.serverTime) return false
+        if (localOffset != other.localOffset) return false
+
+        return true
+    }
+
+    override fun hashCode(): Int {
+        var result = rawData.contentHashCode()
+        result = 31 * result + serverTime.hashCode()
+        result = 31 * result + localOffset.hashCode()
+        return result
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt
similarity index 86%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt
index 9ac63ab9afff3274371dfeb60d95bd4ffb3fd5d9..9177f5ae96a596077080a79bb5a581f9a8a112cf 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.mapping
 
 import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8d78dddcdb0e87acb6e06cb32e7f3405e426a2b1
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt
@@ -0,0 +1,42 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import androidx.annotation.VisibleForTesting
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppFeaturesOuterClass
+import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class CWAConfigMapper @Inject constructor() : CWAConfig.Mapper {
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): CWAConfig {
+        return CWAConfigContainer(
+            appVersion = rawConfig.appVersion,
+            supportedCountries = rawConfig.getMappedSupportedCountries(),
+            appFeatureus = rawConfig.appFeatures
+        )
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal fun AppConfig.ApplicationConfiguration.getMappedSupportedCountries(): List<String> =
+        when {
+            supportedCountriesList == null -> emptyList()
+            supportedCountriesList.size == 1 && !VALID_CC.matches(supportedCountriesList.single()) -> {
+                Timber.w("Invalid country data, clearing. (%s)", supportedCountriesList)
+                emptyList()
+            }
+            else -> supportedCountriesList
+        }
+
+    data class CWAConfigContainer(
+        override val appVersion: AppVersionConfig.ApplicationVersionConfiguration,
+        override val supportedCountries: List<String>,
+        override val appFeatureus: AppFeaturesOuterClass.AppFeatures
+    ) : CWAConfig
+
+    companion object {
+        private val VALID_CC = "^([A-Z]{2,3})$".toRegex()
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..58c4b88b2f7beaff9765715e7c1ed54a4916f199
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt
@@ -0,0 +1,7 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+
+interface ConfigMapper<T> {
+    fun map(rawConfig: AppConfig.ApplicationConfiguration): T
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9858ec812bfc0b74c37330b2d44704decd085a5d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt
@@ -0,0 +1,17 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+
+interface ConfigMapping :
+    CWAConfig,
+    KeyDownloadConfig,
+    ExposureDetectionConfig,
+    RiskCalculationConfig {
+
+    @Deprecated("Try to access a more specific config type, avoid the RAW variant.")
+    val rawConfig: AppConfig.ApplicationConfiguration
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8449b81b7cf3c5e996dc8121f97de585bc0ef693
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt
@@ -0,0 +1,39 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class ConfigParser @Inject constructor(
+    private val cwaConfigMapper: CWAConfig.Mapper,
+    private val keyDownloadConfigMapper: KeyDownloadConfig.Mapper,
+    private val exposureDetectionConfigMapper: ExposureDetectionConfig.Mapper,
+    private val riskCalculationConfigMapper: RiskCalculationConfig.Mapper
+) {
+
+    fun parse(configBytes: ByteArray): ConfigMapping = try {
+        parseRawArray(configBytes).let {
+            DefaultConfigMapping(
+                rawConfig = it,
+                cwaConfig = cwaConfigMapper.map(it),
+                keyDownloadConfig = keyDownloadConfigMapper.map(it),
+                exposureDetectionConfig = exposureDetectionConfigMapper.map(it),
+                riskCalculationConfig = riskCalculationConfigMapper.map(it)
+            )
+        }
+    } catch (e: Exception) {
+        Timber.w(e, "Failed to parse AppConfig: %s", configBytes)
+        throw e
+    }
+
+    private fun parseRawArray(configBytes: ByteArray): AppConfig.ApplicationConfiguration {
+        Timber.v("Parsing config (size=%dB)", configBytes.size)
+        return AppConfig.ApplicationConfiguration.parseFrom(configBytes)
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt
new file mode 100644
index 0000000000000000000000000000000000000000..783385ddfdd3e7c06198eaff8c0ef5ba5a2961ff
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt
@@ -0,0 +1,19 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+
+data class DefaultConfigMapping(
+    override val rawConfig: AppConfig.ApplicationConfiguration,
+    val cwaConfig: CWAConfig,
+    val keyDownloadConfig: KeyDownloadConfig,
+    val exposureDetectionConfig: ExposureDetectionConfig,
+    val riskCalculationConfig: RiskCalculationConfig
+) : ConfigMapping,
+    CWAConfig by cwaConfig,
+    KeyDownloadConfig by keyDownloadConfig,
+    ExposureDetectionConfig by exposureDetectionConfig,
+    RiskCalculationConfig by riskCalculationConfig
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..752f41cb176c35ca3ba562185dcf172548e098d1
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt
@@ -0,0 +1,21 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters
+import javax.inject.Inject
+
+@Reusable
+class DownloadConfigMapper @Inject constructor() : KeyDownloadConfig.Mapper {
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): KeyDownloadConfig {
+
+        return KeyDownloadConfigContainer(
+            keyDownloadParameters = rawConfig.androidKeyDownloadParameters
+        )
+    }
+
+    data class KeyDownloadConfigContainer(
+        override val keyDownloadParameters: KeyDownloadParameters.KeyDownloadParametersAndroid
+    ) : KeyDownloadConfig
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..c010e25af42a0d9b70693ae7522488c33d5bcc4c
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt
@@ -0,0 +1,74 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import androidx.annotation.VisibleForTesting
+import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.ExposureDetectionParameters.ExposureDetectionParametersAndroid
+import javax.inject.Inject
+
+@Reusable
+class ExposureDetectionConfigMapper @Inject constructor() : ExposureDetectionConfig.Mapper {
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): ExposureDetectionConfig =
+        ExposureDetectionConfigContainer(
+            exposureDetectionConfiguration = rawConfig.mapRiskScoreToExposureConfiguration(),
+            exposureDetectionParameters = rawConfig.androidExposureDetectionParameters
+        )
+
+    data class ExposureDetectionConfigContainer(
+        override val exposureDetectionConfiguration: ExposureConfiguration,
+        override val exposureDetectionParameters: ExposureDetectionParametersAndroid
+    ) : ExposureDetectionConfig
+}
+
+@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+fun AppConfig.ApplicationConfiguration.mapRiskScoreToExposureConfiguration(): ExposureConfiguration =
+    ExposureConfiguration
+        .ExposureConfigurationBuilder()
+        .setTransmissionRiskScores(
+            this.exposureConfig.transmission.appDefined1Value,
+            this.exposureConfig.transmission.appDefined2Value,
+            this.exposureConfig.transmission.appDefined3Value,
+            this.exposureConfig.transmission.appDefined4Value,
+            this.exposureConfig.transmission.appDefined5Value,
+            this.exposureConfig.transmission.appDefined6Value,
+            this.exposureConfig.transmission.appDefined7Value,
+            this.exposureConfig.transmission.appDefined8Value
+        )
+        .setDurationScores(
+            this.exposureConfig.duration.eq0MinValue,
+            this.exposureConfig.duration.gt0Le5MinValue,
+            this.exposureConfig.duration.gt5Le10MinValue,
+            this.exposureConfig.duration.gt10Le15MinValue,
+            this.exposureConfig.duration.gt15Le20MinValue,
+            this.exposureConfig.duration.gt20Le25MinValue,
+            this.exposureConfig.duration.gt25Le30MinValue,
+            this.exposureConfig.duration.gt30MinValue
+        )
+        .setDaysSinceLastExposureScores(
+            this.exposureConfig.daysSinceLastExposure.ge14DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge12Lt14DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge10Lt12DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge8Lt10DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge6Lt8DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge4Lt6DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge2Lt4DaysValue,
+            this.exposureConfig.daysSinceLastExposure.ge0Lt2DaysValue
+        )
+        .setAttenuationScores(
+            this.exposureConfig.attenuation.gt73DbmValue,
+            this.exposureConfig.attenuation.gt63Le73DbmValue,
+            this.exposureConfig.attenuation.gt51Le63DbmValue,
+            this.exposureConfig.attenuation.gt33Le51DbmValue,
+            this.exposureConfig.attenuation.gt27Le33DbmValue,
+            this.exposureConfig.attenuation.gt15Le27DbmValue,
+            this.exposureConfig.attenuation.gt10Le15DbmValue,
+            this.exposureConfig.attenuation.le10DbmValue
+        )
+        .setMinimumRiskScore(this.minRiskScore)
+        .setDurationAtAttenuationThresholds(
+            this.attenuationDuration.thresholds.lower,
+            this.attenuationDuration.thresholds.upper
+        )
+        .build()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt
new file mode 100644
index 0000000000000000000000000000000000000000..dd36d4ea99f8f56af32e83fee682d90cc164460f
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt
@@ -0,0 +1,26 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass
+import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass
+import javax.inject.Inject
+
+@Reusable
+class RiskCalculationConfigMapper @Inject constructor() : RiskCalculationConfig.Mapper {
+
+    override fun map(rawConfig: AppConfig.ApplicationConfiguration): RiskCalculationConfig {
+        return RiskCalculationContainer(
+            minRiskScore = rawConfig.minRiskScore,
+            riskScoreClasses = rawConfig.riskScoreClasses,
+            attenuationDuration = rawConfig.attenuationDuration
+        )
+    }
+
+    data class RiskCalculationContainer(
+        override val minRiskScore: Int,
+        override val attenuationDuration: AttenuationDurationOuterClass.AttenuationDuration,
+        override val riskScoreClasses: RiskScoreClassificationOuterClass.RiskScoreClassification
+    ) : RiskCalculationConfig
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d3ae63b5936f0cb30d67f227d1640aef9603d987
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt
@@ -0,0 +1,12 @@
+package de.rki.coronawarnapp.bugreporting
+
+import de.rki.coronawarnapp.util.di.AppInjector
+
+interface BugReporter {
+    fun report(throwable: Throwable, tag: String? = null, info: String? = null)
+}
+
+fun Throwable.reportProblem(tag: String? = null, info: String? = null) {
+    val reporter = AppInjector.component.bugReporter
+    reporter.report(this, tag, info)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/loghistory/LogHistoryTree.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/loghistory/LogHistoryTree.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0852ac4c9530ceb72ebc44cfa96ad21cdedea88f
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/loghistory/LogHistoryTree.kt
@@ -0,0 +1,8 @@
+package de.rki.coronawarnapp.bugreporting.loghistory
+
+import javax.inject.Qualifier
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class LogHistoryTree
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt
index a29ddabf3f4b86efdebbae8310fae5952be4600e..8f602075d2af674dfa4b9853c9d9c22428e36777 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt
@@ -5,6 +5,7 @@ import androidx.localbroadcastmanager.content.LocalBroadcastManager
 import com.google.android.gms.common.api.ApiException
 import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.bugreporting.reportProblem
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.ReportingConstants.STATUS_CODE_GOOGLE_API_FAIL
 import de.rki.coronawarnapp.exception.reporting.ReportingConstants.STATUS_CODE_GOOGLE_UPDATE_NEEDED
@@ -21,6 +22,7 @@ fun Throwable.report(
     prefix: String?,
     suffix: String?
 ) {
+    reportProblem(tag = prefix, info = suffix)
     val context = CoronaWarnApplication.getAppContext()
 
     val intent = Intent(ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ExposureStateUpdateWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ExposureStateUpdateWorker.kt
index 8af906afe220943e146b401ce053bcc9bc963e5b..b0c92e94c97c65e27ed314b736c0a94589c994c8 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ExposureStateUpdateWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ExposureStateUpdateWorker.kt
@@ -5,43 +5,46 @@ import androidx.work.CoroutineWorker
 import androidx.work.WorkerParameters
 import com.google.android.gms.common.api.ApiException
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.NoTokenException
-import de.rki.coronawarnapp.exception.TransactionException
 import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.risk.RiskLevelTask
 import de.rki.coronawarnapp.storage.ExposureSummaryRepository
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
 import timber.log.Timber
 
-class ExposureStateUpdateWorker(val context: Context, workerParams: WorkerParameters) :
-    CoroutineWorker(context, workerParams) {
-    companion object {
-        private val TAG = ExposureStateUpdateWorker::class.simpleName
-    }
+class ExposureStateUpdateWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters,
+    private val taskController: TaskController
+) : CoroutineWorker(context, workerParams) {
 
     override suspend fun doWork(): Result {
         try {
             Timber.v("worker to persist exposure summary started")
             val token = inputData.getString(ExposureNotificationClient.EXTRA_TOKEN)
                 ?: throw NoTokenException(IllegalArgumentException("no token was found in the intent"))
-
             Timber.v("valid token $token retrieved")
-
-            val exposureSummary = InternalExposureNotificationClient
-                .asyncGetExposureSummary(token)
-
-            ExposureSummaryRepository.getExposureSummaryRepository()
-                .insertExposureSummaryEntity(exposureSummary)
-            Timber.v("exposure summary state updated: $exposureSummary")
-
-            RiskLevelTransaction.start()
+            InternalExposureNotificationClient
+                .asyncGetExposureSummary(token).also {
+                    ExposureSummaryRepository.getExposureSummaryRepository()
+                        .insertExposureSummaryEntity(it)
+                    Timber.v("exposure summary state updated: $it")
+                }
+
+            taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
             Timber.v("risk level calculation triggered")
         } catch (e: ApiException) {
             e.report(ExceptionCategory.EXPOSURENOTIFICATION)
-        } catch (e: TransactionException) {
-            e.report(ExceptionCategory.INTERNAL)
         }
 
         return Result.success()
     }
+
+    @AssistedInject.Factory
+    interface Factory : InjectedWorkerFactory<ExposureStateUpdateWorker>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt
index 1aaf4d63b9fe1806db9ec52b6c7760786fe27017..18fb150e31c1164039079bf0130f5037bdd2c533 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt
@@ -3,11 +3,13 @@ package de.rki.coronawarnapp.nearby.modules.calculationtracker
 import android.content.Context
 import com.google.gson.Gson
 import de.rki.coronawarnapp.util.di.AppContext
-import de.rki.coronawarnapp.util.gson.fromJson
-import de.rki.coronawarnapp.util.gson.toJson
 import de.rki.coronawarnapp.util.serialization.BaseGson
+import de.rki.coronawarnapp.util.serialization.fromJson
+import de.rki.coronawarnapp.util.serialization.getDefaultGsonTypeAdapter
+import de.rki.coronawarnapp.util.serialization.toJson
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import org.joda.time.Instant
 import timber.log.Timber
 import java.io.File
 import javax.inject.Inject
@@ -16,8 +18,14 @@ import javax.inject.Singleton
 @Singleton
 class CalculationTrackerStorage @Inject constructor(
     @AppContext private val context: Context,
-    @BaseGson private val gson: Gson
+    @BaseGson gson: Gson
 ) {
+    private val gson by lazy {
+        gson.newBuilder()
+            .registerTypeAdapter(Instant::class.java, Instant::class.getDefaultGsonTypeAdapter())
+            .create()
+    }
+
     private val mutex = Mutex()
     private val storageDir by lazy {
         File(context.filesDir, "calcuation_tracker").apply {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt
index 338a63f34b8fa33fd08ed609aa6e6a41044ae11c..7d981a1c81f08e314fe4456bcce87448dbf686a9 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/DefaultPlaybook.kt
@@ -78,7 +78,7 @@ class DefaultPlaybook @Inject constructor(
         return testResult?.let { TestResult.fromInt(it) } ?: propagateException(exception)
     }
 
-    override suspend fun submission(
+    override suspend fun submit(
         data: Playbook.SubmissionData
     ) {
         Timber.i("[$uid] New Submission Playbook")
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt
index d546440f057fc804983d730e8b64d4bbc2bac6e6..d11cdbfdf497d0ac3a2df94e31694101e0796ae3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/Playbook.kt
@@ -25,7 +25,7 @@ interface Playbook {
         val visistedCountries: List<String>
     )
 
-    suspend fun submission(data: SubmissionData)
+    suspend fun submit(data: SubmissionData)
 
     suspend fun dummy()
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevelCalculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevelCalculation.kt
deleted file mode 100644
index 3dfc8632fb19b8b00884c0d3ef5de3bb04380ca5..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevelCalculation.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package de.rki.coronawarnapp.risk
-
-import com.google.android.gms.nearby.exposurenotification.ExposureSummary
-import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass.AttenuationDuration
-
-import timber.log.Timber
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.math.round
-
-@Singleton
-class DefaultRiskLevelCalculation @Inject constructor() : RiskLevelCalculation {
-
-    companion object {
-
-        private var TAG = DefaultRiskLevelCalculation::class.simpleName
-
-        private const val DECIMAL_MULTIPLIER = 100
-    }
-
-    override fun calculateRiskScore(
-        attenuationParameters: AttenuationDuration,
-        exposureSummary: ExposureSummary
-    ): Double {
-
-        /** all attenuation values are capped to [TimeVariables.MAX_ATTENUATION_DURATION] */
-        val weightedAttenuationLow =
-            attenuationParameters.weights.low
-                .times(exposureSummary.attenuationDurationsInMinutes[0].capped())
-        val weightedAttenuationMid =
-            attenuationParameters.weights.mid
-                .times(exposureSummary.attenuationDurationsInMinutes[1].capped())
-        val weightedAttenuationHigh =
-            attenuationParameters.weights.high
-                .times(exposureSummary.attenuationDurationsInMinutes[2].capped())
-
-        val maximumRiskScore = exposureSummary.maximumRiskScore.toDouble()
-
-        val defaultBucketOffset = attenuationParameters.defaultBucketOffset.toDouble()
-        val normalizationDivisor = attenuationParameters.riskScoreNormalizationDivisor.toDouble()
-
-        val attenuationStrings =
-            "Weighted Attenuation: ($weightedAttenuationLow + $weightedAttenuationMid + " +
-                    "$weightedAttenuationHigh + $defaultBucketOffset)"
-        Timber.v(attenuationStrings)
-
-        val weightedAttenuationDuration =
-            weightedAttenuationLow
-                .plus(weightedAttenuationMid)
-                .plus(weightedAttenuationHigh)
-                .plus(defaultBucketOffset)
-
-        Timber.v("Formula used: ($maximumRiskScore / $normalizationDivisor) * $weightedAttenuationDuration")
-
-        val riskScore = (maximumRiskScore / normalizationDivisor) * weightedAttenuationDuration
-
-        return round(riskScore.times(DECIMAL_MULTIPLIER)).div(DECIMAL_MULTIPLIER)
-    }
-
-    private fun Int.capped(): Int {
-        return if (this > TimeVariables.getMaxAttenuationDuration()) {
-            TimeVariables.getMaxAttenuationDuration()
-        } else {
-            this
-        }
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4df5b3228daba8b66f098621e61dc89a8ff73ec1
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt
@@ -0,0 +1,220 @@
+package de.rki.coronawarnapp.risk
+
+import androidx.annotation.VisibleForTesting
+import androidx.core.app.NotificationCompat
+import com.google.android.gms.nearby.exposurenotification.ExposureSummary
+import de.rki.coronawarnapp.CoronaWarnApplication
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.exception.RiskLevelCalculationException
+import de.rki.coronawarnapp.notification.NotificationHelper
+import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_INITIAL
+import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS
+import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass.AttenuationDuration
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.storage.RiskLevelRepository
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.millisecondsToHours
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.math.round
+
+@Singleton
+class DefaultRiskLevels @Inject constructor(
+    private val appConfigProvider: AppConfigProvider
+) : RiskLevels {
+
+    override fun updateRepository(riskLevel: RiskLevel, time: Long) {
+        val rollbackItems = mutableListOf<RollbackItem>()
+        try {
+            Timber.tag(TAG).v("Update the risk level with $riskLevel")
+            val lastCalculatedRiskLevelScoreForRollback =
+                RiskLevelRepository.getLastCalculatedScore()
+            updateRiskLevelScore(riskLevel)
+            rollbackItems.add {
+                updateRiskLevelScore(lastCalculatedRiskLevelScoreForRollback)
+            }
+
+            // risk level calculation date update
+            val lastCalculatedRiskLevelDate = LocalData.lastTimeRiskLevelCalculation()
+            LocalData.lastTimeRiskLevelCalculation(time)
+            rollbackItems.add {
+                LocalData.lastTimeRiskLevelCalculation(lastCalculatedRiskLevelDate)
+            }
+        } catch (error: Exception) {
+            Timber.tag(TAG).e(error, "Updating the RiskLevelRepository failed.")
+
+            try {
+                Timber.tag(TAG).d("Initiate Rollback")
+                for (rollbackItem: RollbackItem in rollbackItems) rollbackItem.invoke()
+            } catch (rollbackException: Exception) {
+                Timber.tag(TAG).e(rollbackException, "RiskLevelRepository rollback failed.")
+            }
+
+            throw error
+        }
+    }
+
+    override fun calculationNotPossibleBecauseOfOutdatedResults(): Boolean {
+        // if the last calculation is longer in the past as the defined threshold we return the stale state
+        val timeSinceLastDiagnosisKeyFetchFromServer =
+            TimeVariables.getTimeSinceLastDiagnosisKeyFetchFromServer()
+                ?: throw RiskLevelCalculationException(
+                    IllegalArgumentException(
+                        "Time since last exposure calculation is null"
+                    )
+                )
+        /** we only return outdated risk level if the threshold is reached AND the active tracing time is above the
+        defined threshold because [UNKNOWN_RISK_INITIAL] overrules [UNKNOWN_RISK_OUTDATED_RESULTS] */
+        return timeSinceLastDiagnosisKeyFetchFromServer.millisecondsToHours() >
+            TimeVariables.getMaxStaleExposureRiskRange() && isActiveTracingTimeAboveThreshold()
+    }
+
+    override fun calculationNotPossibleBecauseOfNoKeys() =
+        (TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() == null).also {
+            if (it) {
+                Timber.tag(TAG)
+                    .v("No last time diagnosis keys from server fetch timestamp was found")
+            }
+        }
+
+    override suspend fun isIncreasedRisk(lastExposureSummary: ExposureSummary): Boolean {
+        val appConfiguration = appConfigProvider.getAppConfig()
+        Timber.tag(TAG).v("Retrieved configuration from backend")
+        // custom attenuation parameters to weigh the attenuation
+        // values provided by the Google API
+        val attenuationParameters = appConfiguration.attenuationDuration
+        // these are the defined risk classes. They will divide the calculated
+        // risk score into the low and increased risk
+        val riskScoreClassification = appConfiguration.riskScoreClasses
+
+        // calculate the risk score based on the values collected by the Google EN API and
+        // the backend configuration
+        val riskScore = calculateRiskScore(
+            attenuationParameters,
+            lastExposureSummary
+        ).also {
+            Timber.tag(TAG).v("Calculated risk with the given config: $it")
+        }
+
+        // get the high risk score class
+        val highRiskScoreClass =
+            riskScoreClassification.riskClassesList.find { it.label == "HIGH" }
+                ?: throw RiskLevelCalculationException(IllegalStateException("No high risk score class found"))
+
+        // if the calculated risk score is above the defined level threshold we return the high level risk score
+        if (withinDefinedLevelThreshold(
+                riskScore,
+                highRiskScoreClass.min,
+                highRiskScoreClass.max
+            )
+        ) {
+            Timber.tag(TAG)
+                .v("$riskScore is above the defined min value ${highRiskScoreClass.min}")
+            return true
+        } else if (riskScore > highRiskScoreClass.max) {
+            throw RiskLevelCalculationException(
+                IllegalStateException("Risk score is above the max threshold for score class")
+            )
+        }
+
+        return false
+    }
+
+    override fun isActiveTracingTimeAboveThreshold(): Boolean {
+        val durationTracingIsActive = TimeVariables.getTimeActiveTracingDuration()
+        val activeTracingDurationInHours = durationTracingIsActive.millisecondsToHours()
+        val durationTracingIsActiveThreshold =
+            TimeVariables.getMinActivatedTracingTime().toLong()
+
+        return (activeTracingDurationInHours >= durationTracingIsActiveThreshold).also {
+            Timber.tag(TAG).v(
+                "Active tracing time ($activeTracingDurationInHours h) is above threshold " +
+                    "($durationTracingIsActiveThreshold h): $it"
+            )
+            if (it) {
+                Timber.tag(TAG).v("Active tracing time is not enough")
+            }
+        }
+    }
+
+    override fun calculateRiskScore(
+        attenuationParameters: AttenuationDuration,
+        exposureSummary: ExposureSummary
+    ): Double {
+        /** all attenuation values are capped to [TimeVariables.MAX_ATTENUATION_DURATION] */
+        val weightedAttenuationLow =
+            attenuationParameters.weights.low
+                .times(exposureSummary.attenuationDurationsInMinutes[0].capped())
+        val weightedAttenuationMid =
+            attenuationParameters.weights.mid
+                .times(exposureSummary.attenuationDurationsInMinutes[1].capped())
+        val weightedAttenuationHigh =
+            attenuationParameters.weights.high
+                .times(exposureSummary.attenuationDurationsInMinutes[2].capped())
+
+        val maximumRiskScore = exposureSummary.maximumRiskScore.toDouble()
+
+        val defaultBucketOffset = attenuationParameters.defaultBucketOffset.toDouble()
+        val normalizationDivisor = attenuationParameters.riskScoreNormalizationDivisor.toDouble()
+
+        val attenuationStrings =
+            "Weighted Attenuation: ($weightedAttenuationLow + $weightedAttenuationMid + " +
+                "$weightedAttenuationHigh + $defaultBucketOffset)"
+        Timber.v(attenuationStrings)
+
+        val weightedAttenuationDuration =
+            weightedAttenuationLow
+                .plus(weightedAttenuationMid)
+                .plus(weightedAttenuationHigh)
+                .plus(defaultBucketOffset)
+
+        Timber.v("Formula used: ($maximumRiskScore / $normalizationDivisor) * $weightedAttenuationDuration")
+
+        val riskScore = (maximumRiskScore / normalizationDivisor) * weightedAttenuationDuration
+
+        return round(riskScore.times(DECIMAL_MULTIPLIER)).div(DECIMAL_MULTIPLIER)
+    }
+
+    @VisibleForTesting
+    internal fun Int.capped() =
+        if (this > TimeVariables.getMaxAttenuationDuration()) {
+            TimeVariables.getMaxAttenuationDuration()
+        } else {
+            this
+        }
+
+    @VisibleForTesting
+    internal fun withinDefinedLevelThreshold(riskScore: Double, min: Int, max: Int) =
+        riskScore >= min && riskScore <= max
+
+    /**
+     * Updates the Risk Level Score in the repository with the calculated Risk Level
+     *
+     * @param riskLevel
+     */
+    @VisibleForTesting
+    internal fun updateRiskLevelScore(riskLevel: RiskLevel) {
+        val lastCalculatedScore = RiskLevelRepository.getLastCalculatedScore()
+        if (RiskLevel.riskLevelChangedBetweenLowAndHigh(
+                lastCalculatedScore,
+                riskLevel
+            ) && !LocalData.submissionWasSuccessful()
+        ) {
+            NotificationHelper.sendNotification(
+                CoronaWarnApplication.getAppContext().getString(R.string.notification_body),
+                NotificationCompat.PRIORITY_HIGH
+            )
+        }
+        if (lastCalculatedScore.raw == RiskLevelConstants.INCREASED_RISK &&
+            riskLevel.raw == RiskLevelConstants.LOW_LEVEL_RISK) {
+            LocalData.isUserToBeNotifiedOfLoweredRiskLevel = true
+        }
+        RiskLevelRepository.setRiskLevelScore(riskLevel)
+    }
+
+    companion object {
+        private val TAG = DefaultRiskLevels::class.java.simpleName
+        private const val DECIMAL_MULTIPLIER = 100
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskScoreAnalysis.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskScoreAnalysis.kt
deleted file mode 100644
index eb8c7c04337432a01315e29b97216c62515cc411..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskScoreAnalysis.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package de.rki.coronawarnapp.risk
-
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class DefaultRiskScoreAnalysis @Inject constructor() : RiskScoreAnalysis {
-
-    override fun withinDefinedLevelThreshold(riskScore: Double, min: Int, max: Int) =
-        riskScore >= min && riskScore <= max
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelCalculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelCalculation.kt
deleted file mode 100644
index 515419348fd57a1d361ec1a28cc505b3e26e0493..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelCalculation.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package de.rki.coronawarnapp.risk
-
-import com.google.android.gms.nearby.exposurenotification.ExposureSummary
-import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass.AttenuationDuration
-
-interface RiskLevelCalculation {
-
-    fun calculateRiskScore(
-        attenuationParameters: AttenuationDuration,
-        exposureSummary: ExposureSummary
-    ): Double
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..407fef631dd901e8995d64cff3047acc22beb5c6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt
@@ -0,0 +1,174 @@
+package de.rki.coronawarnapp.risk
+
+import android.content.Context
+import com.google.android.gms.nearby.exposurenotification.ExposureSummary
+import de.rki.coronawarnapp.exception.ExceptionCategory
+import de.rki.coronawarnapp.exception.RiskLevelCalculationException
+import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
+import de.rki.coronawarnapp.risk.RiskLevel.INCREASED_RISK
+import de.rki.coronawarnapp.risk.RiskLevel.LOW_LEVEL_RISK
+import de.rki.coronawarnapp.risk.RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF
+import de.rki.coronawarnapp.risk.RiskLevel.UNDETERMINED
+import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_INITIAL
+import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS
+import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.storage.RiskLevelRepository
+import de.rki.coronawarnapp.task.Task
+import de.rki.coronawarnapp.task.TaskCancellationException
+import de.rki.coronawarnapp.task.TaskFactory
+import de.rki.coronawarnapp.task.common.DefaultProgress
+import de.rki.coronawarnapp.util.BackgroundModeStatus
+import de.rki.coronawarnapp.util.ConnectivityHelper.isNetworkEnabled
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.channels.ConflatedBroadcastChannel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.first
+import org.joda.time.Duration
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Provider
+
+class RiskLevelTask @Inject constructor(
+    private val riskLevels: RiskLevels,
+    @AppContext private val context: Context,
+    private val enfClient: ENFClient,
+    private val timeStamper: TimeStamper,
+    private val backgroundModeStatus: BackgroundModeStatus
+) : Task<DefaultProgress, RiskLevelTask.Result> {
+
+    private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>()
+    override val progress: Flow<DefaultProgress> = internalProgress.asFlow()
+
+    private var isCanceled = false
+
+    override suspend fun run(arguments: Task.Arguments): Result {
+        try {
+            Timber.d("Running with arguments=%s", arguments)
+            // If there is no connectivity the transaction will set the last calculated
+            // risk level
+            if (!isNetworkEnabled(context)) {
+                RiskLevelRepository.setLastCalculatedRiskLevelAsCurrent()
+                return Result(UNDETERMINED)
+            }
+
+            if (!enfClient.isTracingEnabled.first()) {
+                return Result(NO_CALCULATION_POSSIBLE_TRACING_OFF)
+            }
+
+            with(riskLevels) {
+                return Result(
+                    when {
+                        calculationNotPossibleBecauseOfNoKeys().also {
+                            checkCancel()
+                        } -> UNKNOWN_RISK_INITIAL
+
+                        calculationNotPossibleBecauseOfOutdatedResults().also {
+                            checkCancel()
+                        } -> if (backgroundJobsEnabled()) {
+                            UNKNOWN_RISK_OUTDATED_RESULTS
+                        } else {
+                            UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
+                        }
+
+                        isIncreasedRisk(getNewExposureSummary()).also {
+                            checkCancel()
+                        } -> INCREASED_RISK
+
+                        !isActiveTracingTimeAboveThreshold().also {
+                            checkCancel()
+                        } -> UNKNOWN_RISK_INITIAL
+
+                        else -> LOW_LEVEL_RISK
+                    }.also {
+                        checkCancel()
+                        updateRepository(it, timeStamper.nowUTC.millis)
+                    }
+                )
+            }
+        } catch (error: Exception) {
+            Timber.tag(TAG).e(error)
+            error.report(ExceptionCategory.EXPOSURENOTIFICATION)
+            throw error
+        } finally {
+            Timber.i("Finished (isCanceled=$isCanceled).")
+            internalProgress.close()
+        }
+    }
+
+    /**
+     * If there is no persisted exposure summary we try to get a new one with the last persisted
+     * Google API token that was used in the [de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction]
+     *
+     * @return a exposure summary from the Google Exposure Notification API
+     */
+    private suspend fun getNewExposureSummary(): ExposureSummary {
+        val googleToken = LocalData.googleApiToken()
+            ?: throw RiskLevelCalculationException(IllegalStateException("Exposure summary is not persisted"))
+
+        val exposureSummary =
+            InternalExposureNotificationClient.asyncGetExposureSummary(googleToken)
+
+        return exposureSummary.also {
+            Timber.tag(TAG)
+                .v("Generated new exposure summary with $googleToken")
+        }
+    }
+
+    private fun checkCancel() {
+        if (isCanceled) throw TaskCancellationException()
+    }
+
+    private suspend fun backgroundJobsEnabled() =
+            backgroundModeStatus.isAutoModeEnabled.first().also {
+                if (it) {
+                    Timber.tag(TAG)
+                            .v("diagnosis keys outdated and active tracing time is above threshold")
+                    Timber.tag(TAG)
+                            .v("manual mode not active (background jobs enabled)")
+                } else {
+                    Timber.tag(TAG)
+                            .v("diagnosis keys outdated and active tracing time is above threshold")
+                    Timber.tag(TAG).v("manual mode active (background jobs disabled)")
+                }
+            }
+
+    override suspend fun cancel() {
+        Timber.w("cancel() called.")
+        isCanceled = true
+    }
+
+    class Result(val riskLevel: RiskLevel) : Task.Result {
+        override fun toString(): String {
+            return "Result(riskLevel=${riskLevel.name})"
+        }
+    }
+
+    data class Config(
+        // TODO unit-test that not > 9 min
+        @Suppress("MagicNumber")
+        override val executionTimeout: Duration = Duration.standardMinutes(8),
+
+        override val collisionBehavior: TaskFactory.Config.CollisionBehavior =
+            TaskFactory.Config.CollisionBehavior.SKIP_IF_SIBLING_RUNNING
+
+    ) : TaskFactory.Config
+
+    class Factory @Inject constructor(
+        private val taskByDagger: Provider<RiskLevelTask>
+    ) : TaskFactory<DefaultProgress, Result> {
+
+        override val config: TaskFactory.Config = Config()
+        override val taskProvider: () -> Task<DefaultProgress, Result> = {
+            taskByDagger.get()
+        }
+    }
+
+    companion object {
+        private val TAG: String? = RiskLevelTask::class.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b8cd2f00c6b4ea61636c4593b03d43b65520078d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt
@@ -0,0 +1,29 @@
+package de.rki.coronawarnapp.risk
+
+import com.google.android.gms.nearby.exposurenotification.ExposureSummary
+import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass
+
+interface RiskLevels {
+
+    fun calculationNotPossibleBecauseOfNoKeys(): Boolean
+
+    fun calculationNotPossibleBecauseOfOutdatedResults(): Boolean
+
+    /**
+     * true if threshold is reached / if the duration of the activated tracing time is above the
+     * defined value
+     */
+    fun isActiveTracingTimeAboveThreshold(): Boolean
+
+    suspend fun isIncreasedRisk(lastExposureSummary: ExposureSummary): Boolean
+
+    fun updateRepository(
+        riskLevel: RiskLevel,
+        time: Long
+    )
+
+    fun calculateRiskScore(
+        attenuationParameters: AttenuationDurationOuterClass.AttenuationDuration,
+        exposureSummary: ExposureSummary
+    ): Double
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskModule.kt
index 7b69bc7eb52fc57ef2012a402fd1081b1c6aff35..ca97d2dc37ee251a74321e42c620dc0b49c90a2c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskModule.kt
@@ -2,20 +2,25 @@ package de.rki.coronawarnapp.risk
 
 import dagger.Binds
 import dagger.Module
+import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.task.Task
+import de.rki.coronawarnapp.task.TaskFactory
+import de.rki.coronawarnapp.task.TaskTypeKey
 import javax.inject.Singleton
 
 @Module
 abstract class RiskModule {
 
     @Binds
-    @Singleton
-    abstract fun bindRiskLevelCalculation(
-        riskLevelCalculation: DefaultRiskLevelCalculation
-    ): RiskLevelCalculation
+    @IntoMap
+    @TaskTypeKey(RiskLevelTask::class)
+    abstract fun riskLevelTaskFactory(
+        factory: RiskLevelTask.Factory
+    ): TaskFactory<out Task.Progress, out Task.Result>
 
     @Binds
     @Singleton
-    abstract fun bindRiskScoreAnalysis(
-        riskScoreAnalysis: DefaultRiskScoreAnalysis
-    ): RiskScoreAnalysis
+    abstract fun bindRiskLevelCalculation(
+        riskLevelCalculation: DefaultRiskLevels
+    ): RiskLevels
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskScoreAnalysis.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskScoreAnalysis.kt
deleted file mode 100644
index 10bb47c25f7ebc0038e0a830f54f35710d1d21b0..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskScoreAnalysis.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package de.rki.coronawarnapp.risk
-
-interface RiskScoreAnalysis {
-
-    fun withinDefinedLevelThreshold(
-        riskScore: Double,
-        min: Int,
-        max: Int
-    ): Boolean
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RollbackItem.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RollbackItem.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1e66cebb0074a6773c97b3e4160dfd3ff6b17b1e
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RollbackItem.kt
@@ -0,0 +1,3 @@
+package de.rki.coronawarnapp.risk
+
+typealias RollbackItem = () -> Unit
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt
deleted file mode 100644
index 16ac02b7963ed29b9d0e3239d480d891abcfaa39..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt
+++ /dev/null
@@ -1,65 +0,0 @@
-package de.rki.coronawarnapp.service.applicationconfiguration
-
-import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
-import de.rki.coronawarnapp.util.di.AppInjector
-
-object ApplicationConfigurationService {
-    suspend fun asyncRetrieveApplicationConfiguration(): ApplicationConfiguration {
-        return AppInjector.component.appConfigProvider.getAppConfig()
-    }
-
-    suspend fun asyncRetrieveExposureConfiguration(): ExposureConfiguration =
-        asyncRetrieveApplicationConfiguration()
-            .mapRiskScoreToExposureConfiguration()
-
-    private fun ApplicationConfiguration.mapRiskScoreToExposureConfiguration(): ExposureConfiguration =
-        ExposureConfiguration
-            .ExposureConfigurationBuilder()
-            .setTransmissionRiskScores(
-                this.exposureConfig.transmission.appDefined1Value,
-                this.exposureConfig.transmission.appDefined2Value,
-                this.exposureConfig.transmission.appDefined3Value,
-                this.exposureConfig.transmission.appDefined4Value,
-                this.exposureConfig.transmission.appDefined5Value,
-                this.exposureConfig.transmission.appDefined6Value,
-                this.exposureConfig.transmission.appDefined7Value,
-                this.exposureConfig.transmission.appDefined8Value
-            )
-            .setDurationScores(
-                this.exposureConfig.duration.eq0MinValue,
-                this.exposureConfig.duration.gt0Le5MinValue,
-                this.exposureConfig.duration.gt5Le10MinValue,
-                this.exposureConfig.duration.gt10Le15MinValue,
-                this.exposureConfig.duration.gt15Le20MinValue,
-                this.exposureConfig.duration.gt20Le25MinValue,
-                this.exposureConfig.duration.gt25Le30MinValue,
-                this.exposureConfig.duration.gt30MinValue
-            )
-            .setDaysSinceLastExposureScores(
-                this.exposureConfig.daysSinceLastExposure.ge14DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge12Lt14DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge10Lt12DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge8Lt10DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge6Lt8DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge4Lt6DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge2Lt4DaysValue,
-                this.exposureConfig.daysSinceLastExposure.ge0Lt2DaysValue
-            )
-            .setAttenuationScores(
-                this.exposureConfig.attenuation.gt73DbmValue,
-                this.exposureConfig.attenuation.gt63Le73DbmValue,
-                this.exposureConfig.attenuation.gt51Le63DbmValue,
-                this.exposureConfig.attenuation.gt33Le51DbmValue,
-                this.exposureConfig.attenuation.gt27Le33DbmValue,
-                this.exposureConfig.attenuation.gt15Le27DbmValue,
-                this.exposureConfig.attenuation.gt10Le15DbmValue,
-                this.exposureConfig.attenuation.le10DbmValue
-            )
-            .setMinimumRiskScore(this.minRiskScore)
-            .setDurationAtAttenuationThresholds(
-                this.attenuationDuration.thresholds.lower,
-                this.attenuationDuration.thresholds.upper
-            )
-            .build()
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt
index 783ec13a29ac5895765b53c0de7e47b19843807b..37da7b31baef3803df7505621ec26ef620b08989 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt
@@ -9,24 +9,19 @@ data class QRScanResult(val rawResult: String) {
     val guid: String? by lazy { extractGUID(rawResult) }
 
     private fun extractGUID(rawResult: String): String? {
-        if (rawResult.length > MAX_QR_CODE_LENGTH) return null
-        if (rawResult.count { it == GUID_SEPARATOR } != 1) return null
         if (!QR_CODE_REGEX.toRegex().matches(rawResult)) return null
 
-        val potentialGUID = rawResult.substringAfterLast(GUID_SEPARATOR, "")
-        if (potentialGUID.isBlank() || potentialGUID.length > MAX_GUID_LENGTH) return null
-
-        return potentialGUID
+        val matcher = QR_CODE_REGEX.matcher(rawResult)
+        return if (matcher.matches()) matcher.group(1) else null
     }
 
     companion object {
         // regex pattern for scanned QR code URL
-        val QR_CODE_REGEX: Pattern = Pattern.compile(
-            "^((^https:\\/{2}localhost)(\\/\\?)[A-Fa-f0-9]{6}" +
-                    "[-][A-Fa-f0-9]{8}[-][A-Fa-f0-9]{4}[-][A-Fa-f0-9]{4}[-][A-Fa-f0-9]{4}[-][A-Fa-f0-9]{12})\$"
-        )
-        const val GUID_SEPARATOR = '?'
-        const val MAX_QR_CODE_LENGTH = 150
-        const val MAX_GUID_LENGTH = 80
+        val QR_CODE_REGEX: Pattern = ("^" + // Match start of string
+            "(?:https:\\/{2}localhost)" + // Match `https://localhost`
+            "(?:\\/{1}\\?)" + // Match the query param `/?`
+            "([a-f\\d]{6}[-][a-f\\d]{8}[-](?:[a-f\\d]{4}[-]){3}[a-f\\d]{12})" + // Match the UUID
+            "\$"
+            ).toPattern(Pattern.CASE_INSENSITIVE)
     }
 }
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 be711b0ae28bdf4ac9b2d804691e45c80bd4cd8c..cfd3f3f408fbc76bc5138c4498e8245be8cebfc4 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
@@ -1,14 +1,11 @@
 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.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
@@ -56,12 +53,6 @@ object SubmissionService {
         SubmissionRepository.updateTestResult(testResult)
     }
 
-    suspend fun asyncSubmitExposureKeys(keys: List<TemporaryExposureKey>, symptoms: Symptoms) {
-        val registrationToken =
-            LocalData.registrationToken() ?: throw NoRegistrationTokenSetException()
-        SubmitDiagnosisKeysTransaction.start(registrationToken, keys, symptoms)
-    }
-
     suspend fun asyncRequestTestResult(): TestResult {
         val registrationToken =
             LocalData.registrationToken() ?: throw NoRegistrationTokenSetException()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/AppDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/AppDatabase.kt
index 454055162dc1c14c57945c1ae1b3e3b97f177631..66fc87e9d7b3b18e41d0a565b070c6c8e4eb2ad4 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/AppDatabase.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/AppDatabase.kt
@@ -20,7 +20,11 @@ import net.sqlcipher.database.SupportFactory
 import java.io.File
 
 @Database(
-    entities = [ExposureSummaryEntity::class, KeyCacheLegacyEntity::class, TracingIntervalEntity::class],
+    entities = [
+        ExposureSummaryEntity::class,
+        KeyCacheLegacyEntity::class,
+        TracingIntervalEntity::class
+    ],
     version = 1,
     exportSchema = true
 )
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
index 9b36caaa77d472e6dbac4c77fd018b6caafa6576..d3f73dff85afeb4999f3a293680176d75e1da8b9 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
@@ -373,10 +373,9 @@ object LocalData {
             PREFERENCE_HAS_RISK_STATUS_LOWERED,
             false
         )
-        set(value) = getSharedPreferenceInstance().edit(commit = true) {
-            putBoolean(PREFERENCE_HAS_RISK_STATUS_LOWERED, value)
-            isUserToBeNotifiedOfLoweredRiskLevelFlowInternal.value = value
-        }
+        set(value) = getSharedPreferenceInstance()
+            .edit(commit = true) { putBoolean(PREFERENCE_HAS_RISK_STATUS_LOWERED, value) }
+            .also { isUserToBeNotifiedOfLoweredRiskLevelFlowInternal.value = value }
 
     /****************************************************
      * SERVER FETCH DATA
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/RiskLevelRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/RiskLevelRepository.kt
index 6fc200f81cf687f36fff3e8c57fc50e3009a021e..6661469b1b925ccd87655eb7e65b2fc449d64e01 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/RiskLevelRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/RiskLevelRepository.kt
@@ -20,7 +20,7 @@ object RiskLevelRepository {
      * Calculation happens in the [de.rki.coronawarnapp.transaction.RiskLevelTransaction]
      *
      * @see de.rki.coronawarnapp.transaction.RiskLevelTransaction
-     * @see de.rki.coronawarnapp.risk.RiskLevelCalculation
+     * @see de.rki.coronawarnapp.risk.RiskLevels
      *
      * @param riskLevel
      */
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt
index eff4ebae82a41cb2f9d9d032d2f70a520ef5c2bf..ed9100cda73ba3dad4bf70ede1eda68443f3dd14 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt
@@ -32,11 +32,9 @@ object SubmissionRepository {
 
     private val testResultReceivedDateFlowInternal = MutableStateFlow(Date())
     val testResultReceivedDateFlow: Flow<Date> = testResultReceivedDateFlowInternal
-    val testResultReceivedDate = testResultReceivedDateFlow.asLiveData()
 
     private val deviceUIStateFlowInternal = MutableStateFlow(DeviceUIState.UNPAIRED)
     val deviceUIStateFlow: Flow<DeviceUIState> = deviceUIStateFlowInternal
-    val deviceUIState = deviceUIStateFlow.asLiveData()
 
     private val testResultFlow = MutableStateFlow<TestResult?>(null)
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
index ee61db71c19e73a187fd6130fbc024995e598189..b52252c0819b0bdd2bf6ce1355cd8785825a2fa5 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
@@ -6,11 +6,14 @@ import de.rki.coronawarnapp.exception.TransactionException
 import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
+import de.rki.coronawarnapp.risk.RiskLevelTask
 import de.rki.coronawarnapp.risk.TimeVariables.getActiveTracingDaysInRetentionPeriod
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.TaskInfo
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
 import de.rki.coronawarnapp.timer.TimerHelper
 import de.rki.coronawarnapp.tracing.TracingProgress
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction
 import de.rki.coronawarnapp.util.ConnectivityHelper
 import de.rki.coronawarnapp.util.coroutine.AppScope
 import kotlinx.coroutines.CoroutineScope
@@ -38,6 +41,7 @@ import javax.inject.Singleton
 @Singleton
 class TracingRepository @Inject constructor(
     @AppScope private val scope: CoroutineScope,
+    private val taskController: TaskController,
     enfClient: ENFClient
 ) {
 
@@ -57,8 +61,11 @@ class TracingRepository @Inject constructor(
             LocalData.lastTimeDiagnosisKeysFromServerFetch()
     }
 
-    // TODO shouldn't access this directly
-    private val internalIsRefreshing = MutableStateFlow(false)
+    private val retrievingDiagnosisKeys = MutableStateFlow(false)
+    private val internalIsRefreshing =
+        retrievingDiagnosisKeys.combine(taskController.tasks) { retrievingDiagnosisKeys, tasks ->
+            retrievingDiagnosisKeys || tasks.isRiskLevelTaskRunning()
+        }
     val tracingProgress: Flow<TracingProgress> = combine(
         internalIsRefreshing,
         enfClient.isCurrentlyCalculating()
@@ -70,6 +77,10 @@ class TracingRepository @Inject constructor(
         }
     }
 
+    private fun List<TaskInfo>.isRiskLevelTaskRunning() = any {
+        it.taskState.isActive && it.taskState.request.type == RiskLevelTask::class
+    }
+
     /**
      * Refresh the diagnosis keys. For that isRefreshing is set to true which is displayed in the ui.
      * Afterwards the RetrieveDiagnosisKeysTransaction and the RiskLevelTransaction are started.
@@ -82,17 +93,15 @@ class TracingRepository @Inject constructor(
      */
     fun refreshDiagnosisKeys() {
         scope.launch {
-            internalIsRefreshing.value = true
+            retrievingDiagnosisKeys.value = true
             try {
                 RetrieveDiagnosisKeysTransaction.start()
-                RiskLevelTransaction.start()
-            } catch (e: TransactionException) {
-                e.cause?.report(ExceptionCategory.EXPOSURENOTIFICATION)
+                taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
             } catch (e: Exception) {
                 e.report(ExceptionCategory.EXPOSURENOTIFICATION)
             }
             refreshLastTimeDiagnosisKeysFetchedDate()
-            internalIsRefreshing.value = false
+            retrievingDiagnosisKeys.value = false
             TimerHelper.startManualKeyRetrievalTimer()
         }
     }
@@ -149,7 +158,7 @@ class TracingRepository @Inject constructor(
 
                 if (keysWereNotRetrievedToday && isNetworkEnabled && isBackgroundJobEnabled) {
                     // TODO shouldn't access this directly
-                    internalIsRefreshing.value = true
+                    retrievingDiagnosisKeys.value = true
 
                     // start the fetching and submitting of the diagnosis keys
                     RetrieveDiagnosisKeysTransaction.start()
@@ -162,16 +171,9 @@ class TracingRepository @Inject constructor(
                 e.report(ExceptionCategory.INTERNAL)
             }
 
-            // refresh the risk level
-            try {
-                RiskLevelTransaction.start()
-            } catch (e: TransactionException) {
-                e.cause?.report(ExceptionCategory.INTERNAL)
-            } catch (e: Exception) {
-                e.report(ExceptionCategory.INTERNAL)
-            }
+            taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
             // TODO shouldn't access this directly
-            internalIsRefreshing.value = false
+            retrievingDiagnosisKeys.value = false
         }
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt
index 81327c8e1182373949179b771c87b4e3936e01dd..f14a53f5ff4656b76205d9e1e847d695e4cf3e76 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt
@@ -1,11 +1,12 @@
 package de.rki.coronawarnapp.storage.interoperability
 
 import android.text.TextUtils
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.Transformations
+import androidx.lifecycle.asLiveData
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.ui.Country
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.runBlocking
 import timber.log.Timber
 import java.util.Locale
@@ -21,8 +22,11 @@ class InteroperabilityRepository @Inject constructor(
         LocalData.isInteroperabilityShownAtLeastOnce = true
     }
 
-    private val _countryList: MutableLiveData<List<Country>> = MutableLiveData(listOf())
-    val countryList = Transformations.distinctUntilChanged(_countryList)
+    private val countryListFlowInternal = MutableStateFlow(listOf<Country>())
+    val countryListFlow: Flow<List<Country>> = countryListFlowInternal
+
+    @Deprecated("Use  countryListFlow")
+    val countryList = countryListFlow.asLiveData()
 
     init {
         getAllCountries()
@@ -36,7 +40,7 @@ class InteroperabilityRepository @Inject constructor(
         runBlocking {
             try {
                 val countries = appConfigProvider.getAppConfig()
-                    .supportedCountriesList
+                    .supportedCountries
                     .mapNotNull { rawCode ->
                         val countryCode = rawCode.toLowerCase(Locale.ROOT)
 
@@ -44,16 +48,16 @@ class InteroperabilityRepository @Inject constructor(
                         if (mappedCountry == null) Timber.e("Unknown countrycode: %s", rawCode)
                         mappedCountry
                     }
-                _countryList.postValue(countries)
+                countryListFlowInternal.value = countries
                 Timber.d("Country list: ${TextUtils.join(System.lineSeparator(), countries)}")
             } catch (e: Exception) {
                 Timber.e(e)
-                _countryList.postValue(listOf())
+                countryListFlowInternal.value = emptyList()
             }
         }
     }
 
     fun clear() {
-        _countryList.postValue(emptyList())
+        countryListFlowInternal.value = emptyList()
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt
index e7a6c4179315a9f031c59ae5d97094ed5f7894b4..037dfc69052e4aa8674fde07ce589b8b2ce04f70 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt
@@ -31,10 +31,9 @@ class DaysSinceOnsetOfSymptomsVectorDeterminator @Inject constructor(
     @Suppress("MagicNumber")
     private fun determinePositiveIndication(symptoms: Symptoms): DaysSinceOnsetOfSymptomsVector {
         return when (symptoms.startOfSymptoms) {
-            is Symptoms.StartOf.Date ->
-                createDaysSinceOnsetOfSymptomsVectorWith(
-                    symptoms.startOfSymptoms.date.ageInDays(timeStamper.nowUTC.toLocalDate())
-                )
+            is Symptoms.StartOf.Date -> createDaysSinceOnsetOfSymptomsVectorWith(
+                symptoms.startOfSymptoms.date.ageInDays(timeStamper.nowUTC.toLocalDate())
+            )
             is Symptoms.StartOf.LastSevenDays ->
                 createDaysSinceOnsetOfSymptomsVectorWith(701)
             is Symptoms.StartOf.OneToTwoWeeksAgo ->
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7b605f10eaed4cc0b218eb2819301e931d15c7a3
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt
@@ -0,0 +1,110 @@
+package de.rki.coronawarnapp.submission
+
+import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.playbook.Playbook
+import de.rki.coronawarnapp.server.protocols.external.exposurenotification.TemporaryExposureKeyExportOuterClass
+import de.rki.coronawarnapp.service.submission.SubmissionService
+import de.rki.coronawarnapp.task.Task
+import de.rki.coronawarnapp.task.TaskCancellationException
+import de.rki.coronawarnapp.task.TaskFactory
+import de.rki.coronawarnapp.task.common.DefaultProgress
+import kotlinx.coroutines.channels.ConflatedBroadcastChannel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import org.joda.time.Duration
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Provider
+
+class SubmissionTask @Inject constructor(
+    private val playbook: Playbook,
+    private val appConfigProvider: AppConfigProvider,
+    private val exposureKeyHistoryCalculations: ExposureKeyHistoryCalculations
+) : Task<DefaultProgress, Task.Result> {
+
+    private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>()
+    override val progress: Flow<DefaultProgress> = internalProgress.asFlow()
+
+    private var isCanceled = false
+
+    override suspend fun run(arguments: Task.Arguments) = try {
+        Timber.d("Running with arguments=%s", arguments)
+        arguments as Arguments
+
+        Playbook.SubmissionData(
+            arguments.registrationToken,
+            arguments.getHistory(),
+            true,
+            getSupportedCountries()
+        )
+            .also { checkCancel() }
+            .let { playbook.submit(it) }
+
+        SubmissionService.submissionSuccessful()
+
+        object : Task.Result {}
+    } catch (error: Exception) {
+        Timber.tag(TAG).e(error)
+        throw error
+    } finally {
+        Timber.i("Finished (isCanceled=$isCanceled).")
+        internalProgress.close()
+    }
+
+    private fun Arguments.getHistory(): List<TemporaryExposureKeyExportOuterClass.TemporaryExposureKey> =
+        exposureKeyHistoryCalculations.transformToKeyHistoryInExternalFormat(
+            keys,
+            symptoms
+        )
+
+    private suspend fun getSupportedCountries(): List<String> {
+        val countries = appConfigProvider.getAppConfig().supportedCountries
+        return when {
+            countries.isEmpty() -> {
+                Timber.w("Country list was empty, corrected")
+                listOf(FALLBACK_COUNTRY)
+            }
+            else -> countries
+        }.also { Timber.i("Supported countries = $it") }
+    }
+
+    private fun checkCancel() {
+        if (isCanceled) throw TaskCancellationException()
+    }
+
+    override suspend fun cancel() {
+        Timber.w("cancel() called.")
+        isCanceled = true
+    }
+
+    class Arguments(
+        val registrationToken: String,
+        val keys: List<TemporaryExposureKey>,
+        val symptoms: Symptoms
+    ) : Task.Arguments
+
+    data class Config(
+        @Suppress("MagicNumber")
+        override val executionTimeout: Duration = Duration.standardMinutes(8), // TODO unit-test that not > 9 min
+
+        override val collisionBehavior: TaskFactory.Config.CollisionBehavior =
+            TaskFactory.Config.CollisionBehavior.ENQUEUE
+
+    ) : TaskFactory.Config
+
+    class Factory @Inject constructor(
+        private val taskByDagger: Provider<SubmissionTask>
+    ) : TaskFactory<DefaultProgress, Task.Result> {
+
+        override val config: TaskFactory.Config = Config()
+        override val taskProvider: () -> Task<DefaultProgress, Task.Result> = {
+            taskByDagger.get()
+        }
+    }
+
+    companion object {
+        private const val FALLBACK_COUNTRY = "DE"
+        private val TAG: String? = SubmissionTask::class.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTaskModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTaskModule.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ed52d5cb0dde238271716249f33d1efb50d1b338
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTaskModule.kt
@@ -0,0 +1,19 @@
+package de.rki.coronawarnapp.submission
+
+import dagger.Binds
+import dagger.Module
+import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.task.Task
+import de.rki.coronawarnapp.task.TaskFactory
+import de.rki.coronawarnapp.task.TaskTypeKey
+
+@Module
+abstract class SubmissionTaskModule {
+
+    @Binds
+    @IntoMap
+    @TaskTypeKey(SubmissionTask::class)
+    abstract fun submissionTaskFactory(
+        factory: SubmissionTask.Factory
+    ): TaskFactory<out Task.Progress, out Task.Result>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/Symptoms.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/Symptoms.kt
index 13e80e20a5f75dd4014b4f7597914124338ed71f..769df6264d8d24e3abd186fd2acfd57e110e30e4 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/Symptoms.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/Symptoms.kt
@@ -1,23 +1,43 @@
 package de.rki.coronawarnapp.submission
 
+import android.os.Parcelable
+import kotlinx.android.parcel.Parcelize
 import org.joda.time.LocalDate
 
+@Parcelize
 data class Symptoms(
     val startOfSymptoms: StartOf?,
     val symptomIndication: Indication
-) {
-    sealed class StartOf {
+) : Parcelable {
 
+    sealed class StartOf : Parcelable {
+        @Parcelize
         data class Date(val date: LocalDate) : StartOf()
+
+        @Parcelize
         object LastSevenDays : StartOf()
+
+        @Parcelize
         object OneToTwoWeeksAgo : StartOf()
+
+        @Parcelize
         object MoreThanTwoWeeks : StartOf()
+
+        @Parcelize
         object NoInformation : StartOf()
     }
 
-    enum class Indication {
+    @Parcelize
+    enum class Indication : Parcelable {
         POSITIVE,
         NEGATIVE,
         NO_INFORMATION
     }
+
+    companion object {
+        val NO_INFO_GIVEN = Symptoms(
+            startOfSymptoms = null, // FIXME  should this be null?
+            symptomIndication = Indication.NO_INFORMATION
+        )
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/common/DefaultTaskRequest.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/common/DefaultTaskRequest.kt
index 03c85b3e6e9e5a6eb5e9dd42da4515ee9665615c..df111a364ba660bc00759312bd44578ea3c371b0 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/common/DefaultTaskRequest.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/common/DefaultTaskRequest.kt
@@ -6,9 +6,9 @@ import java.util.UUID
 import kotlin.reflect.KClass
 
 data class DefaultTaskRequest(
-    override val id: UUID = UUID.randomUUID(),
     override val type: KClass<out Task<Task.Progress, Task.Result>>,
-    override val arguments: Task.Arguments
+    override val arguments: Task.Arguments = object : Task.Arguments {},
+    override val id: UUID = UUID.randomUUID()
 ) : TaskRequest {
 
     fun toNewTask(): DefaultTaskRequest = copy(id = UUID.randomUUID())
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
index 036905b788a065b9b14684da8811ad8b42bd32f4..493bb0c9e41d74b398d64b5c83012a72da296d5f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
@@ -26,7 +26,6 @@ import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.environment.EnvironmentSetup
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.API_SUBMISSION
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.CLOSE
@@ -195,11 +194,10 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
         val countries = if (environmentSetup.useEuropeKeyPackageFiles) {
             listOf("EUR")
         } else {
-            requestedCountries ?: ApplicationConfigurationService
-                .asyncRetrieveApplicationConfiguration()
-                .supportedCountriesList
+            requestedCountries
+                ?: AppInjector.component.appConfigProvider.getAppConfig().supportedCountries
         }
-            invokeSubmissionStartedInDebugOrBuildMode()
+        invokeSubmissionStartedInDebugOrBuildMode()
 
         val availableKeyFiles = executeFetchKeyFilesFromServer(countries)
 
@@ -295,7 +293,7 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
      */
     private suspend fun executeRetrieveRiskScoreParams() =
         executeState(RETRIEVE_RISK_SCORE_PARAMS) {
-            ApplicationConfigurationService.asyncRetrieveExposureConfiguration()
+            AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration
         }
 
     /**
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RiskLevelInjectionHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RiskLevelInjectionHelper.kt
deleted file mode 100644
index f5e6689c6319a39c2dd5d9eb59ad0a12c6919d2c..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RiskLevelInjectionHelper.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package de.rki.coronawarnapp.transaction
-
-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 RiskLevelInjectionHelper @Inject constructor(
-    val transactionScope: TransactionCoroutineScope
-)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RiskLevelTransaction.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RiskLevelTransaction.kt
deleted file mode 100644
index 48162816d9a1f3d18fcb0360a398a7f7e3eff4a8..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RiskLevelTransaction.kt
+++ /dev/null
@@ -1,611 +0,0 @@
-package de.rki.coronawarnapp.transaction
-
-import androidx.core.app.NotificationCompat
-import com.google.android.gms.nearby.exposurenotification.ExposureSummary
-import de.rki.coronawarnapp.CoronaWarnApplication
-import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.exception.RiskLevelCalculationException
-import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
-import de.rki.coronawarnapp.notification.NotificationHelper
-import de.rki.coronawarnapp.risk.DefaultRiskLevelCalculation
-import de.rki.coronawarnapp.risk.DefaultRiskScoreAnalysis
-import de.rki.coronawarnapp.risk.RiskLevel
-import de.rki.coronawarnapp.risk.RiskLevel.INCREASED_RISK
-import de.rki.coronawarnapp.risk.RiskLevel.LOW_LEVEL_RISK
-import de.rki.coronawarnapp.risk.RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF
-import de.rki.coronawarnapp.risk.RiskLevel.UNDETERMINED
-import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_INITIAL
-import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS
-import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
-import de.rki.coronawarnapp.risk.RiskLevelCalculation
-import de.rki.coronawarnapp.risk.RiskLevelConstants
-import de.rki.coronawarnapp.risk.RiskScoreAnalysis
-import de.rki.coronawarnapp.risk.TimeVariables
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
-import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass
-import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
-import de.rki.coronawarnapp.storage.LocalData
-import de.rki.coronawarnapp.storage.RiskLevelRepository
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.CHECK_APP_CONNECTIVITY
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.CHECK_INCREASED_RISK
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.CHECK_TRACING
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.CHECK_UNKNOWN_RISK_INITIAL_NO_KEYS
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.CHECK_UNKNOWN_RISK_INITIAL_TRACING_DURATION
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.CHECK_UNKNOWN_RISK_OUTDATED
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.CLOSE
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.RETRIEVE_APPLICATION_CONFIG
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.RETRIEVE_EXPOSURE_SUMMARY
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.RISK_CALCULATION_DATE_UPDATE
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction.RiskLevelTransactionState.UPDATE_RISK_LEVEL
-import de.rki.coronawarnapp.util.ConnectivityHelper
-import de.rki.coronawarnapp.util.TimeAndDateExtensions.millisecondsToHours
-import de.rki.coronawarnapp.util.di.AppInjector
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import timber.log.Timber
-import java.util.concurrent.atomic.AtomicReference
-
-/**
- * The [RiskLevelTransaction] is used to define an atomic Transaction for the Risk Level Calculation.
- * This transaction calculates different Risk Level Scores based on dynamic values and defined constants.
- *
- * In the different states the conditions for all risk level will be checked and if a risk level is found
- * the value will be persisted. Please have a look at the [de.rki.coronawarnapp.risk.RiskLevel] enum class
- * to see all available technical risk level. At the start of the Transaction the risk level is defined
- * as [de.rki.coronawarnapp.risk.RiskLevel.UNDETERMINED]. The different execution states will define the correct
- * risk level and execute the [UPDATE_RISK_LEVEL] & [CLOSE] block to exit the Transaction and
- * prevent further manipulating of the risk level.
- *
- * There is currently a simple rollback behavior defined for this transaction. This means that the last calculated
- * risk level before the transaction started will be set if there is an error.
- *
- * The logic for the risk level calculation is the following:
- * We have 5 different technical risk levels:
- * 1. [NO_CALCULATION_POSSIBLE_TRACING_OFF]
- * 2. [UNKNOWN_RISK_OUTDATED_RESULTS]
- * 3. [INCREASED_RISK]
- * 4. [UNKNOWN_RISK_INITIAL]
- * 5. [LOW_LEVEL_RISK]
- *
- * HIERARCHY EXPLANATION
- *
- * [NO_CALCULATION_POSSIBLE_TRACING_OFF] will always be displayed no matter if a condition of a different risk level
- * is also applicable. It will overrule all other risk levels.
- *
- * [UNKNOWN_RISK_OUTDATED_RESULTS] will overrule [INCREASED_RISK] and [LOW_LEVEL_RISK].
- * This will ensure that the user has the most recent diagnosis keys available.
- *
- * [INCREASED_RISK] will overrule [UNKNOWN_RISK_INITIAL] and [LOW_LEVEL_RISK].
- *
- * [UNKNOWN_RISK_INITIAL] will overrule [LOW_LEVEL_RISK] and [UNKNOWN_RISK_OUTDATED_RESULTS].
- *
- *
- * RISK LEVEL CALCULATION
- *
- * [NO_CALCULATION_POSSIBLE_TRACING_OFF] is calculated if the tracing is disabled at the moment. This will be determined
- * by the current state of the Exposure Notification API (isEnabled function call).
- *
- * [UNKNOWN_RISK_OUTDATED_RESULTS] is calculated if the [RetrieveDiagnosisKeysTransaction] was executed more
- * than [de.rki.coronawarnapp.risk.TimeVariables.MAX_STALE_EXPOSURE_RISK_RANGE] time ago and new diagnosis keys are
- * needed from the backend to calculate an accurate risk level.
- *
- * [INCREASED_RISK] is calculated if the Google Exposure Notification API has provided a valid Exposure Summary and
- * the maximumRiskScore of this summary exceeds the defined threshold that will be retrieved from the
- * backend. The backend call is necessary so that experts can adapt the thresholds in sync with the current
- * development of the pandemic without the need to update the app. This ensures that every app will use
- * the current and correct threshold to calculate the risk level.
- *
- * [UNKNOWN_RISK_INITIAL] is calculated if the user has not retrieved any diagnosis keys from the server (the
- * [RetrieveDiagnosisKeysTransaction] was never executed) and a calculation therefore is simply not possible
- * because the Google Exposure Notification Framework needs those Keys to generate a match.
- * This risk level is also shown to the user if there was a previous Diagnosis Keys Retrieval but the initial
- * tracing activation started less than [de.rki.coronawarnapp.risk.TimeVariables.MIN_ACTIVATED_TRACING_TIME] ago.
- *
- * [LOW_LEVEL_RISK] is applied if the conditions of the above are not met.
- *
- *
- * TRANSACTION EXECUTION
- *
- * The Transaction undergoes multiple States:
- * 1. [CHECK_TRACING]
- * 2. [CHECK_UNKNOWN_RISK_INITIAL_NO_KEYS]
- * 3. [CHECK_UNKNOWN_RISK_OUTDATED]
- * 4. [CHECK_APP_CONNECTIVITY]
- * 5. [RETRIEVE_APPLICATION_CONFIG]
- * 6. [RETRIEVE_EXPOSURE_SUMMARY]
- * 7. [CHECK_INCREASED_RISK]
- * 8. [CHECK_UNKNOWN_RISK_INITIAL_TRACING_DURATION]
- * 9. [UPDATE_RISK_LEVEL]
- * 10. [RISK_CALCULATION_DATE_UPDATE]
- * 11. [CLOSE]
- *
- * This transaction will queue up any start calls and executes them in the given order (unlike the other defined
- * transactions (e.g. [RetrieveDiagnosisKeysTransaction]). This is necessary in order to respond to various trigger
- * events that can occur at any time and will change the previously calculated Risk Level Score.
- *
- * @see Transaction
- *
- * @throws de.rki.coronawarnapp.exception.TransactionException An Exception thrown when an error occurs during Transaction Execution
- * @throws de.rki.coronawarnapp.exception.RollbackException An Exception thrown when an error occurs during Rollback of the Transaction
- */
-object RiskLevelTransaction : Transaction() {
-
-    override val TAG: String? = RiskLevelTransaction::class.simpleName
-
-    // @Inject lateinit var riskLevelCalculation: RiskLevelCalculation
-    // TODO pass instance of this to constructor as soon as RiskLevelTransaction is converted to a class
-    // Injecting here will break Test
-    private val riskLevelCalculation: RiskLevelCalculation = DefaultRiskLevelCalculation()
-
-    /**
-     * The maximal runtime of the Risk Level transaction
-     * In milliseconds
-     */
-    private const val RISK_LEVEL_TRANSACTION_TIMEOUT = 480 * 1000L
-
-    // @Inject lateinit var riskScoreAnalysis: RiskScoreAnalysis
-    // TODO pass instance of this to constructor as soon as RiskLevelTransaction is converted to a class
-    // Injecting here will break Test
-    private val riskScoreAnalysis: RiskScoreAnalysis = DefaultRiskScoreAnalysis()
-
-    /** possible transaction states */
-    private enum class RiskLevelTransactionState : TransactionState {
-        /** Check the conditions for the [NO_CALCULATION_POSSIBLE_TRACING_OFF] score */
-        CHECK_TRACING,
-
-        /** Check if the user has started tracing and if he has executed the
-         *  [RetrieveDiagnosisKeysTransaction] at least once */
-        CHECK_UNKNOWN_RISK_INITIAL_NO_KEYS,
-
-        /** Check the conditions for the [UNKNOWN_RISK_OUTDATED_RESULTS] score */
-        CHECK_UNKNOWN_RISK_OUTDATED,
-
-        /** Check if the current app has internet, if not, use the last successful  */
-        CHECK_APP_CONNECTIVITY,
-
-        /** Retrieve the Application Configuration values to calculate the Risk Score
-         * and determine the [INCREASED_RISK] and [LOW_LEVEL_RISK] */
-        RETRIEVE_APPLICATION_CONFIG,
-
-        /** Retrieve the last persisted [ExposureSummary] (if available) from the Google Exposure Notification API for
-         * further calculation of the Risk Level Score */
-        RETRIEVE_EXPOSURE_SUMMARY,
-
-        /** Check the conditions for the [INCREASED_RISK] score */
-        CHECK_INCREASED_RISK,
-
-        /** Check the conditions for the [UNKNOWN_RISK_INITIAL] score */
-        CHECK_UNKNOWN_RISK_INITIAL_TRACING_DURATION,
-
-        /** Update and persist the Risk Level Score with the calculated score */
-        UPDATE_RISK_LEVEL,
-
-        /** Update of the Date to reflect a complete Transaction State */
-        RISK_CALCULATION_DATE_UPDATE,
-
-        /** Transaction Closure */
-        CLOSE
-    }
-
-    /** atomic reference for the rollback value for the last calculated risk level score */
-    private val lastCalculatedRiskLevelScoreForRollback = AtomicReference<RiskLevel>()
-
-    /** atomic reference for the rollback value for date of last risk level calculation */
-    private val lastCalculatedRiskLevelDate = AtomicReference<Long>()
-
-    private val transactionScope: TransactionCoroutineScope by lazy {
-        AppInjector.component.transRiskLevelInjection.transactionScope
-    }
-
-    /** initiates the transaction. This suspend function guarantees a successful transaction once completed. */
-    suspend fun start() = lockAndExecute(
-        scope = transactionScope,
-        timeout = RISK_LEVEL_TRANSACTION_TIMEOUT
-    ) {
-        /****************************************************
-         * CHECK [NO_CALCULATION_POSSIBLE_TRACING_OFF] CONDITIONS
-         ****************************************************/
-        var result = executeCheckTracing()
-        if (isValidResult(result)) return@lockAndExecute
-
-        /****************************************************
-         * CHECK [UNKNOWN_RISK_INITIAL] FOR
-         * INIT STATE CONDITIONS
-         ****************************************************/
-        result = executeCheckUnknownRiskInitialNoKeys()
-        if (isValidResult(result)) return@lockAndExecute
-
-        /****************************************************
-         * CHECK [UNKNOWN_RISK_OUTDATED_RESULTS] CONDITIONS
-         ****************************************************/
-        result = executeCheckUnknownRiskOutdatedResults()
-        if (isValidResult(result)) return@lockAndExecute
-
-        /****************************************************
-         * [CHECK_APP_CONNECTIVITY]
-         ****************************************************/
-        if (!executeCheckAppConnectivity()) {
-            executeClose()
-            return@lockAndExecute
-        }
-
-        /****************************************************
-         * RETRIEVE APPLICATION CONFIGURATION
-         ****************************************************/
-        val appConfiguration = executeRetrieveApplicationConfiguration()
-
-        /****************************************************
-         * RETRIEVE EXPOSURE SUMMARY
-         ****************************************************/
-        val lastExposureSummary = executeRetrieveExposureSummary()
-
-        /****************************************************
-         * CHECK [INCREASED_RISK] CONDITIONS
-         ****************************************************/
-        result = executeCheckIncreasedRisk(appConfiguration, lastExposureSummary)
-        if (isValidResult(result)) return@lockAndExecute
-
-        /****************************************************
-         * CHECK [UNKNOWN_RISK_INITIAL] FOR TRACING
-         * DURATION THRESHOLD
-         ****************************************************/
-        result = executeCheckUnknownRiskInitialTracingDuration()
-        if (isValidResult(result)) return@lockAndExecute
-
-        /****************************************************
-         * SET [LOW_LEVEL_RISK] LEVEL IF NONE ABOVE APPLIED
-         ****************************************************/
-        if (result == UNDETERMINED) {
-            lastCalculatedRiskLevelScoreForRollback.set(RiskLevelRepository.getLastCalculatedScore())
-            executeUpdateRiskLevelScore(LOW_LEVEL_RISK)
-            executeRiskLevelCalculationDateUpdate()
-            executeClose()
-            return@lockAndExecute
-        } else {
-            throw RiskLevelCalculationException(IllegalStateException("no risk level could be determined"))
-        }
-    }
-
-    override suspend fun rollback() {
-        super.rollback()
-        try {
-            if (UPDATE_RISK_LEVEL.isInStateStack()) {
-                updateRiskLevelScore(lastCalculatedRiskLevelScoreForRollback.get())
-            }
-            if (RISK_CALCULATION_DATE_UPDATE.isInStateStack()) {
-                LocalData.lastTimeRiskLevelCalculation(lastCalculatedRiskLevelDate.get())
-            }
-        } catch (e: Exception) {
-            // We handle every exception through a RollbackException to make sure that a single EntryPoint
-            // is available for the caller.
-            handleRollbackError(e)
-        }
-    }
-
-    /**
-     * Executes the [CHECK_TRACING] Transaction State
-     */
-    private suspend fun executeCheckTracing(): RiskLevel = executeState(CHECK_TRACING) {
-        // this applies if tracing is not activated
-        val isTracingEnabled = InternalExposureNotificationClient.asyncIsEnabled()
-        if (!isTracingEnabled) return@executeState NO_CALCULATION_POSSIBLE_TRACING_OFF
-
-        Timber.tag(TAG).v("$transactionId - TRACING_NOT_ACTIVE_RISK not applicable")
-        return@executeState UNDETERMINED
-    }
-
-    /**
-     * Executes the [CHECK_UNKNOWN_RISK_INITIAL_NO_KEYS] Transaction State
-     */
-    private suspend fun executeCheckUnknownRiskInitialNoKeys(): RiskLevel = executeState(
-        CHECK_UNKNOWN_RISK_INITIAL_NO_KEYS
-    ) {
-        // if there was no key retrieval before, we return no calculation state
-        TimeVariables.getLastTimeDiagnosisKeysFromServerFetch()
-            ?: return@executeState UNKNOWN_RISK_INITIAL.also {
-                Timber.tag(TAG).v(
-                    "$transactionId - no last time diagnosis keys from server fetch timestamp was found"
-                )
-            }
-
-        Timber.tag(TAG).v("$transactionId - CHECK_UNKNOWN_RISK_INITIAL_NO_KEYS not applicable")
-        return@executeState UNDETERMINED
-    }
-
-    /**
-     * Executes the [CHECK_UNKNOWN_RISK_OUTDATED] Transaction State
-     */
-    private suspend fun executeCheckUnknownRiskOutdatedResults(): RiskLevel =
-        executeState(CHECK_UNKNOWN_RISK_OUTDATED) {
-
-            // if the last calculation is longer in the past as the defined threshold we return the stale state
-            val timeSinceLastDiagnosisKeyFetchFromServer =
-                TimeVariables.getTimeSinceLastDiagnosisKeyFetchFromServer()
-                    ?: throw RiskLevelCalculationException(
-                        IllegalArgumentException("time since last exposure calculation is null")
-                    )
-
-            /** we only return outdated risk level if the threshold is reached AND the active tracing time is above the
-            defined threshold because [UNKNOWN_RISK_INITIAL] overrules [UNKNOWN_RISK_OUTDATED_RESULTS] */
-            if (timeSinceLastDiagnosisKeyFetchFromServer.millisecondsToHours() >
-                TimeVariables.getMaxStaleExposureRiskRange() && isActiveTracingTimeAboveThreshold()
-            ) {
-                if (ConnectivityHelper.autoModeEnabled(CoronaWarnApplication.getAppContext())) {
-                    return@executeState UNKNOWN_RISK_OUTDATED_RESULTS.also {
-                        Timber.tag(TAG).v(
-                            "diagnosis keys outdated and active tracing time is above threshold"
-                        )
-                        Timber.tag(TAG).v("manual mode not active (background jobs enabled)")
-                    }
-                } else {
-                    return@executeState UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL.also {
-                        Timber.tag(TAG).v(
-                            "diagnosis keys outdated and active tracing time is above threshold"
-                        )
-                        Timber.tag(TAG).v("manual mode active (background jobs disabled)")
-                    }
-                }
-            }
-
-            Timber.tag(TAG).v("$transactionId - CHECK_UNKNOWN_RISK_OUTDATED not applicable")
-            return@executeState UNDETERMINED
-        }
-
-    /**
-     * Executes the [CHECK_APP_CONNECTIVITY] Transaction State
-     * If there is no connectivity the transaction will set the last calculated
-     * risk level and closes the transaction
-     *
-     * @return
-     */
-    private suspend fun executeCheckAppConnectivity(): Boolean =
-        executeState(CHECK_APP_CONNECTIVITY) {
-            val isNetworkEnabled =
-                ConnectivityHelper.isNetworkEnabled(CoronaWarnApplication.getAppContext())
-
-            if (!isNetworkEnabled) {
-                RiskLevelRepository.setLastCalculatedRiskLevelAsCurrent()
-                return@executeState false
-            }
-            return@executeState true
-        }
-
-    /**
-     * Executes the [RETRIEVE_APPLICATION_CONFIG] Transaction State
-     *
-     * @return the values of the application configuration
-     */
-    private suspend fun executeRetrieveApplicationConfiguration():
-        ApplicationConfiguration =
-        executeState(RETRIEVE_APPLICATION_CONFIG) {
-            return@executeState getApplicationConfiguration()
-                .also {
-                    Timber.tag(TAG).v("$transactionId - retrieved configuration from backend")
-                }
-        }
-
-    /**
-     * Executes the [RETRIEVE_EXPOSURE_SUMMARY] Transaction State
-     */
-    private suspend fun executeRetrieveExposureSummary(): ExposureSummary =
-        executeState(RETRIEVE_EXPOSURE_SUMMARY) {
-            val exposureSummary = getNewExposureSummary()
-
-            return@executeState exposureSummary.also {
-                Timber.tag(TAG).v(
-                    "$transactionId - get the exposure summary for further calculation"
-                )
-            }
-        }
-
-    /**
-     * Executes the [CHECK_INCREASED_RISK] Transaction State
-     */
-    private suspend fun executeCheckIncreasedRisk(
-        appConfig: ApplicationConfiguration,
-        exposureSummary: ExposureSummary
-    ): RiskLevel =
-        executeState(CHECK_INCREASED_RISK) {
-
-            // custom attenuation parameters to weight the attenuation
-            // values provided by the Google API
-            val attenuationParameters = appConfig.attenuationDuration
-
-            // these are the defined risk classes. They will divide the calculated
-            // risk score into the low and increased risk
-            val riskScoreClassification = appConfig.riskScoreClasses
-
-            return@executeState getRiskLevel(
-                riskLevelCalculation,
-                riskScoreAnalysis,
-                attenuationParameters,
-                exposureSummary,
-                riskScoreClassification
-            )
-        }
-
-    fun getRiskLevel(
-        riskLevelCalculation: RiskLevelCalculation,
-        riskScoreAnalysis: RiskScoreAnalysis,
-        attenuationParameters: AttenuationDurationOuterClass.AttenuationDuration,
-        exposureSummary: ExposureSummary,
-        riskScoreClassification: RiskScoreClassificationOuterClass.RiskScoreClassification
-    ): RiskLevel {
-        // calculate the risk score based on the values collected by the Google EN API and
-        // the backend configuration
-        val riskScore = riskLevelCalculation.calculateRiskScore(
-            attenuationParameters,
-            exposureSummary
-        ).also {
-            Timber.tag(TAG).v("calculated risk with the given config: $it")
-        }
-
-        // get the high risk score class
-        val highRiskScoreClass =
-            riskScoreClassification.riskClassesList.find { it.label == "HIGH" }
-                ?: throw RiskLevelCalculationException(IllegalStateException("no high risk score class found"))
-
-        // if the calculated risk score is above the defined level threshold we return the high level risk score
-        if (riskScoreAnalysis.withinDefinedLevelThreshold(
-                riskScore,
-                highRiskScoreClass.min,
-                highRiskScoreClass.max
-            )
-        ) {
-            Timber.tag(TAG).v("$riskScore is above the defined min value ${highRiskScoreClass.min}")
-            return INCREASED_RISK
-        } else if (riskScore > highRiskScoreClass.max) {
-            throw RiskLevelCalculationException(
-                IllegalStateException("risk score is above the max threshold for score class")
-            )
-        }
-
-        Timber.tag(TAG).v("$transactionId - INCREASED_RISK not applicable")
-        return UNDETERMINED
-    }
-
-    /**
-     * Executes the [CHECK_UNKNOWN_RISK_INITIAL_TRACING_DURATION] Transaction State
-     */
-    private suspend fun executeCheckUnknownRiskInitialTracingDuration() = executeState(
-        CHECK_UNKNOWN_RISK_INITIAL_TRACING_DURATION
-    ) {
-        // if the active tracing duration is not above the defined threshold we return no calculation state
-        if (!isActiveTracingTimeAboveThreshold()) {
-            Timber.tag(TAG).v("$transactionId - active tracing time is not enough")
-            return@executeState UNKNOWN_RISK_INITIAL
-        }
-
-        Timber.tag(TAG).v("$transactionId - UNKNOWN_RISK_INITIAL not applicable")
-        return@executeState UNDETERMINED
-    }
-
-    /**
-     * Executes the [UPDATE_RISK_LEVEL] Transaction State
-     */
-    private suspend fun executeUpdateRiskLevelScore(riskLevel: RiskLevel) =
-        executeState(UPDATE_RISK_LEVEL) {
-            Timber.tag(TAG).v("$transactionId - update the risk level with $riskLevel")
-            updateRiskLevelScore(riskLevel)
-        }
-
-    /**
-     * Executes the [CLOSE] Transaction State
-     */
-    private suspend fun executeClose() = executeState(CLOSE) {
-        Timber.tag(TAG).v("$transactionId - transaction will close")
-        lastCalculatedRiskLevelScoreForRollback.set(null)
-        lastCalculatedRiskLevelDate.set(null)
-    }
-
-    /****************************************************
-     * HELPER AND GETTER FUNCTIONS
-     ****************************************************/
-
-    /**
-     * Checks if the result of an executed state returns a valid Risk Level Score value.
-     * If that is the case the [RiskLevelTransaction] can update the Risk Level Score with the calculated
-     * value and close the transaction
-     *
-     * @param riskLevel calculated Risk Level Score
-     * @return if a valid score was already found
-     */
-    private suspend fun isValidResult(riskLevel: RiskLevel): Boolean {
-        if (riskLevel != UNDETERMINED) {
-            Timber.tag(TAG).d(
-                "$transactionId - $riskLevel was determined by the transaction. UPDATE and CLOSE will be called"
-            )
-            lastCalculatedRiskLevelScoreForRollback.set(RiskLevelRepository.getLastCalculatedScore())
-            executeUpdateRiskLevelScore(riskLevel)
-            lastCalculatedRiskLevelDate.set(LocalData.lastTimeRiskLevelCalculation())
-            executeRiskLevelCalculationDateUpdate()
-            executeClose()
-            return true
-        }
-        return false
-    }
-
-    /**
-     * Make a call to the backend to retrieve the current application configuration values
-     *
-     * @return the [ApplicationConfigurationOuterClass.ApplicationConfiguration] from the backend
-     */
-    private suspend fun getApplicationConfiguration(): ApplicationConfiguration =
-        withContext(Dispatchers.Default) {
-            return@withContext ApplicationConfigurationService.asyncRetrieveApplicationConfiguration()
-                .also { Timber.tag(TAG).d("configuration from backend: $it") }
-        }
-
-    /**
-     * Returns a Boolean if the duration of the activated tracing time is above the
-     * defined value
-     *
-     * @return Boolean if threshold is reached
-     */
-    private fun isActiveTracingTimeAboveThreshold(): Boolean {
-        val durationTracingIsActive = TimeVariables.getTimeActiveTracingDuration()
-        val durationTracingIsActiveThreshold = TimeVariables.getMinActivatedTracingTime().toLong()
-
-        val activeTracingDurationInHours = durationTracingIsActive.millisecondsToHours()
-
-        return (activeTracingDurationInHours >= durationTracingIsActiveThreshold).also {
-            Timber.tag(TAG).v(
-                "active tracing time ($activeTracingDurationInHours h) is above threshold " +
-                    "($durationTracingIsActiveThreshold h): $it"
-            )
-        }
-    }
-
-    /**
-     * Updates the Risk Level Score in the repository with the calculated Risk Level
-     *
-     * @param riskLevel
-     */
-    private fun updateRiskLevelScore(riskLevel: RiskLevel) {
-        val lastCalculatedScore = RiskLevelRepository.getLastCalculatedScore()
-        if (RiskLevel.riskLevelChangedBetweenLowAndHigh(
-                lastCalculatedScore,
-                riskLevel
-            ) && !LocalData.submissionWasSuccessful()
-        ) {
-            NotificationHelper.sendNotification(
-                CoronaWarnApplication.getAppContext().getString(R.string.notification_body),
-                NotificationCompat.PRIORITY_HIGH
-            )
-        }
-        if (lastCalculatedScore.raw == RiskLevelConstants.INCREASED_RISK &&
-            riskLevel.raw == RiskLevelConstants.LOW_LEVEL_RISK) {
-            LocalData.isUserToBeNotifiedOfLoweredRiskLevel = true
-        }
-            RiskLevelRepository.setRiskLevelScore(riskLevel)
-    }
-
-    /**
-     * If there is no persisted exposure summary we try to get a new one with the last persisted
-     * Google API token that was used in the [de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction]
-     *
-     * @return a exposure summary from the Google Exposure Notification API
-     */
-    private suspend fun getNewExposureSummary(): ExposureSummary {
-        val googleToken = LocalData.googleApiToken()
-            ?: throw RiskLevelCalculationException(IllegalStateException("exposure summary is not persisted"))
-
-        val exposureSummary =
-            InternalExposureNotificationClient.asyncGetExposureSummary(googleToken)
-
-        return exposureSummary.also {
-            Timber.tag(TAG).v("$transactionId - generated new exposure summary with $googleToken")
-        }
-    }
-
-    /**
-     * Executes the CALCULATION_DATE_UPDATE Transaction State
-     */
-    private suspend fun executeRiskLevelCalculationDateUpdate() {
-        val currentDate = System.currentTimeMillis()
-        executeState(RISK_CALCULATION_DATE_UPDATE) {
-            LocalData.lastTimeRiskLevelCalculation(currentDate)
-        }
-    }
-}
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
deleted file mode 100644
index 7203a78381ac141fa61c139c50cd76810f6848d6..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisInjectionHelper.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package de.rki.coronawarnapp.transaction
-
-import de.rki.coronawarnapp.submission.ExposureKeyHistoryCalculations
-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 playbook: Playbook,
-    val appConfigProvider: AppConfigProvider,
-    val exposureKeyHistoryCalculations: ExposureKeyHistoryCalculations
-)
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
deleted file mode 100644
index 9e7c32caa40e2bf9f6fda68344b73187334f27c7..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransaction.kt
+++ /dev/null
@@ -1,111 +0,0 @@
-package de.rki.coronawarnapp.transaction
-
-import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
-import de.rki.coronawarnapp.appconfig.AppConfigProvider
-import de.rki.coronawarnapp.appconfig.toNewConfig
-import de.rki.coronawarnapp.playbook.Playbook
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
-import de.rki.coronawarnapp.service.submission.SubmissionService
-import de.rki.coronawarnapp.submission.ExposureKeyHistoryCalculations
-import de.rki.coronawarnapp.submission.Symptoms
-import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDiagnosisKeysTransactionState.CLOSE
-import de.rki.coronawarnapp.transaction.SubmitDiagnosisKeysTransaction.SubmitDiagnosisKeysTransactionState.RETRIEVE_TAN_AND_SUBMIT_KEYS
-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
- * isolated work area that can recover from failures and keep a consistent key state even through an
- * unclear, potentially dangerous state within the transaction itself. It is guaranteed that the Key Files
- * that are used in the transaction will be generated, submitted and accepted from the Google API once the transaction
- * has completed its work and returned from the start() coroutine.
- *
- * There is currently a simple rollback behavior needed / identified.
- *
- * The Transaction undergoes multiple States:
- * 1. RETRIEVE_TAN - Fetch the TAN with the provided Registration Token
- * 2. RETRIEVE_TEMPORARY_EXPOSURE_KEY_HISTORY - Get the TEKs from the exposure notification framework
- * 3. SUBMIT_KEYS - Submission of the diagnosis keys to the Server
- * 4. CLOSE - Transaction Closure
- *
- * This transaction is special in terms of concurrent entry-calls (e.g. calling the transaction again before it closes and
- * releases its internal mutex. The transaction will not queue up like a normal mutex, but instead completely omit the last
- * execution. Execution Privilege is First In.
- *
- * @see Transaction
- *
- * @throws de.rki.coronawarnapp.exception.TransactionException An Exception thrown when an error occurs during Transaction Execution
- */
-object SubmitDiagnosisKeysTransaction : Transaction() {
-
-    private const val FALLBACK_COUNTRY = "DE"
-    override val TAG: String? = SubmitDiagnosisKeysTransaction::class.simpleName
-
-    /** possible transaction states */
-    private enum class SubmitDiagnosisKeysTransactionState :
-        TransactionState {
-        RETRIEVE_TEMPORARY_EXPOSURE_KEY_HISTORY,
-        RETRIEVE_TAN_AND_SUBMIT_KEYS,
-        STORE_SUCCESS,
-        CLOSE
-    }
-
-    private val transactionScope: TransactionCoroutineScope by lazy {
-        AppInjector.component.transSubmitDiagnosisInjection.transactionScope
-    }
-
-    private val playbook: Playbook
-        get() = AppInjector.component.transSubmitDiagnosisInjection.playbook
-
-    private val appConfigProvider: AppConfigProvider
-        get() = AppInjector.component.transSubmitDiagnosisInjection.appConfigProvider
-
-    private val exposureKeyHistoryCalculations: ExposureKeyHistoryCalculations
-        get() = AppInjector.component.transSubmitDiagnosisInjection.exposureKeyHistoryCalculations
-
-    /** 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) {
-
-        val temporaryExposureKeyList = executeState(RETRIEVE_TEMPORARY_EXPOSURE_KEY_HISTORY) {
-            exposureKeyHistoryCalculations.transformToKeyHistoryInExternalFormat(keys, symptoms)
-        }
-
-        val visistedCountries =
-            appConfigProvider.getAppConfig().performSanityChecks().supportedCountriesList
-
-        executeState(RETRIEVE_TAN_AND_SUBMIT_KEYS) {
-            val submissionData = Playbook.SubmissionData(
-                registrationToken = registrationToken,
-                temporaryExposureKeys = temporaryExposureKeyList,
-                consentToFederation = true,
-                visistedCountries = visistedCountries
-            )
-            playbook.submission(submissionData)
-        }
-
-        executeState(STORE_SUCCESS) {
-            SubmissionService.submissionSuccessful()
-        }
-
-        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 0abb8c24510e87802c155fbdebb7770862503846..4b07eea491c78a3135f3bd0407a1bc041ebec75e 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
@@ -5,6 +5,7 @@ import android.content.Intent
 import android.net.Uri
 import android.os.Bundle
 import android.provider.Settings
+import android.widget.Toast
 import androidx.appcompat.app.AppCompatActivity
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.FragmentManager
@@ -19,10 +20,14 @@ import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.ui.base.startActivitySafely
 import de.rki.coronawarnapp.ui.viewmodel.SettingsViewModel
 import de.rki.coronawarnapp.util.BackgroundPrioritization
+import de.rki.coronawarnapp.util.CWADebug
 import de.rki.coronawarnapp.util.ConnectivityHelper
 import de.rki.coronawarnapp.util.DialogHelper
 import de.rki.coronawarnapp.util.device.PowerManagement
 import de.rki.coronawarnapp.util.di.AppInjector
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
+import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
 import de.rki.coronawarnapp.worker.BackgroundWorkScheduler
 import kotlinx.coroutines.launch
 import javax.inject.Inject
@@ -47,6 +52,12 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector {
     @Inject lateinit var dispatchingAndroidInjector: DispatchingAndroidInjector<Any>
     override fun androidInjector(): AndroidInjector<Any> = dispatchingAndroidInjector
 
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val vm: MainActivityViewModel by cwaViewModels(
+        ownerProducer = { viewModelStore },
+        factoryProducer = { viewModelFactory }
+    )
+
     private val FragmentManager.currentNavigationFragment: Fragment?
         get() = primaryNavigationFragment?.childFragmentManager?.fragments?.first()
 
@@ -76,6 +87,12 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector {
         super.onCreate(savedInstanceState)
         setContentView(R.layout.activity_main)
         settingsViewModel = ViewModelProviders.of(this).get(SettingsViewModel::class.java)
+
+        if (CWADebug.isDeviceForTestersBuild) {
+            vm.showEnvironmentHint.observe2(this) {
+                Toast.makeText(this, "Current environment: $it", Toast.LENGTH_SHORT).show()
+            }
+        }
     }
 
     /**
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityModule.kt
index af1a59dfd887c171a66c9e901a55a21e049d5e61..7a596bfc0666c156fe94d05749be586282b531e7 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityModule.kt
@@ -1,7 +1,9 @@
 package de.rki.coronawarnapp.ui.main
 
+import dagger.Binds
 import dagger.Module
 import dagger.android.ContributesAndroidInjector
+import dagger.multibindings.IntoMap
 import de.rki.coronawarnapp.ui.interoperability.InteroperabilityConfigurationFragment
 import de.rki.coronawarnapp.ui.interoperability.InteroperabilityConfigurationFragmentModule
 import de.rki.coronawarnapp.ui.main.home.HomeFragmentModule
@@ -9,8 +11,11 @@ import de.rki.coronawarnapp.ui.onboarding.OnboardingDeltaInteroperabilityModule
 import de.rki.coronawarnapp.ui.settings.SettingFragmentsModule
 import de.rki.coronawarnapp.ui.settings.SettingsResetFragment
 import de.rki.coronawarnapp.ui.settings.SettingsResetModule
-import de.rki.coronawarnapp.ui.submission.SubmissionFragmentModule
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionFragmentModule
 import de.rki.coronawarnapp.ui.tracing.details.RiskDetailsFragmentModule
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey
 
 @Module(
     includes = [
@@ -34,4 +39,11 @@ abstract class MainActivityModule {
 
     @ContributesAndroidInjector(modules = [SettingsResetModule::class])
     abstract fun settingsResetScreen(): SettingsResetFragment
+
+    @Binds
+    @IntoMap
+    @CWAViewModelKey(MainActivityViewModel::class)
+    abstract fun mainActivityViewModel(
+        factory: MainActivityViewModel.Factory
+    ): CWAViewModelFactory<out CWAViewModel>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..8345f33c5aafac4b55a784fd5090d4ebb8a6779d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityViewModel.kt
@@ -0,0 +1,33 @@
+package de.rki.coronawarnapp.ui.main
+
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.environment.EnvironmentSetup
+import de.rki.coronawarnapp.util.CWADebug
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+
+class MainActivityViewModel @AssistedInject constructor(
+    dispatcherProvider: DispatcherProvider,
+    private val environmentSetup: EnvironmentSetup
+) : CWAViewModel(
+    dispatcherProvider = dispatcherProvider
+) {
+
+    val showEnvironmentHint = SingleLiveEvent<String>()
+
+    init {
+        if (CWADebug.isDeviceForTestersBuild) {
+            launch {
+                val current = environmentSetup.currentEnvironment
+                if (current != EnvironmentSetup.Type.PRODUCTION) {
+                    showEnvironmentHint.postValue(current.rawKey)
+                }
+            }
+        }
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<MainActivityViewModel>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
index 7b095ed4540fd6604e465a1c1e120ea45403d068..55e426d63f01557b91be18402f430778ec28678d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
@@ -91,7 +91,7 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject {
         }
 
         vm.showLoweredRiskLevelDialog.observe2(this) {
-            showRiskLevelLoweredDialogIfNeeded()
+            if (it) { showRiskLevelLoweredDialog() }
         }
     }
 
@@ -182,7 +182,7 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject {
         }
     }
 
-    private fun showRiskLevelLoweredDialogIfNeeded() {
+    private fun showRiskLevelLoweredDialog() {
         val riskLevelLoweredDialog = DialogHelper.DialogInstance(
             context = requireActivity(),
             title = R.string.risk_lowered_dialog_headline,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
index bd81af8e3aae3ff31be8a61771dd8cf79c3c4b75..c50cee70f2a186980552bb1b43d562f93ff4bd0d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
@@ -21,9 +21,7 @@ import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-import kotlinx.coroutines.flow.filter
 import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.sample
 
 class HomeFragmentViewModel @AssistedInject constructor(
@@ -78,9 +76,15 @@ class HomeFragmentViewModel @AssistedInject constructor(
 
     // TODO only lazy to keep tests going which would break because of LocalData access
     val showLoweredRiskLevelDialog: LiveData<Boolean> by lazy {
-        LocalData.isUserToBeNotifiedOfLoweredRiskLevelFlow
-            .filter { it && !isLoweredRiskLevelDialogBeingShown }
-            .onEach { isLoweredRiskLevelDialogBeingShown = true }
+        LocalData
+            .isUserToBeNotifiedOfLoweredRiskLevelFlow
+            .map { shouldBeNotified ->
+                val shouldBeShown = shouldBeNotified && !isLoweredRiskLevelDialogBeingShown
+                if (shouldBeShown) {
+                    isLoweredRiskLevelDialogBeingShown = true
+                }
+                shouldBeShown
+            }
             .asLiveData(context = dispatcherProvider.Default)
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/TanConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/TanConstants.kt
deleted file mode 100644
index 644cf1ee0f3aca6bb52e87b0fcca899b24065220..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/TanConstants.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package de.rki.coronawarnapp.ui.submission
-
-object TanConstants {
-    const val MAX_LENGTH = 10
-    val ALPHA_NUMERIC_CHARS = ('a'..'z').plus('A'..'Z').plus('0'..'9')
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionSymptomCalendarFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionSymptomCalendarFragment.kt
deleted file mode 100644
index 1c3184ab813f7b2bb8a2cf3537b55ca4b05605a4..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionSymptomCalendarFragment.kt
+++ /dev/null
@@ -1,161 +0,0 @@
-package de.rki.coronawarnapp.ui.submission.fragment
-
-import android.content.res.ColorStateList
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Button
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
-import androidx.lifecycle.Observer
-import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.databinding.FragmentSubmissionSymptomCalendarBinding
-import de.rki.coronawarnapp.submission.Symptoms
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionSymptomCalendarViewModel
-import de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel
-import de.rki.coronawarnapp.util.di.AutoInject
-import de.rki.coronawarnapp.util.formatter.formatCalendarBackgroundButtonStyleByState
-import de.rki.coronawarnapp.util.formatter.formatCalendarButtonStyleByState
-import de.rki.coronawarnapp.util.formatter.isEnableSymptomCalendarButtonByState
-import de.rki.coronawarnapp.util.ui.doNavigate
-import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
-import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
-import javax.inject.Inject
-
-class SubmissionSymptomCalendarFragment : Fragment(), AutoInject {
-
-    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
-    private val viewModel: SubmissionSymptomCalendarViewModel by cwaViewModels { viewModelFactory }
-    private var _binding: FragmentSubmissionSymptomCalendarBinding? = null
-    private val binding: FragmentSubmissionSymptomCalendarBinding get() = _binding!!
-    private val submissionViewModel: SubmissionViewModel by activityViewModels()
-
-    override fun onCreateView(
-        inflater: LayoutInflater,
-        container: ViewGroup?,
-        savedInstanceState: Bundle?
-    ): View? {
-        _binding = FragmentSubmissionSymptomCalendarBinding.inflate(inflater)
-        binding.submissionViewModel = submissionViewModel
-        binding.lifecycleOwner = this
-        return binding.root
-    }
-
-    override fun onDestroyView() {
-        super.onDestroyView()
-        _binding = null
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-        setButtonOnClickListener()
-
-        binding.symptomCalendarContainer.setDateSelectedListener(submissionViewModel::onDateSelected)
-
-        viewModel.routeToScreen.observe(viewLifecycleOwner, Observer {
-            when (it) {
-                is SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning -> navigateToSymptomFinish()
-                is SubmissionNavigationEvents.NavigateToSymptomIntroduction -> navigateToPreviousScreen()
-            }
-        })
-
-        submissionViewModel.symptomStart.observe(viewLifecycleOwner, Observer {
-            updateButtons(it)
-            if (it !is Symptoms.StartOf.Date) {
-                binding.symptomCalendarContainer.unsetSelection()
-            }
-        })
-
-        submissionViewModel.initSymptomStart()
-    }
-
-    private fun updateButtons(symptomStart: Symptoms.StartOf?) {
-        binding.symptomCalendarChoiceSelection.calendarButtonSevenDays
-            .findViewById<Button>(R.id.calendar_button_seven_days)
-            .setTextColor(formatCalendarButtonStyleByState(symptomStart, Symptoms.StartOf.LastSevenDays))
-        binding.symptomCalendarChoiceSelection.targetLayout
-            .findViewById<Button>(R.id.calendar_button_seven_days).backgroundTintList =
-            ColorStateList.valueOf(
-                formatCalendarBackgroundButtonStyleByState(
-                    symptomStart, Symptoms.StartOf.LastSevenDays
-                )
-            )
-
-        binding.symptomCalendarChoiceSelection.targetLayout
-            .findViewById<Button>(R.id.calendar_button_one_two_weeks)
-            .setTextColor(formatCalendarButtonStyleByState(symptomStart, Symptoms.StartOf.OneToTwoWeeksAgo))
-        binding.symptomCalendarChoiceSelection.targetLayout
-            .findViewById<Button>(R.id.calendar_button_one_two_weeks).backgroundTintList =
-            ColorStateList.valueOf(
-                formatCalendarBackgroundButtonStyleByState(
-                    symptomStart, Symptoms.StartOf.OneToTwoWeeksAgo
-                )
-            )
-
-        binding.symptomCalendarChoiceSelection.targetLayout
-            .findViewById<Button>(R.id.calendar_button_more_than_two_weeks)
-            .setTextColor(formatCalendarButtonStyleByState(symptomStart, Symptoms.StartOf.MoreThanTwoWeeks))
-        binding.symptomCalendarChoiceSelection.targetLayout
-            .findViewById<Button>(R.id.calendar_button_more_than_two_weeks).backgroundTintList =
-            ColorStateList.valueOf(
-                formatCalendarBackgroundButtonStyleByState(
-                    symptomStart, Symptoms.StartOf.MoreThanTwoWeeks
-                )
-            )
-
-        binding.symptomCalendarChoiceSelection.targetLayout
-            .findViewById<Button>(R.id.target_button_verify)
-            .setTextColor(formatCalendarButtonStyleByState(symptomStart, Symptoms.StartOf.NoInformation))
-        binding.symptomCalendarChoiceSelection.targetLayout
-            .findViewById<Button>(R.id.target_button_verify).backgroundTintList =
-            ColorStateList.valueOf(
-                formatCalendarBackgroundButtonStyleByState(
-                    symptomStart, Symptoms.StartOf.NoInformation
-                )
-            )
-
-        binding
-            .symptomButtonNext.findViewById<Button>(R.id.symptom_button_next).isEnabled =
-            isEnableSymptomCalendarButtonByState(
-                symptomStart
-            )
-    }
-
-    private fun navigateToSymptomFinish() {
-        doNavigate(SubmissionSymptomCalendarFragmentDirections
-            .actionSubmissionSymptomCalendarFragmentToSubmissionResultPositiveOtherWarningFragment())
-    }
-
-    private fun navigateToPreviousScreen() {
-        doNavigate(SubmissionSymptomCalendarFragmentDirections
-            .actionSubmissionCalendarFragmentToSubmissionSymptomIntroductionFragment())
-    }
-
-    private fun setButtonOnClickListener() {
-        binding
-            .submissionSymptomCalendarHeader.headerButtonBack.buttonIcon
-            .setOnClickListener { viewModel.onCalendarPreviousClicked() }
-
-        binding
-            .symptomButtonNext
-            .setOnClickListener { viewModel.onCalendarNextClicked() }
-
-        binding.symptomCalendarChoiceSelection
-            .calendarButtonSevenDays
-            .setOnClickListener { submissionViewModel.onLastSevenDaysStart() }
-
-        binding.symptomCalendarChoiceSelection
-            .calendarButtonOneTwoWeeks
-            .setOnClickListener { submissionViewModel.onOneToTwoWeeksAgoStart() }
-
-        binding.symptomCalendarChoiceSelection
-            .calendarButtonMoreThanTwoWeeks
-            .setOnClickListener { submissionViewModel.onMoreThanTwoWeeksStart() }
-
-        binding.symptomCalendarChoiceSelection
-            .targetButtonVerify
-            .setOnClickListener { submissionViewModel.onNoInformationStart() }
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeInfoFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragment.kt
similarity index 78%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeInfoFragment.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragment.kt
index 1f37ca3e03ea3d3829a9492919609c4ae2864333..c98d506a032d742cec2cc6f8a1749d03717f10b1 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeInfoFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragment.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.fragment
+package de.rki.coronawarnapp.ui.submission.qrcode.info
 
 import android.os.Bundle
 import android.view.View
@@ -6,7 +6,6 @@ import androidx.fragment.app.Fragment
 import androidx.navigation.fragment.findNavController
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentSubmissionQrCodeInfoBinding
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionQRCodeInfoFragmentViewModel
 import de.rki.coronawarnapp.util.di.AutoInject
 import de.rki.coronawarnapp.util.ui.doNavigate
 import de.rki.coronawarnapp.util.ui.observe2
@@ -24,11 +23,11 @@ class SubmissionQRCodeInfoFragment : Fragment(R.layout.fragment_submission_qr_co
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
 
-        binding.submissionQrCodeInfoHeader.headerButtonBack.buttonIcon.setOnClickListener() {
+        binding.submissionQrCodeInfoHeader.headerButtonBack.buttonIcon.setOnClickListener {
             viewModel.onBackPressed()
         }
 
-        binding.submissionQrInfoButtonNext.setOnClickListener() {
+        binding.submissionQrInfoButtonNext.setOnClickListener {
             viewModel.onNextPressed()
         }
 
@@ -38,8 +37,9 @@ class SubmissionQRCodeInfoFragment : Fragment(R.layout.fragment_submission_qr_co
 
         viewModel.navigateToQRScan.observe2(this) {
             doNavigate(
-                    SubmissionQRCodeInfoFragmentDirections
-                        .actionSubmissionQRCodeInfoFragmentToSubmissionQRCodeScanFragment())
+                SubmissionQRCodeInfoFragmentDirections
+                    .actionSubmissionQRCodeInfoFragmentToSubmissionQRCodeScanFragment()
+            )
         }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeInfoFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragmentViewModel.kt
similarity index 92%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeInfoFragmentViewModel.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragmentViewModel.kt
index cde1a6246b98b1f4ed7321526b2f4b1f86bb6312..f90824a82de035cbc09cc81fec9986e3e05ff305 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeInfoFragmentViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragmentViewModel.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
+package de.rki.coronawarnapp.ui.submission.qrcode.info
 
 import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeInfoModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoModule.kt
similarity index 79%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeInfoModule.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoModule.kt
index fe135026e5823adaf0be18a9283913b193ff3410..437fc64b2353848b749819925a9187cf4fd956cb 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeInfoModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoModule.kt
@@ -1,9 +1,8 @@
-package de.rki.coronawarnapp.ui.submission.fragment
+package de.rki.coronawarnapp.ui.submission.qrcode.info
 
 import dagger.Binds
 import dagger.Module
 import dagger.multibindings.IntoMap
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionQRCodeInfoFragmentViewModel
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeScanFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanFragment.kt
similarity index 89%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeScanFragment.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanFragment.kt
index c09f7a54bcd6321ea83d2d00f1ff39eb3d52485e..1ecb64ba822eb0f531f8cf37d4fc769e464f1925 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionQRCodeScanFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanFragment.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.fragment
+package de.rki.coronawarnapp.ui.submission.qrcode.scan
 
 import android.Manifest
 import android.content.pm.PackageManager
@@ -6,9 +6,7 @@ import android.os.Bundle
 import android.view.View
 import android.view.accessibility.AccessibilityEvent
 import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
 import com.google.zxing.BarcodeFormat
-import com.journeyapps.barcodescanner.BarcodeResult
 import com.journeyapps.barcodescanner.DefaultDecoderFactory
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentSubmissionQrCodeScanBinding
@@ -20,8 +18,6 @@ import de.rki.coronawarnapp.ui.main.MainActivity
 import de.rki.coronawarnapp.ui.submission.ApiRequestState
 import de.rki.coronawarnapp.ui.submission.ScanStatus
 import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionQRCodeScanViewModel
-import de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel
 import de.rki.coronawarnapp.util.CameraPermissionHelper
 import de.rki.coronawarnapp.util.DialogHelper
 import de.rki.coronawarnapp.util.di.AutoInject
@@ -40,54 +36,10 @@ class SubmissionQRCodeScanFragment : Fragment(R.layout.fragment_submission_qr_co
 
     @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
     private val viewModel: SubmissionQRCodeScanViewModel by cwaViewModels { viewModelFactory }
-    private val submissionViewModel: SubmissionViewModel by activityViewModels()
+
     private val binding: FragmentSubmissionQrCodeScanBinding by viewBindingLazy()
     private var showsPermissionDialog = false
 
-    private fun decodeCallback(result: BarcodeResult) {
-        submissionViewModel.validateAndStoreTestGUID(result.text)
-    }
-
-    private fun startDecode() {
-        binding.submissionQrCodeScanPreview.decodeSingle { decodeCallback(it) }
-    }
-
-    private fun buildErrorDialog(exception: CwaWebException): DialogHelper.DialogInstance {
-        return when (exception) {
-            is BadRequestException -> DialogHelper.DialogInstance(
-                requireActivity(),
-                R.string.submission_qr_code_scan_invalid_dialog_headline,
-                R.string.submission_qr_code_scan_invalid_dialog_body,
-                R.string.submission_qr_code_scan_invalid_dialog_button_positive,
-                R.string.submission_qr_code_scan_invalid_dialog_button_negative,
-                true,
-                { startDecode() },
-                ::navigateToDispatchScreen
-            )
-            is CwaClientError, is CwaServerError -> DialogHelper.DialogInstance(
-                requireActivity(),
-                R.string.submission_error_dialog_web_generic_error_title,
-                getString(
-                    R.string.submission_error_dialog_web_generic_network_error_body,
-                    exception.statusCode
-                ),
-                R.string.submission_error_dialog_web_generic_error_button_positive,
-                null,
-                true,
-                ::navigateToDispatchScreen
-            )
-            else -> DialogHelper.DialogInstance(
-                requireActivity(),
-                R.string.submission_error_dialog_web_generic_error_title,
-                R.string.submission_error_dialog_web_generic_error_body,
-                R.string.submission_error_dialog_web_generic_error_button_positive,
-                null,
-                true,
-                ::navigateToDispatchScreen
-            )
-        }
-    }
-
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
 
@@ -106,9 +58,9 @@ class SubmissionQRCodeScanFragment : Fragment(R.layout.fragment_submission_qr_co
 
         binding.submissionQrCodeScanViewfinderView.setCameraPreview(binding.submissionQrCodeScanPreview)
 
-        submissionViewModel.scanStatus.observeEvent(viewLifecycleOwner) {
+        viewModel.scanStatus.observeEvent(viewLifecycleOwner) {
             if (ScanStatus.SUCCESS == it) {
-                submissionViewModel.doDeviceRegistration()
+                viewModel.doDeviceRegistration()
             }
 
             if (ScanStatus.INVALID == it) {
@@ -116,7 +68,7 @@ class SubmissionQRCodeScanFragment : Fragment(R.layout.fragment_submission_qr_co
             }
         }
 
-        submissionViewModel.registrationState.observeEvent(viewLifecycleOwner) {
+        viewModel.registrationState.observe2(this) {
             binding.submissionQrCodeScanSpinner.visibility = when (it) {
                 ApiRequestState.STARTED -> View.VISIBLE
                 else -> View.GONE
@@ -130,7 +82,7 @@ class SubmissionQRCodeScanFragment : Fragment(R.layout.fragment_submission_qr_co
             }
         }
 
-        submissionViewModel.registrationError.observeEvent(viewLifecycleOwner) {
+        viewModel.registrationError.observe2(this) {
             DialogHelper.showDialog(buildErrorDialog(it))
         }
 
@@ -144,10 +96,51 @@ class SubmissionQRCodeScanFragment : Fragment(R.layout.fragment_submission_qr_co
         }
     }
 
+    private fun startDecode() {
+        binding.submissionQrCodeScanPreview.decodeSingle {
+            viewModel.validateAndStoreTestGUID(it.text)
+        }
+    }
+
+    private fun buildErrorDialog(exception: CwaWebException): DialogHelper.DialogInstance {
+        return when (exception) {
+            is BadRequestException -> DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_qr_code_scan_invalid_dialog_headline,
+                R.string.submission_qr_code_scan_invalid_dialog_body,
+                R.string.submission_qr_code_scan_invalid_dialog_button_positive,
+                R.string.submission_qr_code_scan_invalid_dialog_button_negative,
+                true,
+                { startDecode() },
+                ::navigateToDispatchScreen
+            )
+            is CwaClientError, is CwaServerError -> DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_error_dialog_web_generic_error_title,
+                getString(
+                    R.string.submission_error_dialog_web_generic_network_error_body,
+                    exception.statusCode
+                ),
+                R.string.submission_error_dialog_web_generic_error_button_positive,
+                null,
+                true,
+                ::navigateToDispatchScreen
+            )
+            else -> DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_error_dialog_web_generic_error_title,
+                R.string.submission_error_dialog_web_generic_error_body,
+                R.string.submission_error_dialog_web_generic_error_button_positive,
+                null,
+                true,
+                ::navigateToDispatchScreen
+            )
+        }
+    }
+
     private fun navigateToDispatchScreen() =
         doNavigate(
-            SubmissionQRCodeScanFragmentDirections
-                .actionSubmissionQRCodeScanFragmentToSubmissionDispatcherFragment()
+            SubmissionQRCodeScanFragmentDirections.actionSubmissionQRCodeScanFragmentToSubmissionDispatcherFragment()
         )
 
     private fun showInvalidScanDialog() {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeScanModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanModule.kt
similarity index 90%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeScanModule.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanModule.kt
index 55c737607dd9de8d75bf7054e5137b53daaad770..7a435572f5371e5e76a6a49482e6bc80386e803b 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeScanModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanModule.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
+package de.rki.coronawarnapp.ui.submission.qrcode.scan
 
 import dagger.Binds
 import dagger.Module
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b4330c73b3d8596cfcd44f708d814e6e9e786ede
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt
@@ -0,0 +1,71 @@
+package de.rki.coronawarnapp.ui.submission.qrcode.scan
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.exception.ExceptionCategory
+import de.rki.coronawarnapp.exception.TransactionException
+import de.rki.coronawarnapp.exception.http.CwaWebException
+import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.service.submission.QRScanResult
+import de.rki.coronawarnapp.service.submission.SubmissionService
+import de.rki.coronawarnapp.ui.submission.ApiRequestState
+import de.rki.coronawarnapp.ui.submission.ScanStatus
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
+import de.rki.coronawarnapp.util.Event
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+
+class SubmissionQRCodeScanViewModel @AssistedInject constructor() : CWAViewModel() {
+
+    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
+    private val _scanStatus = MutableLiveData(Event(ScanStatus.STARTED))
+
+    val scanStatus: LiveData<Event<ScanStatus>> = _scanStatus
+
+    fun validateAndStoreTestGUID(rawResult: String) {
+        val scanResult = QRScanResult(rawResult)
+        if (scanResult.isValid) {
+            SubmissionService.storeTestGUID(scanResult.guid!!)
+            _scanStatus.value = Event(ScanStatus.SUCCESS)
+        } else {
+            _scanStatus.value = Event(ScanStatus.INVALID)
+        }
+    }
+
+    val registrationState = MutableLiveData(ApiRequestState.IDLE)
+    val registrationError = SingleLiveEvent<CwaWebException>()
+
+    fun doDeviceRegistration() = launch {
+        try {
+            registrationState.postValue(ApiRequestState.STARTED)
+            SubmissionService.asyncRegisterDevice()
+            registrationState.postValue(ApiRequestState.SUCCESS)
+        } catch (err: CwaWebException) {
+            registrationState.postValue(ApiRequestState.FAILED)
+            registrationError.postValue(err)
+        } catch (err: TransactionException) {
+            if (err.cause is CwaWebException) {
+                registrationError.postValue(err.cause)
+            } else {
+                err.report(ExceptionCategory.INTERNAL)
+            }
+            registrationState.postValue(ApiRequestState.FAILED)
+        } catch (err: Exception) {
+            registrationState.postValue(ApiRequestState.FAILED)
+            err.report(ExceptionCategory.INTERNAL)
+        }
+    }
+
+    fun onBackPressed() {
+        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToQRInfo)
+    }
+
+    fun onClosePressed() {
+        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToDispatcher)
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<SubmissionQRCodeScanViewModel>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarFragment.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2c1653eedd26f0d6df583642e4edd1fbb6eb3263
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarFragment.kt
@@ -0,0 +1,148 @@
+package de.rki.coronawarnapp.ui.submission.symptoms.calendar
+
+import android.content.res.ColorStateList
+import android.os.Bundle
+import android.view.View
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.navArgs
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.FragmentSubmissionSymptomCalendarBinding
+import de.rki.coronawarnapp.submission.Symptoms
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
+import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.formatter.formatCalendarBackgroundButtonStyleByState
+import de.rki.coronawarnapp.util.formatter.formatCalendarButtonStyleByState
+import de.rki.coronawarnapp.util.formatter.isEnableSymptomCalendarButtonByState
+import de.rki.coronawarnapp.util.ui.doNavigate
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.viewBindingLazy
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
+import de.rki.coronawarnapp.util.viewmodel.cwaViewModelsAssisted
+import javax.inject.Inject
+
+class SubmissionSymptomCalendarFragment : Fragment(R.layout.fragment_submission_symptom_calendar),
+    AutoInject {
+
+    private val navArgs by navArgs<SubmissionSymptomCalendarFragmentArgs>()
+
+    @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
+    private val viewModel: SubmissionSymptomCalendarViewModel by cwaViewModelsAssisted(
+        factoryProducer = { viewModelFactory },
+        constructorCall = { factory, _ ->
+            factory as SubmissionSymptomCalendarViewModel.Factory
+            factory.create(navArgs.symptomIndication)
+        }
+    )
+
+    private val binding: FragmentSubmissionSymptomCalendarBinding by viewBindingLazy()
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        binding.symptomCalendarContainer.setDateSelectedListener {
+            viewModel.onDateSelected(it)
+        }
+
+        viewModel.routeToScreen.observe2(this) {
+            when (it) {
+                is SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning -> doNavigate(
+                    SubmissionSymptomCalendarFragmentDirections
+                        .actionSubmissionSymptomCalendarFragmentToSubmissionResultPositiveOtherWarningFragment(
+                            it.symptoms
+                        )
+                )
+                is SubmissionNavigationEvents.NavigateToSymptomIntroduction -> doNavigate(
+                    SubmissionSymptomCalendarFragmentDirections
+                        .actionSubmissionCalendarFragmentToSubmissionSymptomIntroductionFragment()
+                )
+            }
+        }
+
+        viewModel.symptomStart.observe2(this) {
+            updateButtons(it)
+            if (it !is Symptoms.StartOf.Date) {
+                binding.symptomCalendarContainer.unsetSelection()
+            }
+        }
+
+        binding.apply {
+            submissionSymptomCalendarHeader.headerButtonBack.buttonIcon
+                .setOnClickListener { viewModel.onCalendarPreviousClicked() }
+
+            symptomButtonNext
+                .setOnClickListener { viewModel.onCalendarNextClicked() }
+
+            symptomCalendarChoiceSelection
+                .calendarButtonSevenDays
+                .setOnClickListener { viewModel.onLastSevenDaysStart() }
+
+            symptomCalendarChoiceSelection
+                .calendarButtonOneTwoWeeks
+                .setOnClickListener { viewModel.onOneToTwoWeeksAgoStart() }
+
+            symptomCalendarChoiceSelection
+                .calendarButtonMoreThanTwoWeeks
+                .setOnClickListener { viewModel.onMoreThanTwoWeeksStart() }
+
+            symptomCalendarChoiceSelection
+                .targetButtonVerify
+                .setOnClickListener { viewModel.onNoInformationStart() }
+        }
+    }
+
+    private fun updateButtons(symptomStart: Symptoms.StartOf?) {
+        binding.symptomCalendarChoiceSelection.apply {
+            calendarButtonSevenDays.apply {
+                setTextColor(
+                    formatCalendarButtonStyleByState(symptomStart, Symptoms.StartOf.LastSevenDays)
+                )
+                backgroundTintList = ColorStateList.valueOf(
+                    formatCalendarBackgroundButtonStyleByState(
+                        symptomStart, Symptoms.StartOf.LastSevenDays
+                    )
+                )
+            }
+
+            calendarButtonOneTwoWeeks.apply {
+                setTextColor(
+                    formatCalendarButtonStyleByState(
+                        symptomStart,
+                        Symptoms.StartOf.OneToTwoWeeksAgo
+                    )
+                )
+                backgroundTintList = ColorStateList.valueOf(
+                    formatCalendarBackgroundButtonStyleByState(
+                        symptomStart, Symptoms.StartOf.OneToTwoWeeksAgo
+                    )
+                )
+            }
+
+            calendarButtonMoreThanTwoWeeks.apply {
+                setTextColor(
+                    formatCalendarButtonStyleByState(
+                        symptomStart,
+                        Symptoms.StartOf.MoreThanTwoWeeks
+                    )
+                )
+                backgroundTintList = ColorStateList.valueOf(
+                    formatCalendarBackgroundButtonStyleByState(
+                        symptomStart, Symptoms.StartOf.MoreThanTwoWeeks
+                    )
+                )
+            }
+            targetButtonVerify.apply {
+                setTextColor(
+                    formatCalendarButtonStyleByState(symptomStart, Symptoms.StartOf.NoInformation)
+                )
+                backgroundTintList = ColorStateList.valueOf(
+                    formatCalendarBackgroundButtonStyleByState(
+                        symptomStart, Symptoms.StartOf.NoInformation
+                    )
+                )
+            }
+        }
+
+        binding.symptomButtonNext.isEnabled = isEnableSymptomCalendarButtonByState(
+            symptomStart
+        )
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomCalendarModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarModule.kt
similarity index 90%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomCalendarModule.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarModule.kt
index d359621f48f15cce986628fcea9dec82e2eb64f3..82edb4598b871084eb556494657d61a2e6e0f505 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomCalendarModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarModule.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
+package de.rki.coronawarnapp.ui.submission.symptoms.calendar
 
 import dagger.Binds
 import dagger.Module
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e79975890a20c3d5572801fd7ecf5481f246e367
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/calendar/SubmissionSymptomCalendarViewModel.kt
@@ -0,0 +1,68 @@
+package de.rki.coronawarnapp.ui.submission.symptoms.calendar
+
+import androidx.lifecycle.asLiveData
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.submission.Symptoms
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import org.joda.time.LocalDate
+
+class SubmissionSymptomCalendarViewModel @AssistedInject constructor(
+    @Assisted private val symptomIndication: Symptoms.Indication,
+    dispatcherProvider: DispatcherProvider
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    private val symptomStartInternal = MutableStateFlow<Symptoms.StartOf?>(null)
+    val symptomStart = symptomStartInternal
+        .asLiveData(context = dispatcherProvider.Default)
+
+    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
+
+    fun onCalendarNextClicked() {
+        launch {
+            val symptoms = Symptoms(
+                startOfSymptoms = symptomStartInternal.first(),
+                symptomIndication = symptomIndication
+            )
+            routeToScreen.postValue(
+                SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning(symptoms)
+            )
+        }
+    }
+
+    fun onCalendarPreviousClicked() {
+        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSymptomIntroduction)
+    }
+
+    fun onLastSevenDaysStart() {
+        symptomStartInternal.value = Symptoms.StartOf.LastSevenDays
+    }
+
+    fun onOneToTwoWeeksAgoStart() {
+        symptomStartInternal.value = Symptoms.StartOf.OneToTwoWeeksAgo
+    }
+
+    fun onMoreThanTwoWeeksStart() {
+        symptomStartInternal.value = Symptoms.StartOf.MoreThanTwoWeeks
+    }
+
+    fun onNoInformationStart() {
+        symptomStartInternal.value = Symptoms.StartOf.NoInformation
+    }
+
+    fun onDateSelected(localDate: LocalDate?) {
+        symptomStartInternal.value = localDate?.let { Symptoms.StartOf.Date(it) }
+    }
+
+    @AssistedInject.Factory
+    interface Factory : CWAViewModelFactory<SubmissionSymptomCalendarViewModel> {
+
+        fun create(symptomIndication: Symptoms.Indication): SubmissionSymptomCalendarViewModel
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionSymptomIntroductionFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionFragment.kt
similarity index 60%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionSymptomIntroductionFragment.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionFragment.kt
index a7895306fc34a66757f801c3b52c7cce97f77ec7..c2af32534fb433369929cc714279aed588a3a4f1 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionSymptomIntroductionFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionFragment.kt
@@ -1,20 +1,15 @@
-package de.rki.coronawarnapp.ui.submission.fragment
+package de.rki.coronawarnapp.ui.submission.symptoms.introduction
 
 import android.content.res.ColorStateList
 import android.os.Bundle
-import android.view.LayoutInflater
 import android.view.View
-import android.view.ViewGroup
 import android.widget.Button
 import androidx.activity.OnBackPressedCallback
 import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentSubmissionSymptomIntroBinding
 import de.rki.coronawarnapp.submission.Symptoms
 import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionSymptomIntroductionViewModel
-import de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel
 import de.rki.coronawarnapp.util.DialogHelper
 import de.rki.coronawarnapp.util.di.AutoInject
 import de.rki.coronawarnapp.util.formatter.formatBackgroundButtonStyleByState
@@ -22,52 +17,62 @@ import de.rki.coronawarnapp.util.formatter.formatButtonStyleByState
 import de.rki.coronawarnapp.util.formatter.isEnableSymptomIntroButtonByState
 import de.rki.coronawarnapp.util.ui.doNavigate
 import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.viewBindingLazy
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
 import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
 import javax.inject.Inject
 
-class SubmissionSymptomIntroductionFragment : Fragment(), AutoInject {
+class SubmissionSymptomIntroductionFragment : Fragment(R.layout.fragment_submission_symptom_intro),
+    AutoInject {
 
     @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
     private val viewModel: SubmissionSymptomIntroductionViewModel by cwaViewModels { viewModelFactory }
-    private var _binding: FragmentSubmissionSymptomIntroBinding? = null
-    private val binding: FragmentSubmissionSymptomIntroBinding get() = _binding!!
-    private val submissionViewModel: SubmissionViewModel by activityViewModels()
-
-    override fun onCreateView(
-        inflater: LayoutInflater,
-        container: ViewGroup?,
-        savedInstanceState: Bundle?
-    ): View? {
-        _binding = FragmentSubmissionSymptomIntroBinding.inflate(inflater)
-        binding.submissionViewModel = submissionViewModel
-        binding.lifecycleOwner = this
-        return binding.root
-    }
 
-    override fun onDestroyView() {
-        super.onDestroyView()
-        _binding = null
-    }
+    private val binding: FragmentSubmissionSymptomIntroBinding by viewBindingLazy()
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
-        setButtonOnClickListener()
 
         viewModel.routeToScreen.observe2(this) {
             when (it) {
-                is SubmissionNavigationEvents.NavigateToSymptomCalendar -> navigateToNext()
+                is SubmissionNavigationEvents.NavigateToSymptomCalendar -> doNavigate(
+                    SubmissionSymptomIntroductionFragmentDirections
+                        .actionSubmissionSymptomIntroductionFragmentToSubmissionSymptomCalendarFragment(
+                            symptomIndication = it.symptomIndication
+                        )
+                )
+                is SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning -> doNavigate(
+                    SubmissionSymptomIntroductionFragmentDirections
+                        .actionSubmissionSymptomIntroductionFragmentToSubmissionResultPositiveOtherWarningFragment(
+                            it.symptoms
+                        )
+                )
                 is SubmissionNavigationEvents.NavigateToTestResult -> handleSubmissionCancellation()
             }
         }
 
-        submissionViewModel.symptomIndication.observe(viewLifecycleOwner, {
+        viewModel.symptomIndication.observe2(this) {
             updateButtons(it)
-        })
+        }
 
         requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backCallback)
 
-        submissionViewModel.initSymptoms()
+        binding.apply {
+            submissionSymptomHeader.headerButtonBack.buttonIcon
+                .setOnClickListener { viewModel.onPreviousClicked() }
+
+            symptomButtonNext
+                .setOnClickListener { viewModel.onNextClicked() }
+
+            symptomChoiceSelection.targetButtonApply
+                .setOnClickListener { viewModel.onPositiveSymptomIndication() }
+
+            symptomChoiceSelection.targetButtonReject
+                .setOnClickListener { viewModel.onNegativeSymptomIndication() }
+
+            symptomChoiceSelection.targetButtonVerify
+                .setOnClickListener { viewModel.onNoInformationSymptomIndication() }
+        }
     }
 
     private val backCallback: OnBackPressedCallback =
@@ -117,26 +122,9 @@ class SubmissionSymptomIntroductionFragment : Fragment(), AutoInject {
             )
     }
 
-    private fun navigateToNext() {
-
-        if (submissionViewModel.symptomIndication.value!! == Symptoms.Indication.POSITIVE) {
-            doNavigate(
-                SubmissionSymptomIntroductionFragmentDirections
-                    .actionSubmissionSymptomIntroductionFragmentToSubmissionSymptomCalendarFragment()
-            )
-        } else {
-            doNavigate(
-                SubmissionSymptomIntroductionFragmentDirections
-                    .actionSubmissionSymptomIntroductionFragmentToSubmissionResultPositiveOtherWarningFragment()
-            )
-        }
-    }
-
     /**
      * Opens a Dialog that warns user
      * when they're about to cancel the submission flow
-     * @see DialogHelper
-     * @see navigateToPreviousScreen
      */
     private fun handleSubmissionCancellation() {
         DialogHelper.showDialog(
@@ -147,37 +135,13 @@ class SubmissionSymptomIntroductionFragment : Fragment(), AutoInject {
                 R.string.submission_error_dialog_confirm_cancellation_button_positive,
                 R.string.submission_error_dialog_confirm_cancellation_button_negative,
                 true,
-                ::navigateToPreviousScreen
+                {
+                    doNavigate(
+                        SubmissionSymptomIntroductionFragmentDirections
+                            .actionSubmissionSymptomIntroductionFragmentToSubmissionResultFragment()
+                    )
+                }
             )
         )
     }
-
-    private fun navigateToPreviousScreen() {
-        doNavigate(
-            SubmissionSymptomIntroductionFragmentDirections
-                .actionSubmissionSymptomIntroductionFragmentToSubmissionResultFragment()
-        )
-    }
-
-    private fun setButtonOnClickListener() {
-        binding
-            .submissionSymptomHeader.headerButtonBack.buttonIcon
-            .setOnClickListener { viewModel.onPreviousClicked() }
-
-        binding
-            .symptomButtonNext
-            .setOnClickListener { viewModel.onNextClicked() }
-
-        binding
-            .symptomChoiceSelection.targetButtonApply
-            .setOnClickListener { submissionViewModel.onPositiveSymptomIndication() }
-
-        binding
-            .symptomChoiceSelection.targetButtonReject
-            .setOnClickListener { submissionViewModel.onNegativeSymptomIndication() }
-
-        binding
-            .symptomChoiceSelection.targetButtonVerify
-            .setOnClickListener { submissionViewModel.onNoInformationSymptomIndication() }
-    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomIntroductionModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionModule.kt
similarity index 89%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomIntroductionModule.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionModule.kt
index 53792300528498a2ff95abcfe6d4d2fa69c10628..40b49ba8e839ffc6d3cc687d375632e93678ec79 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomIntroductionModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionModule.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
+package de.rki.coronawarnapp.ui.submission.symptoms.introduction
 
 import dagger.Binds
 import dagger.Module
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..43f7550b426a28cfee0e10c8360906c685c4aaf2
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/symptoms/introduction/SubmissionSymptomIntroductionViewModel.kt
@@ -0,0 +1,61 @@
+package de.rki.coronawarnapp.ui.submission.symptoms.introduction
+
+import androidx.lifecycle.asLiveData
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.submission.Symptoms
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+
+class SubmissionSymptomIntroductionViewModel @AssistedInject constructor(
+    dispatcherProvider: DispatcherProvider
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    private val symptomIndicationInternal = MutableStateFlow<Symptoms.Indication?>(null)
+    val symptomIndication = symptomIndicationInternal
+        .asLiveData(context = dispatcherProvider.Default)
+
+    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
+
+    fun onNextClicked() {
+        launch {
+            when (symptomIndicationInternal.first()) {
+                Symptoms.Indication.POSITIVE -> SubmissionNavigationEvents.NavigateToSymptomCalendar(
+                    Symptoms.Indication.POSITIVE
+                )
+                Symptoms.Indication.NEGATIVE -> SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning(
+                    symptoms = Symptoms(
+                        startOfSymptoms = null,
+                        symptomIndication = Symptoms.Indication.NEGATIVE
+                    )
+                )
+                else -> SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning(
+                    symptoms = Symptoms.NO_INFO_GIVEN
+                )
+            }.let { routeToScreen.postValue(it) }
+        }
+    }
+
+    fun onPreviousClicked() {
+        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToTestResult)
+    }
+
+    fun onPositiveSymptomIndication() {
+        symptomIndicationInternal.value = Symptoms.Indication.POSITIVE
+    }
+
+    fun onNegativeSymptomIndication() {
+        symptomIndicationInternal.value = Symptoms.Indication.NEGATIVE
+    }
+
+    fun onNoInformationSymptomIndication() {
+        symptomIndicationInternal.value = Symptoms.Indication.NO_INFORMATION
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<SubmissionSymptomIntroductionViewModel>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionTanFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt
similarity index 69%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionTanFragment.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt
index f021bdc4d7b675c3c40cff9513773999cab8d873..a9c953118a6983ab44db4e83d669d41185e25c00 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionTanFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt
@@ -1,10 +1,9 @@
-package de.rki.coronawarnapp.ui.submission.fragment
+package de.rki.coronawarnapp.ui.submission.tan
 
 import android.os.Bundle
 import android.view.View
 import android.view.accessibility.AccessibilityEvent
 import androidx.fragment.app.Fragment
-import androidx.fragment.app.viewModels
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentSubmissionTanBinding
 import de.rki.coronawarnapp.exception.http.BadRequestException
@@ -13,14 +12,11 @@ import de.rki.coronawarnapp.exception.http.CwaServerError
 import de.rki.coronawarnapp.exception.http.CwaWebException
 import de.rki.coronawarnapp.ui.main.MainActivity
 import de.rki.coronawarnapp.ui.submission.ApiRequestState
-import de.rki.coronawarnapp.ui.submission.TanConstants
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionTanViewModel
-import de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel
 import de.rki.coronawarnapp.util.DialogHelper
-import de.rki.coronawarnapp.util.TanHelper
 import de.rki.coronawarnapp.util.di.AutoInject
-import de.rki.coronawarnapp.util.observeEvent
 import de.rki.coronawarnapp.util.ui.doNavigate
+import de.rki.coronawarnapp.util.ui.observe2
+import de.rki.coronawarnapp.util.ui.setGone
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
 import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
@@ -33,67 +29,33 @@ import javax.inject.Inject
 class SubmissionTanFragment : Fragment(R.layout.fragment_submission_tan), AutoInject {
 
     @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
-    private val submissionViewModel: SubmissionViewModel by viewModels()
     private val viewModel: SubmissionTanViewModel by cwaViewModels { viewModelFactory }
-    private val binding: FragmentSubmissionTanBinding by viewBindingLazy()
 
-    private fun buildErrorDialog(exception: CwaWebException): DialogHelper.DialogInstance {
-        return when (exception) {
-            is BadRequestException -> DialogHelper.DialogInstance(
-                requireActivity(),
-                R.string.submission_error_dialog_web_test_paired_title_tan,
-                R.string.submission_error_dialog_web_test_paired_body_tan,
-                R.string.submission_error_dialog_web_test_paired_button_positive,
-                null,
-                true,
-                ::goBack
-            )
-            is CwaClientError, is CwaServerError -> DialogHelper.DialogInstance(
-                requireActivity(),
-                R.string.submission_error_dialog_web_generic_error_title,
-                getString(
-                    R.string.submission_error_dialog_web_generic_network_error_body,
-                    exception.statusCode
-                ),
-                R.string.submission_error_dialog_web_generic_error_button_positive,
-                null,
-                true,
-                ::goBack
-            )
-            else -> DialogHelper.DialogInstance(
-                requireActivity(),
-                R.string.submission_error_dialog_web_generic_error_title,
-                R.string.submission_error_dialog_web_generic_error_body,
-                R.string.submission_error_dialog_web_generic_error_button_positive,
-                null,
-                true,
-                ::goBack
-            )
-        }
-    }
+    private val binding: FragmentSubmissionTanBinding by viewBindingLazy()
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
-        binding.viewmodel = viewModel
 
-        binding.submissionTanContent.submissionTanInput.listener = { tan ->
-            resetError()
+        viewModel.state.observe2(this) {
+            binding.uiState = it
 
-            viewModel.tan.value = tan
+            submission_tan_character_error.setGone(it.areCharactersCorrect)
+            submission_tan_error.setGone(it.isTanValidFormat)
+        }
 
-            if (tan != null) {
-                if (!TanHelper.allCharactersValid(tan))
-                    showCharacterError()
+        binding.submissionTanContent.submissionTanInput.listener = { tan ->
+            submission_tan_character_error.visibility = View.GONE
+            submission_tan_error.visibility = View.GONE
 
-                if (tan.length == TanConstants.MAX_LENGTH && !TanHelper.isChecksumValid(tan))
-                    showTanError()
-            }
+            viewModel.onTanChanged(tan)
         }
 
-        binding.submissionTanButtonEnter.setOnClickListener { storeTanAndContinue() }
+        binding.submissionTanButtonEnter.setOnClickListener {
+            viewModel.onTanSubmit()
+        }
         binding.submissionTanHeader.headerButtonBack.buttonIcon.setOnClickListener { goBack() }
 
-        submissionViewModel.registrationState.observeEvent(viewLifecycleOwner) {
+        viewModel.registrationState.observe2(this) {
             binding.submissionTanSpinner.visibility = when (it) {
                 ApiRequestState.STARTED -> View.VISIBLE
                 else -> View.GONE
@@ -106,24 +68,11 @@ class SubmissionTanFragment : Fragment(R.layout.fragment_submission_tan), AutoIn
             }
         }
 
-        submissionViewModel.registrationError.observeEvent(viewLifecycleOwner) {
+        viewModel.registrationError.observe2(this) {
             DialogHelper.showDialog(buildErrorDialog(it))
         }
     }
 
-    private fun resetError() {
-        submission_tan_character_error.visibility = View.GONE
-        submission_tan_error.visibility = View.GONE
-    }
-
-    private fun showCharacterError() {
-        submission_tan_character_error.visibility = View.VISIBLE
-    }
-
-    private fun showTanError() {
-        submission_tan_error.visibility = View.VISIBLE
-    }
-
     override fun onResume() {
         super.onResume()
         binding.submissionTanRoot.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT)
@@ -131,14 +80,38 @@ class SubmissionTanFragment : Fragment(R.layout.fragment_submission_tan), AutoIn
 
     private fun goBack() = (activity as MainActivity).goBack()
 
-    private fun storeTanAndContinue() {
-        // verify input format
-        if (viewModel.isValidTanFormat.value != true)
-            return
-
-        // store locally
-        viewModel.storeTeletan()
-
-        submissionViewModel.doDeviceRegistration()
+    private fun buildErrorDialog(exception: CwaWebException): DialogHelper.DialogInstance {
+        return when (exception) {
+            is BadRequestException -> DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_error_dialog_web_test_paired_title_tan,
+                R.string.submission_error_dialog_web_test_paired_body_tan,
+                R.string.submission_error_dialog_web_test_paired_button_positive,
+                null,
+                true,
+                ::goBack
+            )
+            is CwaClientError, is CwaServerError -> DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_error_dialog_web_generic_error_title,
+                getString(
+                    R.string.submission_error_dialog_web_generic_network_error_body,
+                    exception.statusCode
+                ),
+                R.string.submission_error_dialog_web_generic_error_button_positive,
+                null,
+                true,
+                ::goBack
+            )
+            else -> DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_error_dialog_web_generic_error_title,
+                R.string.submission_error_dialog_web_generic_error_body,
+                R.string.submission_error_dialog_web_generic_error_button_positive,
+                null,
+                true,
+                ::goBack
+            )
+        }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTanModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanModule.kt
similarity index 90%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTanModule.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanModule.kt
index 5a05697d71229959b5716f7ba14c7c24ab095391..a33e0f4ca9d23dd81adf95d3eb6a3719a187d5a6 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTanModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanModule.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
+package de.rki.coronawarnapp.ui.submission.tan
 
 import dagger.Binds
 import dagger.Module
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b2fcfe37df70cdd766efa70050c84b350cba7336
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt
@@ -0,0 +1,81 @@
+package de.rki.coronawarnapp.ui.submission.tan
+
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asLiveData
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.exception.ExceptionCategory
+import de.rki.coronawarnapp.exception.TransactionException
+import de.rki.coronawarnapp.exception.http.CwaWebException
+import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.service.submission.SubmissionService
+import de.rki.coronawarnapp.storage.SubmissionRepository
+import de.rki.coronawarnapp.ui.submission.ApiRequestState
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
+import timber.log.Timber
+
+class SubmissionTanViewModel @AssistedInject constructor(
+    dispatcherProvider: DispatcherProvider
+) : CWAViewModel() {
+
+    private val currentTan = MutableStateFlow(Tan(""))
+
+    val state = currentTan.map { currentTan ->
+        UIState(
+            isTanValid = currentTan.isTanValid,
+            isTanValidFormat = currentTan.isTanValidFormat,
+            areCharactersCorrect = currentTan.areCharactersValid
+        )
+    }.asLiveData(context = dispatcherProvider.Default)
+
+    val registrationState = MutableLiveData(ApiRequestState.IDLE)
+    val registrationError = SingleLiveEvent<CwaWebException>()
+
+    fun onTanChanged(tan: String) {
+        currentTan.value = Tan(tan)
+    }
+
+    fun onTanSubmit() {
+        val teletan = currentTan.value
+        if (!teletan.isTanValid) {
+            Timber.w("Tried to set invalid teletan: %s", teletan)
+            return
+        }
+        Timber.d("Storing teletan $teletan")
+        SubmissionRepository.setTeletan(teletan.value)
+
+        launch {
+            try {
+                registrationState.postValue(ApiRequestState.STARTED)
+                SubmissionService.asyncRegisterDevice()
+                registrationState.postValue(ApiRequestState.SUCCESS)
+            } catch (err: CwaWebException) {
+                registrationState.postValue(ApiRequestState.FAILED)
+                registrationError.postValue(err)
+            } catch (err: TransactionException) {
+                if (err.cause is CwaWebException) {
+                    registrationError.postValue(err.cause)
+                } else {
+                    err.report(ExceptionCategory.INTERNAL)
+                }
+                registrationState.postValue(ApiRequestState.FAILED)
+            } catch (err: Exception) {
+                registrationState.postValue(ApiRequestState.FAILED)
+                err.report(ExceptionCategory.INTERNAL)
+            }
+        }
+    }
+
+    data class UIState(
+        val isTanValid: Boolean = false,
+        val areCharactersCorrect: Boolean = false,
+        val isTanValidFormat: Boolean = false
+    )
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<SubmissionTanViewModel>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a4d883605477fbc1295047d10155bb8bb5305b22
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt
@@ -0,0 +1,46 @@
+package de.rki.coronawarnapp.ui.submission.tan
+
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.util.Locale
+
+data class Tan(
+    val value: String
+) {
+
+    val areCharactersValid = allCharactersValid(value)
+    val isTanValidFormat = value.length == MAX_LENGTH && isChecksumValid(value)
+    val isTanValid = areCharactersValid && isTanValidFormat
+
+    companion object {
+        const val MAX_LENGTH = 10
+        internal val ALPHA_NUMERIC_CHARS = ('a'..'z').plus('A'..'Z').plus('0'..'9')
+
+        private const val VALID_CHARACTERS = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
+
+        fun isChecksumValid(tan: String): Boolean {
+            if (tan.trim().length != MAX_LENGTH)
+                return false
+            val subTan = tan.substring(0, MAX_LENGTH - 1).toUpperCase(Locale.ROOT)
+            val tanDigest = MessageDigest.getInstance("SHA-256")
+                .digest(subTan.toByteArray(StandardCharsets.US_ASCII))
+            var checkChar = "%02x".format(tanDigest[0])[0]
+            if (checkChar == '0') checkChar = 'G'
+            if (checkChar == '1') checkChar = 'H'
+
+            return checkChar.toUpperCase() == tan.last().toUpperCase()
+        }
+
+        fun allCharactersValid(tan: String): Boolean {
+            for (character in tan) {
+                if (!isTanCharacterValid(character.toString()))
+                    return false
+            }
+            return true
+        }
+
+        fun isTanCharacterValid(character: String): Boolean {
+            return VALID_CHARACTERS.contains(character)
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/view/TanInput.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/TanInput.kt
similarity index 90%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/view/TanInput.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/TanInput.kt
index 91a3e227e730b652d2a98bdb9cf2b9f915358106..04c1e7a8f0040f8bff6199ad7d1e752fc46751e6 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/view/TanInput.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/TanInput.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.view
+package de.rki.coronawarnapp.ui.submission.tan
 
 import android.content.Context
 import android.os.Handler
@@ -13,9 +13,7 @@ import androidx.annotation.DimenRes
 import androidx.core.view.children
 import androidx.core.widget.doOnTextChanged
 import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.ui.submission.TanConstants
-import de.rki.coronawarnapp.util.TanHelper
-import kotlinx.android.synthetic.main.view_tan_input_edittext.view.tan_input_edittext
+import kotlinx.android.synthetic.main.view_tan_input_edittext.view.*
 import java.util.Locale
 import kotlin.math.max
 
@@ -36,14 +34,14 @@ class TanInput(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs
         InputFilter { source, _, _, _, _, _ -> source.filter { !it.isWhitespace() } }
     private val alphaNumericFilter = InputFilter { source, _, _, _, _, _ ->
         source.filter {
-            TanConstants.ALPHA_NUMERIC_CHARS.contains(it)
+            Tan.ALPHA_NUMERIC_CHARS.contains(it)
         }
     }
-    private var lengthFilter = InputFilter.LengthFilter(TanConstants.MAX_LENGTH)
+    private var lengthFilter = InputFilter.LengthFilter(Tan.MAX_LENGTH)
 
-    var listener: ((String?) -> Unit)? = null
+    var listener: ((String) -> Unit)? = null
 
-    private var tan: String? = null
+    private var tan: String = ""
 
     private val lineSpacing: Int
 
@@ -61,11 +59,12 @@ class TanInput(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs
         tan_input_edittext.filters = arrayOf(whitespaceFilter, alphaNumericFilter, lengthFilter)
 
         // register listener
-        tan_input_edittext.doOnTextChanged { text, _, _, _ -> updateTan(text) }
+        tan_input_edittext.doOnTextChanged { text, _, _, _ -> updateTan(text ?: "") }
         setOnClickListener { showKeyboard() }
 
         // initially show the keyboard
-        Handler().postDelayed({ showKeyboard() },
+        Handler().postDelayed(
+            { showKeyboard() },
             KEYBOARD_TRIGGER_DELAY
         )
     }
@@ -77,8 +76,8 @@ class TanInput(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs
         }
     }
 
-    private fun updateTan(text: CharSequence?) {
-        this.tan = text?.toString()?.toUpperCase(Locale.ROOT)
+    private fun updateTan(text: CharSequence) {
+        this.tan = text.toString().toUpperCase(Locale.ROOT)
         updateDigits()
         notifyListener()
     }
@@ -98,7 +97,7 @@ class TanInput(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs
         tanDigit.text = text
         tanDigit.background = when {
             text == EMPTY_STRING -> resources.getDrawable(R.drawable.tan_input_digit, null)
-            TanHelper.isTanCharacterValid(text) -> resources.getDrawable(
+            Tan.isTanCharacterValid(text) -> resources.getDrawable(
                 R.drawable.tan_input_digit_entered,
                 null
             )
@@ -106,14 +105,14 @@ class TanInput(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs
         }
 
         tanDigit.setTextColor(
-            if (TanHelper.isTanCharacterValid(text))
+            if (Tan.isTanCharacterValid(text))
                 resources.getColor(R.color.colorTextPrimary1, null)
             else
                 resources.getColor(R.color.colorTextSemanticRed, null)
         )
     }
 
-    private fun digitAtIndex(index: Int): String = tan?.getOrNull(index)?.toString() ?: EMPTY_STRING
+    private fun digitAtIndex(index: Int): String = tan.getOrNull(index)?.toString() ?: EMPTY_STRING
 
     override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
         super.onMeasure(widthMeasureSpec, heightMeasureSpec)
@@ -206,7 +205,7 @@ class TanInput(context: Context, attrs: AttributeSet) : ViewGroup(context, attrs
     private fun calculateDigitDimension(availableWith: Int, textSize: Int): Pair<Int, Int> {
         val widthRequiredForSpacing =
             (DIGIT_SPACING_COUNT * getDimension(R.dimen.submission_tan_total_digit_spacing)) +
-                    (GROUP_SPACING_COUNT * getDimension(R.dimen.submission_tan_total_group_spacing))
+                (GROUP_SPACING_COUNT * getDimension(R.dimen.submission_tan_total_group_spacing))
 
         val remainingWidthForDigits = availableWith - widthRequiredForSpacing
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionTestResultFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt
similarity index 70%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionTestResultFragment.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt
index c0da337c77a6c1373d2090684ec976173f218194..0655e3e5893e889f563f5b0b7361e3f9e4efcf0d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionTestResultFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.fragment
+package de.rki.coronawarnapp.ui.submission.testresult
 
 import android.app.AlertDialog
 import android.os.Bundle
@@ -6,7 +6,6 @@ import android.view.View
 import android.view.accessibility.AccessibilityEvent
 import androidx.activity.OnBackPressedCallback
 import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentSubmissionTestResultBinding
 import de.rki.coronawarnapp.exception.http.CwaClientError
@@ -14,11 +13,7 @@ import de.rki.coronawarnapp.exception.http.CwaServerError
 import de.rki.coronawarnapp.exception.http.CwaWebException
 import de.rki.coronawarnapp.storage.SubmissionRepository
 import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionTestResultViewModel
-import de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel
-import de.rki.coronawarnapp.util.DeviceUIState
 import de.rki.coronawarnapp.util.DialogHelper
-import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.AutoInject
 import de.rki.coronawarnapp.util.observeEvent
 import de.rki.coronawarnapp.util.ui.doNavigate
@@ -26,20 +21,13 @@ import de.rki.coronawarnapp.util.ui.observe2
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
 import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.withContext
 import javax.inject.Inject
 
-/**
- * A simple [Fragment] subclass.
- */
 class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_result),
     AutoInject {
 
     @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
     private val viewModel: SubmissionTestResultViewModel by cwaViewModels { viewModelFactory }
-    private val submissionViewModel: SubmissionViewModel by activityViewModels()
 
     private val binding: FragmentSubmissionTestResultBinding by viewBindingLazy()
 
@@ -86,7 +74,11 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_
 
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
-        binding.submissionViewModel = submissionViewModel
+
+        viewModel.uiState.observe2(this) {
+            binding.uiState = it
+        }
+
         // registers callback when the os level back is pressed
         requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner, backCallback)
 
@@ -95,14 +87,29 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_
 
         setButtonOnClickListener()
 
-        submissionViewModel.uiStateError.observeEvent(viewLifecycleOwner) {
+        viewModel.showTracingRequiredScreen.observe2(this) {
+            val tracingRequiredDialog = DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_test_result_dialog_tracing_required_title,
+                R.string.submission_test_result_dialog_tracing_required_message,
+                R.string.submission_test_result_dialog_tracing_required_button
+            )
+            DialogHelper.showDialog(tracingRequiredDialog)
+        }
+
+        viewModel.uiStateError.observeEvent(viewLifecycleOwner) {
             DialogHelper.showDialog(buildErrorDialog(it))
         }
 
-        submissionViewModel.deviceUiState.observe2(this) { uiState ->
-            if (uiState == DeviceUIState.PAIRED_REDEEMED) {
-                showRedeemedTokenWarningDialog()
-            }
+        viewModel.showRedeemedTokenWarning.observe2(this) {
+            val dialog = DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_error_dialog_web_tan_redeemed_title,
+                R.string.submission_error_dialog_web_tan_redeemed_body,
+                R.string.submission_error_dialog_web_tan_redeemed_button_positive
+            )
+
+            DialogHelper.showDialog(dialog)
         }
 
         viewModel.routeToScreen.observe2(this) {
@@ -115,7 +122,9 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_
                 is SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning ->
                     doNavigate(
                         SubmissionTestResultFragmentDirections
-                            .actionSubmissionResultFragmentToSubmissionResultPositiveOtherWarningFragment()
+                            .actionSubmissionResultFragmentToSubmissionResultPositiveOtherWarningFragment(
+                                it.symptoms
+                            )
                     )
                 is SubmissionNavigationEvents.NavigateToMainActivity ->
                     doNavigate(
@@ -125,17 +134,6 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_
         }
     }
 
-    private fun showRedeemedTokenWarningDialog() {
-        val dialog = DialogHelper.DialogInstance(
-            requireActivity(),
-            R.string.submission_error_dialog_web_tan_redeemed_title,
-            R.string.submission_error_dialog_web_tan_redeemed_body,
-            R.string.submission_error_dialog_web_tan_redeemed_button_positive
-        )
-
-        DialogHelper.showDialog(dialog)
-    }
-
     override fun onResume() {
         super.onResume()
         binding.submissionTestResultContainer.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT)
@@ -160,12 +158,11 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_
         }
 
         binding.submissionTestResultButtonPositiveContinue.setOnClickListener {
-            continueIfTracingEnabled(false)
+            viewModel.onContinuePressed()
         }
 
         binding.submissionTestResultButtonPositiveContinueWithoutSymptoms.setOnClickListener {
-            submissionViewModel.onNoInformationSymptomIndication()
-            continueIfTracingEnabled(true)
+            viewModel.onContinueWithoutSymptoms()
         }
 
         binding.submissionTestResultButtonInvalidRemoveTest.setOnClickListener {
@@ -177,31 +174,6 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_
         }
     }
 
-    private fun continueIfTracingEnabled(skipSymptomSubmission: Boolean) {
-        // TODO Workaround until we have a VM injected that can handle this
-        submissionViewModel.launch {
-            val isTracingEnabled = AppInjector.component.enfClient.isTracingEnabled.first()
-            withContext(Dispatchers.Main) {
-                if (!isTracingEnabled) {
-                    val tracingRequiredDialog = DialogHelper.DialogInstance(
-                        requireActivity(),
-                        R.string.submission_test_result_dialog_tracing_required_title,
-                        R.string.submission_test_result_dialog_tracing_required_message,
-                        R.string.submission_test_result_dialog_tracing_required_button
-                    )
-                    DialogHelper.showDialog(tracingRequiredDialog)
-                    return@withContext
-                }
-
-                if (skipSymptomSubmission) {
-                    viewModel.onContinueNoSymptomsPressed()
-                } else {
-                    viewModel.onContinuePressed()
-                }
-            }
-        }
-    }
-
     private fun removeTestAfterConfirmation() {
         val removeTestDialog = DialogHelper.DialogInstance(
             requireActivity(),
@@ -210,8 +182,7 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_
             R.string.submission_test_result_dialog_remove_test_button_positive,
             R.string.submission_test_result_dialog_remove_test_button_negative,
             positiveButtonFunction = {
-                submissionViewModel.deregisterTestFromDevice()
-                viewModel.onNavigateTestRemoved()
+                viewModel.deregisterTestFromDevice()
             }
         )
         DialogHelper.showDialog(removeTestDialog).apply {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTestResultModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultModule.kt
similarity index 90%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTestResultModule.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultModule.kt
index a10ad1086c01cfb63f2c70f7566f94db5e95f854..a81b3bf8e6f3c2eab36f14ed34f9807f902222a7 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTestResultModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultModule.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
+package de.rki.coronawarnapp.ui.submission.testresult
 
 import dagger.Binds
 import dagger.Module
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..96766c26904d12ab16c85b2809fe68477248cb0d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt
@@ -0,0 +1,101 @@
+package de.rki.coronawarnapp.ui.submission.testresult
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.asLiveData
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.exception.http.CwaWebException
+import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.service.submission.SubmissionService
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.storage.SubmissionRepository
+import de.rki.coronawarnapp.submission.Symptoms
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
+import de.rki.coronawarnapp.util.DeviceUIState
+import de.rki.coronawarnapp.util.Event
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.flow.combineTransform
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import timber.log.Timber
+
+class SubmissionTestResultViewModel @AssistedInject constructor(
+    dispatcherProvider: DispatcherProvider,
+    private val enfClient: ENFClient
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
+    val showTracingRequiredScreen = SingleLiveEvent<Unit>()
+    val showRedeemedTokenWarning = SingleLiveEvent<Unit>()
+
+    private var wasRedeemedTokenErrorShown = false
+    private val tokenErrorMutex = Mutex()
+
+    val uiState: LiveData<TestResultUIState> = combineTransform(
+        SubmissionRepository.uiStateStateFlow,
+        SubmissionRepository.deviceUIStateFlow,
+        SubmissionRepository.testResultReceivedDateFlow
+    ) { apiRequestState, deviceUiState, resultDate ->
+
+        tokenErrorMutex.withLock {
+            if (!wasRedeemedTokenErrorShown && deviceUiState == DeviceUIState.PAIRED_REDEEMED) {
+                wasRedeemedTokenErrorShown = true
+                showRedeemedTokenWarning.postValue(Unit)
+            }
+        }
+
+        TestResultUIState(
+            apiRequestState = apiRequestState,
+            deviceUiState = deviceUiState,
+            testResultReceivedDate = resultDate
+        ).let { emit(it) }
+    }.asLiveData(context = dispatcherProvider.Default)
+
+    val uiStateError: LiveData<Event<CwaWebException>> = SubmissionRepository.uiStateError
+
+    fun onBackPressed() {
+        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToMainActivity)
+    }
+
+    fun onContinuePressed() {
+        Timber.d("onContinuePressed()")
+        requireTracingOrShowError {
+            routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSymptomIntroduction)
+        }
+    }
+
+    fun onContinueWithoutSymptoms() {
+        Timber.d("onContinueWithoutSymptoms()")
+        requireTracingOrShowError {
+            Symptoms.NO_INFO_GIVEN
+                .let { SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning(it) }
+                .let { routeToScreen.postValue(it) }
+        }
+    }
+
+    private fun requireTracingOrShowError(action: () -> Unit) = launch {
+        if (enfClient.isTracingEnabled.first()) {
+            action()
+        } else {
+            showTracingRequiredScreen.postValue(Unit)
+        }
+    }
+
+    fun deregisterTestFromDevice() {
+        launch {
+            Timber.d("deregisterTestFromDevice()")
+            SubmissionService.deleteTestGUID()
+            SubmissionService.deleteRegistrationToken()
+            LocalData.isAllowedToSubmitDiagnosisKeys(false)
+            LocalData.initialTestResultReceivedTimestamp(0L)
+
+            routeToScreen.postValue(SubmissionNavigationEvents.NavigateToMainActivity)
+        }
+    }
+
+    @AssistedInject.Factory
+    interface Factory : SimpleCWAViewModelFactory<SubmissionTestResultViewModel>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/TestResultUIState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/TestResultUIState.kt
new file mode 100644
index 0000000000000000000000000000000000000000..b1909eb805d6cbaa923b41336f14d60cb30aa45b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/TestResultUIState.kt
@@ -0,0 +1,11 @@
+package de.rki.coronawarnapp.ui.submission.testresult
+
+import de.rki.coronawarnapp.ui.submission.ApiRequestState
+import de.rki.coronawarnapp.util.DeviceUIState
+import java.util.Date
+
+data class TestResultUIState(
+    val apiRequestState: ApiRequestState,
+    val deviceUiState: DeviceUIState,
+    val testResultReceivedDate: Date?
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/SubmissionFragmentModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionFragmentModule.kt
similarity index 61%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/SubmissionFragmentModule.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionFragmentModule.kt
index 0378f1666f6cad28665bc0026c3f48174860b0f1..07cdfe2f9d8fc00919ab4de58fb2b4d8c4af0592 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/SubmissionFragmentModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionFragmentModule.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission
+package de.rki.coronawarnapp.ui.submission.viewmodel
 
 import dagger.Module
 import dagger.android.ContributesAndroidInjector
@@ -6,24 +6,20 @@ import de.rki.coronawarnapp.ui.submission.fragment.SubmissionContactFragment
 import de.rki.coronawarnapp.ui.submission.fragment.SubmissionDispatcherFragment
 import de.rki.coronawarnapp.ui.submission.fragment.SubmissionDoneFragment
 import de.rki.coronawarnapp.ui.submission.fragment.SubmissionIntroFragment
-import de.rki.coronawarnapp.ui.submission.fragment.SubmissionQRCodeInfoFragment
-import de.rki.coronawarnapp.ui.submission.fragment.SubmissionQRCodeInfoModule
-import de.rki.coronawarnapp.ui.submission.fragment.SubmissionQRCodeScanFragment
-import de.rki.coronawarnapp.ui.submission.fragment.SubmissionResultPositiveOtherWarningFragment
-import de.rki.coronawarnapp.ui.submission.fragment.SubmissionSymptomCalendarFragment
-import de.rki.coronawarnapp.ui.submission.fragment.SubmissionSymptomIntroductionFragment
-import de.rki.coronawarnapp.ui.submission.fragment.SubmissionTanFragment
-import de.rki.coronawarnapp.ui.submission.fragment.SubmissionTestResultFragment
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionContactModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionDispatcherModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionDoneModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionIntroModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionQRCodeScanModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionResultPositiveOtherWarningModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionSymptomCalendarModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionSymptomIntroductionModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionTanModule
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionTestResultModule
+import de.rki.coronawarnapp.ui.submission.qrcode.info.SubmissionQRCodeInfoFragment
+import de.rki.coronawarnapp.ui.submission.qrcode.info.SubmissionQRCodeInfoModule
+import de.rki.coronawarnapp.ui.submission.qrcode.scan.SubmissionQRCodeScanFragment
+import de.rki.coronawarnapp.ui.submission.qrcode.scan.SubmissionQRCodeScanModule
+import de.rki.coronawarnapp.ui.submission.symptoms.calendar.SubmissionSymptomCalendarFragment
+import de.rki.coronawarnapp.ui.submission.symptoms.calendar.SubmissionSymptomCalendarModule
+import de.rki.coronawarnapp.ui.submission.symptoms.introduction.SubmissionSymptomIntroductionFragment
+import de.rki.coronawarnapp.ui.submission.symptoms.introduction.SubmissionSymptomIntroductionModule
+import de.rki.coronawarnapp.ui.submission.tan.SubmissionTanFragment
+import de.rki.coronawarnapp.ui.submission.tan.SubmissionTanModule
+import de.rki.coronawarnapp.ui.submission.testresult.SubmissionTestResultFragment
+import de.rki.coronawarnapp.ui.submission.testresult.SubmissionTestResultModule
+import de.rki.coronawarnapp.ui.submission.warnothers.SubmissionResultPositiveOtherWarningFragment
+import de.rki.coronawarnapp.ui.submission.warnothers.SubmissionResultPositiveOtherWarningModule
 
 @Module
 internal abstract class SubmissionFragmentModule {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionNavigationEvents.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionNavigationEvents.kt
index 347cb1c18a335a8a9d72926b04e4721c36bf23be..5a0fe235fefec008d7d167cfe47144d5fd755f95 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionNavigationEvents.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionNavigationEvents.kt
@@ -1,14 +1,23 @@
 package de.rki.coronawarnapp.ui.submission.viewmodel
 
+import de.rki.coronawarnapp.submission.Symptoms
+
 sealed class SubmissionNavigationEvents {
     object NavigateToContact : SubmissionNavigationEvents()
     object NavigateToDispatcher : SubmissionNavigationEvents()
     object NavigateToSubmissionDone : SubmissionNavigationEvents()
     object NavigateToSubmissionIntro : SubmissionNavigationEvents()
     object NavigateToQRCodeScan : SubmissionNavigationEvents()
-    object NavigateToResultPositiveOtherWarning : SubmissionNavigationEvents()
+
+    data class NavigateToResultPositiveOtherWarning(
+        val symptoms: Symptoms
+    ) : SubmissionNavigationEvents()
+
     object NavigateToSymptomSubmission : SubmissionNavigationEvents()
-    object NavigateToSymptomCalendar : SubmissionNavigationEvents()
+    data class NavigateToSymptomCalendar(
+        val symptomIndication: Symptoms.Indication
+    ) : SubmissionNavigationEvents()
+
     object NavigateToSymptomIntroduction : SubmissionNavigationEvents()
     object NavigateToTAN : SubmissionNavigationEvents()
     object NavigateToTestResult : SubmissionNavigationEvents()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeScanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeScanViewModel.kt
deleted file mode 100644
index ae575ce54fc6d2cc6cd8ab3c3d2a7018f955b345..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeScanViewModel.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
-
-import com.squareup.inject.assisted.AssistedInject
-import de.rki.coronawarnapp.util.ui.SingleLiveEvent
-import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
-import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-
-class SubmissionQRCodeScanViewModel @AssistedInject constructor() : CWAViewModel() {
-
-    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
-
-    fun onBackPressed() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToQRInfo)
-    }
-
-    fun onClosePressed() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToDispatcher)
-    }
-
-    @AssistedInject.Factory
-    interface Factory : SimpleCWAViewModelFactory<SubmissionQRCodeScanViewModel>
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionResultPositiveOtherWarningViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionResultPositiveOtherWarningViewModel.kt
deleted file mode 100644
index a2b242ceaca511028fdbfadfdff686813b416f4c..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionResultPositiveOtherWarningViewModel.kt
+++ /dev/null
@@ -1,26 +0,0 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
-
-import com.squareup.inject.assisted.AssistedInject
-import de.rki.coronawarnapp.util.ui.SingleLiveEvent
-import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
-import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-
-class SubmissionResultPositiveOtherWarningViewModel @AssistedInject constructor() : CWAViewModel() {
-
-    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
-
-    fun onBackPressed() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToTestResult)
-    }
-
-    fun onWarnOthersPressed() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSymptomIntroduction)
-    }
-
-    fun onSubmissionComplete() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSubmissionDone)
-    }
-
-    @AssistedInject.Factory
-    interface Factory : SimpleCWAViewModelFactory<SubmissionResultPositiveOtherWarningViewModel>
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomCalendarViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomCalendarViewModel.kt
deleted file mode 100644
index 38bd530b33e363ebaebad3d0c1e11eb3ee2af652..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomCalendarViewModel.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
-
-import com.squareup.inject.assisted.AssistedInject
-import de.rki.coronawarnapp.util.ui.SingleLiveEvent
-import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
-import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-
-class SubmissionSymptomCalendarViewModel @AssistedInject constructor() : CWAViewModel() {
-
-    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
-
-    fun onCalendarNextClicked() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning)
-    }
-
-    fun onCalendarPreviousClicked() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSymptomIntroduction)
-    }
-
-    @AssistedInject.Factory
-    interface Factory : SimpleCWAViewModelFactory<SubmissionSymptomCalendarViewModel>
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomIntroductionViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomIntroductionViewModel.kt
deleted file mode 100644
index a79f4a1b3204e61c841e0b1419f9a59d03acefc3..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionSymptomIntroductionViewModel.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
-
-import com.squareup.inject.assisted.AssistedInject
-import de.rki.coronawarnapp.util.ui.SingleLiveEvent
-import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
-import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-
-class SubmissionSymptomIntroductionViewModel @AssistedInject constructor() : CWAViewModel() {
-
-    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
-
-    fun onNextClicked() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSymptomCalendar)
-    }
-
-    fun onPreviousClicked() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToTestResult)
-    }
-
-    @AssistedInject.Factory
-    interface Factory : SimpleCWAViewModelFactory<SubmissionSymptomIntroductionViewModel>
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTanViewModel.kt
deleted file mode 100644
index 2a33f7239397b9bb01df36b8608c8b23516c07bc..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTanViewModel.kt
+++ /dev/null
@@ -1,37 +0,0 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
-
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.Transformations
-import com.squareup.inject.assisted.AssistedInject
-import de.rki.coronawarnapp.storage.SubmissionRepository
-import de.rki.coronawarnapp.ui.submission.TanConstants
-import de.rki.coronawarnapp.util.TanHelper
-import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
-import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-import timber.log.Timber
-
-class SubmissionTanViewModel @AssistedInject constructor() : CWAViewModel() {
-
-    companion object {
-        private val TAG: String? = SubmissionTanViewModel::class.simpleName
-    }
-
-    val tan = MutableLiveData<String?>(null)
-
-    val isValidTanFormat =
-        Transformations.map(tan) {
-            it != null &&
-                    it.length == TanConstants.MAX_LENGTH &&
-                    TanHelper.isChecksumValid(it) &&
-                    TanHelper.allCharactersValid(it)
-        }
-
-    fun storeTeletan() {
-        val teletan = tan.value!!
-        Timber.d("Storing teletan $teletan")
-        SubmissionRepository.setTeletan(teletan)
-    }
-
-    @AssistedInject.Factory
-    interface Factory : SimpleCWAViewModelFactory<SubmissionTanViewModel>
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTestResultViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTestResultViewModel.kt
deleted file mode 100644
index 5c96c86ecfc27250f9499765a8e9a58a96536cf8..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionTestResultViewModel.kt
+++ /dev/null
@@ -1,30 +0,0 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
-
-import com.squareup.inject.assisted.AssistedInject
-import de.rki.coronawarnapp.util.ui.SingleLiveEvent
-import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
-import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
-
-class SubmissionTestResultViewModel @AssistedInject constructor() : CWAViewModel() {
-
-    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
-
-    fun onBackPressed() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToMainActivity)
-    }
-
-    fun onNavigateTestRemoved() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToMainActivity)
-    }
-
-    fun onContinuePressed() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSymptomIntroduction)
-    }
-
-    fun onContinueNoSymptomsPressed() {
-        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToResultPositiveOtherWarning)
-    }
-
-    @AssistedInject.Factory
-    interface Factory : SimpleCWAViewModelFactory<SubmissionTestResultViewModel>
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionResultPositiveOtherWarningFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningFragment.kt
similarity index 60%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionResultPositiveOtherWarningFragment.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningFragment.kt
index 1d96f4804519e3e36d919cecfd46aa072190729f..19dcf06c4176614c9980ad174f6872034bc164d0 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/fragment/SubmissionResultPositiveOtherWarningFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningFragment.kt
@@ -1,12 +1,12 @@
-package de.rki.coronawarnapp.ui.submission.fragment
+package de.rki.coronawarnapp.ui.submission.warnothers
 
 import android.content.Intent
 import android.os.Bundle
 import android.view.View
 import android.view.accessibility.AccessibilityEvent
 import androidx.fragment.app.Fragment
-import androidx.fragment.app.activityViewModels
 import androidx.navigation.fragment.findNavController
+import androidx.navigation.fragment.navArgs
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentSubmissionPositiveOtherWarningBinding
@@ -16,43 +16,113 @@ import de.rki.coronawarnapp.exception.http.CwaServerError
 import de.rki.coronawarnapp.exception.http.CwaWebException
 import de.rki.coronawarnapp.exception.http.ForbiddenException
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationPermissionHelper
-import de.rki.coronawarnapp.ui.submission.ApiRequestState
 import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionResultPositiveOtherWarningViewModel
-import de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel
 import de.rki.coronawarnapp.util.DialogHelper
-import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.AutoInject
-import de.rki.coronawarnapp.util.observeEvent
 import de.rki.coronawarnapp.util.ui.doNavigate
 import de.rki.coronawarnapp.util.ui.observe2
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider
-import de.rki.coronawarnapp.util.viewmodel.cwaViewModels
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.withContext
+import de.rki.coronawarnapp.util.viewmodel.cwaViewModelsAssisted
 import javax.inject.Inject
 
 class SubmissionResultPositiveOtherWarningFragment :
     Fragment(R.layout.fragment_submission_positive_other_warning),
     InternalExposureNotificationPermissionHelper.Callback, AutoInject {
 
+    private val navArgs by navArgs<SubmissionResultPositiveOtherWarningFragmentArgs>()
+
     @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory
-    private val viewModel: SubmissionResultPositiveOtherWarningViewModel by cwaViewModels { viewModelFactory }
-    private val submissionViewModel: SubmissionViewModel by activityViewModels()
+    private val viewModel: SubmissionResultPositiveOtherWarningViewModel by cwaViewModelsAssisted(
+        factoryProducer = { viewModelFactory },
+        constructorCall = { factory, _ ->
+            factory as SubmissionResultPositiveOtherWarningViewModel.Factory
+            factory.create(navArgs.symptoms)
+        }
+    )
 
     private val binding: FragmentSubmissionPositiveOtherWarningBinding by viewBindingLazy()
-    private lateinit var internalExposureNotificationPermissionHelper:
-        InternalExposureNotificationPermissionHelper
+    private lateinit var internalExposureNotificationPermissionHelper: InternalExposureNotificationPermissionHelper
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+
+        viewModel.uiState.observe2(this) {
+            binding.uiState = it
+        }
+
+        internalExposureNotificationPermissionHelper =
+            InternalExposureNotificationPermissionHelper(this, this)
+
+        binding.submissionPositiveOtherWarningButtonNext.setOnClickListener {
+            viewModel.onWarnOthersPressed()
+        }
+        binding.submissionPositiveOtherWarningHeader.headerButtonBack.buttonIcon.setOnClickListener {
+            viewModel.onBackPressed()
+        }
+
+        viewModel.routeToScreen.observe2(this) {
+            when (it) {
+                is SubmissionNavigationEvents.NavigateToSubmissionIntro -> doNavigate(
+                    SubmissionResultPositiveOtherWarningFragmentDirections
+                        .actionSubmissionResultPositiveOtherWarningFragmentToSubmissionDoneFragment()
+                )
+                is SubmissionNavigationEvents.NavigateToSubmissionDone -> doNavigate(
+                    SubmissionResultPositiveOtherWarningFragmentDirections
+                        .actionSubmissionResultPositiveOtherWarningFragmentToSubmissionDoneFragment()
+                )
+                is SubmissionNavigationEvents.NavigateToTestResult -> findNavController().popBackStack()
+            }
+        }
+
+        viewModel.submissionError.observe2(this) {
+            DialogHelper.showDialog(buildErrorDialog(it))
+        }
+
+        viewModel.requestKeySharing.observe2(this) {
+            internalExposureNotificationPermissionHelper.requestPermissionToShareKeys()
+        }
+
+        viewModel.showEnableTracingEvent.observe2(this) {
+            val tracingRequiredDialog = DialogHelper.DialogInstance(
+                requireActivity(),
+                R.string.submission_test_result_dialog_tracing_required_title,
+                R.string.submission_test_result_dialog_tracing_required_message,
+                R.string.submission_test_result_dialog_tracing_required_button
+            )
+            DialogHelper.showDialog(tracingRequiredDialog)
+        }
+    }
 
     override fun onResume() {
         super.onResume()
         binding.submissionPositiveOtherPrivacyContainer.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT)
     }
 
-    private fun buildErrorDialog(exception: CwaWebException): DialogHelper.DialogInstance {
-        return when (exception) {
+    private fun navigateToSubmissionResultFragment() = doNavigate(
+        SubmissionResultPositiveOtherWarningFragmentDirections
+            .actionSubmissionResultPositiveOtherWarningFragmentToSubmissionResultFragment()
+    )
+
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        this.internalExposureNotificationPermissionHelper.onResolutionComplete(
+            requestCode,
+            resultCode
+        )
+    }
+
+    // InternalExposureNotificationPermissionHelper - callbacks
+    override fun onKeySharePermissionGranted(keys: List<TemporaryExposureKey>) {
+        super.onKeySharePermissionGranted(keys)
+        viewModel.onKeysShared(keys)
+    }
+
+    override fun onFailure(exception: Exception?) {
+        // NOOP
+    }
+
+    private fun buildErrorDialog(throwable: Throwable): DialogHelper.DialogInstance {
+        return when (throwable) {
             is BadRequestException -> DialogHelper.DialogInstance(
                 requireActivity(),
                 R.string.submission_error_dialog_web_paring_invalid_title,
@@ -76,7 +146,7 @@ class SubmissionResultPositiveOtherWarningFragment :
                 R.string.submission_error_dialog_web_generic_error_title,
                 getString(
                     R.string.submission_error_dialog_web_generic_network_error_body,
-                    exception.statusCode
+                    (throwable as CwaWebException).statusCode
                 ),
                 R.string.submission_error_dialog_web_generic_error_button_positive,
                 null,
@@ -94,104 +164,4 @@ class SubmissionResultPositiveOtherWarningFragment :
             )
         }
     }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-        binding.submissionViewModel = submissionViewModel
-
-        internalExposureNotificationPermissionHelper =
-            InternalExposureNotificationPermissionHelper(this, this)
-
-        setButtonOnClickListener()
-
-        submissionViewModel.submissionError.observeEvent(viewLifecycleOwner) {
-            DialogHelper.showDialog(buildErrorDialog(it))
-        }
-
-        submissionViewModel.submissionState.observe2(this) {
-            if (it == ApiRequestState.SUCCESS) {
-                viewModel.onSubmissionComplete()
-            }
-        }
-    }
-
-    private fun setButtonOnClickListener() {
-        binding.submissionPositiveOtherWarningButtonNext.setOnClickListener {
-            initiateWarningOthers()
-            viewModel.onWarnOthersPressed()
-        }
-        binding.submissionPositiveOtherWarningHeader.headerButtonBack.buttonIcon.setOnClickListener {
-            findNavController().popBackStack()
-            viewModel.onBackPressed()
-        }
-
-        viewModel.routeToScreen.observe2(this) {
-            when (it) {
-                is SubmissionNavigationEvents.NavigateToSubmissionIntro ->
-                    initiateWarningOthers()
-                is SubmissionNavigationEvents.NavigateToSubmissionDone ->
-                    navigateToSubmissionDoneFragment()
-                is SubmissionNavigationEvents.NavigateToTestResult ->
-                    findNavController().popBackStack()
-            }
-        }
-    }
-
-    private fun navigateToSubmissionResultFragment() =
-        doNavigate(
-            SubmissionResultPositiveOtherWarningFragmentDirections
-                .actionSubmissionResultPositiveOtherWarningFragmentToSubmissionResultFragment()
-        )
-
-    /**
-     * Navigate to submission done Fragment
-     * @see SubmissionDoneFragment
-     */
-    private fun navigateToSubmissionDoneFragment() =
-        doNavigate(
-            SubmissionResultPositiveOtherWarningFragmentDirections
-                .actionSubmissionResultPositiveOtherWarningFragmentToSubmissionDoneFragment()
-        )
-
-    private fun initiateWarningOthers() {
-        // TODO remove after VM Injection, workaround, should not happen in the fragment
-        submissionViewModel.launch {
-            val isTracingEnabled = AppInjector.component.enfClient.isTracingEnabled.first()
-            withContext(Dispatchers.Main) {
-                if (!isTracingEnabled) {
-                    val tracingRequiredDialog = DialogHelper.DialogInstance(
-                        requireActivity(),
-                        R.string.submission_test_result_dialog_tracing_required_title,
-                        R.string.submission_test_result_dialog_tracing_required_message,
-                        R.string.submission_test_result_dialog_tracing_required_button
-                    )
-                    DialogHelper.showDialog(tracingRequiredDialog)
-                } else {
-                    internalExposureNotificationPermissionHelper.requestPermissionToShareKeys()
-                }
-            }
-        }
-    }
-
-    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
-        this.internalExposureNotificationPermissionHelper.onResolutionComplete(
-            requestCode,
-            resultCode
-        )
-    }
-
-    // InternalExposureNotificationPermissionHelper - callbacks
-    override fun onKeySharePermissionGranted(keys: List<TemporaryExposureKey>) {
-        super.onKeySharePermissionGranted(keys)
-        if (keys.isNotEmpty()) {
-            submissionViewModel.submitDiagnosisKeys(keys)
-        } else {
-            submissionViewModel.submitWithNoDiagnosisKeys()
-            viewModel.onSubmissionComplete()
-        }
-    }
-
-    override fun onFailure(exception: Exception?) {
-        // NOOP
-    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionResultPositiveOtherWarningModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningModule.kt
similarity index 91%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionResultPositiveOtherWarningModule.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningModule.kt
index 41a0322ceeb2edc24cbd7d66d4eec75fec795688..3ff8fc4cf5f20ecee8060a6f925b5f16cb43bf13 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionResultPositiveOtherWarningModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningModule.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
+package de.rki.coronawarnapp.ui.submission.warnothers
 
 import dagger.Binds
 import dagger.Module
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9f1591ec0cb161e753cf68dbd1add15dc65bb310
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt
@@ -0,0 +1,122 @@
+package de.rki.coronawarnapp.ui.submission.warnothers
+
+import androidx.lifecycle.asLiveData
+import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.exception.NoRegistrationTokenSetException
+import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.service.submission.SubmissionService
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.storage.interoperability.InteroperabilityRepository
+import de.rki.coronawarnapp.submission.SubmissionTask
+import de.rki.coronawarnapp.submission.Symptoms
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.TaskState
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
+import de.rki.coronawarnapp.ui.submission.ApiRequestState
+import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionNavigationEvents
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
+import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
+import kotlinx.coroutines.flow.combineTransform
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import timber.log.Timber
+import java.util.UUID
+
+class SubmissionResultPositiveOtherWarningViewModel @AssistedInject constructor(
+    @Assisted private val symptoms: Symptoms,
+    dispatcherProvider: DispatcherProvider,
+    private val enfClient: ENFClient,
+    private val taskController: TaskController,
+    interoperabilityRepository: InteroperabilityRepository
+) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
+
+    private var currentSubmissionRequestId: UUID? = null
+    private val currentSubmission = taskController.tasks
+            .map { it.find { taskInfo -> taskInfo.taskState.type == SubmissionTask::class }?.taskState }
+    private val submissionState = currentSubmission
+            .map { taskState ->
+                when {
+                    taskState == null -> ApiRequestState.IDLE
+                    taskState.isFailed -> ApiRequestState.FAILED.also { updateUI(taskState) }
+                    taskState.isFinished -> ApiRequestState.SUCCESS.also { updateUI(taskState) }
+                    else -> ApiRequestState.STARTED
+                }
+            }
+    val submissionError = SingleLiveEvent<Throwable>()
+
+    val uiState = combineTransform(
+            submissionState,
+            interoperabilityRepository.countryListFlow
+    ) { state, countries ->
+        WarnOthersState(
+                apiRequestState = state,
+                countryList = countries
+        ).also { emit(it) }
+    }.asLiveData(context = dispatcherProvider.Default)
+
+    val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent()
+
+    val requestKeySharing = SingleLiveEvent<Unit>()
+    val showEnableTracingEvent = SingleLiveEvent<Unit>()
+
+    private fun updateUI(taskState: TaskState) {
+        if (taskState.request.id == currentSubmissionRequestId) {
+            currentSubmissionRequestId = null
+            when {
+                taskState.isFailed ->
+                    submissionError.postValue(taskState.error ?: return)
+                taskState.isSuccessful ->
+                    routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSubmissionDone)
+            }
+        }
+    }
+
+    fun onBackPressed() {
+        routeToScreen.postValue(SubmissionNavigationEvents.NavigateToTestResult)
+    }
+
+    fun onWarnOthersPressed() {
+        launch {
+            if (enfClient.isTracingEnabled.first()) {
+                requestKeySharing.postValue(Unit)
+            } else {
+                showEnableTracingEvent.postValue(Unit)
+            }
+        }
+    }
+
+    fun onKeysShared(keys: List<TemporaryExposureKey>) {
+        if (keys.isNotEmpty()) {
+            submitDiagnosisKeys(keys)
+        } else {
+            submitWithNoDiagnosisKeys()
+            routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSubmissionDone)
+        }
+    }
+
+    private fun submitDiagnosisKeys(keys: List<TemporaryExposureKey>) {
+        Timber.d("submitDiagnosisKeys(keys=%s, symptoms=%s)", keys, symptoms)
+        val registrationToken =
+                LocalData.registrationToken() ?: throw NoRegistrationTokenSetException()
+        val taskRequest = DefaultTaskRequest(
+                SubmissionTask::class,
+                SubmissionTask.Arguments(registrationToken, keys, symptoms)
+        )
+        currentSubmissionRequestId = taskRequest.id
+        taskController.submit(taskRequest)
+    }
+
+    private fun submitWithNoDiagnosisKeys() {
+        Timber.d("submitWithNoDiagnosisKeys()")
+        SubmissionService.submissionSuccessful()
+    }
+
+    @AssistedInject.Factory
+    interface Factory : CWAViewModelFactory<SubmissionResultPositiveOtherWarningViewModel> {
+        fun create(symptoms: Symptoms): SubmissionResultPositiveOtherWarningViewModel
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/WarnOthersState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/WarnOthersState.kt
new file mode 100644
index 0000000000000000000000000000000000000000..33809f6b7780c42c014d3c0bfb0698deee4aab2b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/WarnOthersState.kt
@@ -0,0 +1,17 @@
+package de.rki.coronawarnapp.ui.submission.warnothers
+
+import de.rki.coronawarnapp.ui.Country
+import de.rki.coronawarnapp.ui.submission.ApiRequestState
+
+data class WarnOthersState(
+    val apiRequestState: ApiRequestState,
+    val countryList: List<Country>
+) {
+
+    fun isSubmitButtonEnabled(): Boolean =
+        apiRequestState == ApiRequestState.IDLE || apiRequestState == ApiRequestState.FAILED
+
+    fun isSubmitSpinnerVisible(): Boolean {
+        return apiRequestState == ApiRequestState.STARTED
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModel.kt
index c1079bbdd87eef234f6a31dc0a3cac320cd83265..6b668e6c420a52ee06796f5a4365794fd96452b6 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModel.kt
@@ -1,175 +1,12 @@
 package de.rki.coronawarnapp.ui.viewmodel
 
 import androidx.lifecycle.LiveData
-import androidx.lifecycle.MutableLiveData
-import androidx.lifecycle.viewModelScope
-import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
-import de.rki.coronawarnapp.exception.ExceptionCategory
-import de.rki.coronawarnapp.exception.TransactionException
-import de.rki.coronawarnapp.exception.http.CwaWebException
-import de.rki.coronawarnapp.exception.reporting.report
-import de.rki.coronawarnapp.service.submission.QRScanResult
-import de.rki.coronawarnapp.service.submission.SubmissionService
-import de.rki.coronawarnapp.storage.LocalData
+import androidx.lifecycle.asLiveData
 import de.rki.coronawarnapp.storage.SubmissionRepository
-import de.rki.coronawarnapp.storage.interoperability.InteroperabilityRepository
-import de.rki.coronawarnapp.submission.Symptoms
-import de.rki.coronawarnapp.ui.submission.ApiRequestState
-import de.rki.coronawarnapp.ui.submission.ScanStatus
 import de.rki.coronawarnapp.util.DeviceUIState
-import de.rki.coronawarnapp.util.Event
-import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
-import kotlinx.coroutines.launch
-import org.joda.time.LocalDate
-import timber.log.Timber
-import java.util.Date
 
 class SubmissionViewModel : CWAViewModel() {
-    private val _scanStatus = MutableLiveData(Event(ScanStatus.STARTED))
 
-    private val _registrationState = MutableLiveData(Event(ApiRequestState.IDLE))
-    private val _registrationError = MutableLiveData<Event<CwaWebException>>(null)
-
-    private val _submissionState = MutableLiveData(ApiRequestState.IDLE)
-    private val _submissionError = MutableLiveData<Event<CwaWebException>>(null)
-    private val interoperabilityRepository: InteroperabilityRepository
-        get() = AppInjector.component.interoperabilityRepository
-
-    val scanStatus: LiveData<Event<ScanStatus>> = _scanStatus
-
-    val registrationState: LiveData<Event<ApiRequestState>> = _registrationState
-    val registrationError: LiveData<Event<CwaWebException>> = _registrationError
-
-    val uiStateState: LiveData<ApiRequestState> = SubmissionRepository.uiStateState
-    val uiStateError: LiveData<Event<CwaWebException>> = SubmissionRepository.uiStateError
-
-    val submissionState: LiveData<ApiRequestState> = _submissionState
-    val submissionError: LiveData<Event<CwaWebException>> = _submissionError
-
-    val testResultReceivedDate: LiveData<Date> = SubmissionRepository.testResultReceivedDate
-    val deviceUiState: LiveData<DeviceUIState> = SubmissionRepository.deviceUIState
-
-    val symptomIndication = MutableLiveData<Symptoms.Indication?>()
-    val symptomStart = MutableLiveData<Symptoms.StartOf?>()
-
-    val countryList by lazy {
-        MutableLiveData(interoperabilityRepository.countryList)
-    }
-
-    fun initSymptoms() {
-        symptomIndication.postValue(null)
-    }
-
-    fun initSymptomStart() {
-        symptomStart.postValue(null)
-    }
-
-    fun submitDiagnosisKeys(keys: List<TemporaryExposureKey>) {
-        val indication = symptomIndication.value
-        if (indication == null) {
-            Timber.w("symptoms indicator is null")
-            return
-        }
-        Symptoms(symptomStart.value, indication).also {
-            viewModelScope.launch {
-                try {
-                    _submissionState.value = ApiRequestState.STARTED
-                    SubmissionService.asyncSubmitExposureKeys(keys, it)
-                    _submissionState.value = ApiRequestState.SUCCESS
-                } catch (err: CwaWebException) {
-                    _submissionError.value = Event(err)
-                    _submissionState.value = ApiRequestState.FAILED
-                } catch (err: TransactionException) {
-                    if (err.cause is CwaWebException) {
-                        _submissionError.value = Event(err.cause)
-                    } else {
-                        err.report(ExceptionCategory.INTERNAL)
-                    }
-                    _submissionState.value = ApiRequestState.FAILED
-                } catch (err: Exception) {
-                    _submissionState.value = ApiRequestState.FAILED
-                    err.report(ExceptionCategory.INTERNAL)
-                }
-            }
-        }
-    }
-
-    fun doDeviceRegistration() = viewModelScope.launch {
-        try {
-            _registrationState.value = Event(ApiRequestState.STARTED)
-            SubmissionService.asyncRegisterDevice()
-            _registrationState.value = Event(ApiRequestState.SUCCESS)
-        } catch (err: CwaWebException) {
-            _registrationError.value = Event(err)
-            _registrationState.value = Event(ApiRequestState.FAILED)
-        } catch (err: TransactionException) {
-            if (err.cause is CwaWebException) {
-                _registrationError.value = Event(err.cause)
-            } else {
-                err.report(ExceptionCategory.INTERNAL)
-            }
-            _registrationState.value = Event(ApiRequestState.FAILED)
-        } catch (err: Exception) {
-            _registrationState.value = Event(ApiRequestState.FAILED)
-            err.report(ExceptionCategory.INTERNAL)
-        }
-    }
-
-    fun validateAndStoreTestGUID(rawResult: String) {
-        val scanResult = QRScanResult(rawResult)
-        if (scanResult.isValid) {
-            SubmissionService.storeTestGUID(scanResult.guid!!)
-            _scanStatus.value = Event(ScanStatus.SUCCESS)
-        } else {
-            _scanStatus.value = Event(ScanStatus.INVALID)
-        }
-    }
-
-    fun deleteTestGUID() {
-        SubmissionService.deleteTestGUID()
-    }
-
-    fun submitWithNoDiagnosisKeys() {
-        SubmissionService.submissionSuccessful()
-    }
-
-    fun deregisterTestFromDevice() {
-        deleteTestGUID()
-        SubmissionService.deleteRegistrationToken()
-        LocalData.isAllowedToSubmitDiagnosisKeys(false)
-        LocalData.initialTestResultReceivedTimestamp(0L)
-    }
-
-    fun onPositiveSymptomIndication() {
-        symptomIndication.postValue(Symptoms.Indication.POSITIVE)
-    }
-
-    fun onNegativeSymptomIndication() {
-        symptomIndication.postValue(Symptoms.Indication.NEGATIVE)
-    }
-
-    fun onNoInformationSymptomIndication() {
-        symptomIndication.postValue(Symptoms.Indication.NO_INFORMATION)
-    }
-
-    fun onLastSevenDaysStart() {
-        symptomStart.postValue(Symptoms.StartOf.LastSevenDays)
-    }
-
-    fun onOneToTwoWeeksAgoStart() {
-        symptomStart.postValue(Symptoms.StartOf.OneToTwoWeeksAgo)
-    }
-
-    fun onMoreThanTwoWeeksStart() {
-        symptomStart.postValue(Symptoms.StartOf.MoreThanTwoWeeks)
-    }
-
-    fun onNoInformationStart() {
-        symptomStart.postValue(Symptoms.StartOf.NoInformation)
-    }
-
-    fun onDateSelected(localDate: LocalDate?) {
-        symptomStart.postValue(if (localDate == null) null else Symptoms.StartOf.Date(localDate))
-    }
+    val deviceUiState: LiveData<DeviceUIState> = SubmissionRepository.deviceUIStateFlow.asLiveData()
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt
index b203364fc37e060d4a8d6a56533f4818e0c1e06b..42dfb2f60231fc8461b4b164d8d14bad74a37160 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt
@@ -6,10 +6,11 @@ import androidx.appcompat.app.AlertDialog
 import androidx.core.content.ContextCompat.startActivity
 import de.rki.coronawarnapp.BuildConfig
 import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.appconfig.ApplicationConfigurationCorruptException
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.download.ApplicationConfigurationCorruptException
 import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig.SemanticVersion
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.ui.LauncherActivity
+import de.rki.coronawarnapp.util.di.AppInjector
 import timber.log.Timber
 
 class UpdateChecker(private val activity: LauncherActivity) {
@@ -66,10 +67,9 @@ class UpdateChecker(private val activity: LauncherActivity) {
     }
 
     private suspend fun checkIfUpdatesNeededFromServer(): Boolean {
-        val applicationConfigurationFromServer =
-            ApplicationConfigurationService.asyncRetrieveApplicationConfiguration()
+        val cwaAppConfig: CWAConfig = AppInjector.component.appConfigProvider.getAppConfig()
 
-        val minVersionFromServer = applicationConfigurationFromServer.appVersion.android.min
+        val minVersionFromServer = cwaAppConfig.appVersion.android.min
         val minVersionFromServerString =
             constructSemanticVersionString(minVersionFromServer)
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt
index b99b9f2ccd8768f361e697298f185bb96511e5dc..97839c1c3b2f9e10205070f7cfbbb5747dae35da 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt
@@ -6,21 +6,29 @@ import java.util.Locale
 
 internal object HashExtensions {
 
+    fun ByteArray.toSHA256() = this.hashByteArray("SHA-256")
+
+    fun ByteArray.toSHA1() = this.hashByteArray("SHA-1")
+
+    fun ByteArray.toMD5() = this.hashByteArray("MD5")
+
     fun String.toSHA256() = this.hashString("SHA-256")
 
     fun String.toSHA1() = this.hashString("SHA-1")
 
     fun String.toMD5() = this.hashString("MD5")
 
-    private fun ByteArray.formatHash(): String = this
-        .joinToString(separator = "") { String.format("%02X", it) }
-        .toLowerCase(Locale.ROOT)
+    private fun String.hashString(type: String): String = toByteArray().hashByteArray(type)
 
-    private fun String.hashString(type: String): String = MessageDigest
+    private fun ByteArray.hashByteArray(type: String): String = MessageDigest
         .getInstance(type)
-        .digest(this.toByteArray())
+        .digest(this)
         .formatHash()
 
+    private fun ByteArray.formatHash(): String = this
+        .joinToString(separator = "") { String.format("%02X", it) }
+        .toLowerCase(Locale.ROOT)
+
     fun File.hashToMD5(): String = this.hashTo("MD5")
 
     private fun File.hashTo(type: String): String = MessageDigest
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TanHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TanHelper.kt
deleted file mode 100644
index 5327b0f0073f1ced096a9a3ac329b6f750ec2981..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TanHelper.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package de.rki.coronawarnapp.util
-
-import de.rki.coronawarnapp.ui.submission.TanConstants.MAX_LENGTH
-import java.nio.charset.StandardCharsets
-import java.security.MessageDigest
-import java.util.Locale
-
-object TanHelper {
-    private const val VALID_CHARACTERS = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
-
-    fun isChecksumValid(tan: String): Boolean {
-        if (tan.trim().length != MAX_LENGTH)
-            return false
-        val subTan = tan.substring(0, MAX_LENGTH - 1).toUpperCase(Locale.ROOT)
-        val tanDigest = MessageDigest.getInstance("SHA-256")
-            .digest(subTan.toByteArray(StandardCharsets.US_ASCII))
-        var checkChar = "%02x".format(tanDigest[0])[0]
-        if (checkChar == '0') checkChar = 'G'
-        if (checkChar == '1') checkChar = 'H'
-
-        return checkChar.toUpperCase() == tan.last().toUpperCase()
-    }
-
-    fun allCharactersValid(tan: String): Boolean {
-        for (character in tan) {
-            if (!isTanCharacterValid(character.toString()))
-                return false
-        }
-        return true
-    }
-
-    fun isTanCharacterValid(character: String): Boolean {
-        return VALID_CHARACTERS.contains(character)
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt
index b296cced9c324d4a7499b2512b716e069a38abc0..14a58b2a1ec43f8a4f14d49c6570904e2f7465ad 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt
@@ -73,15 +73,21 @@ object ZipHelper {
         zipOutputStream.closeEntry()
     }
 
-    fun InputStream.unzip(callback: (entry: ZipEntry, entryContent: ByteArray) -> Any) =
-        ZipInputStream(this).use {
+    fun InputStream.unzip(): Sequence<Pair<ZipEntry, InputStream>> = sequence {
+        ZipInputStream(this@unzip).use {
             do {
                 val entry = it.nextEntry
                 if (entry != null) {
-                    Timber.v("read zip entry ${entry.name}")
-                    callback(entry, it.readBytes())
+                    Timber.v("Reading zip entry ${entry.name}")
+                    yield(entry to it)
                     it.closeEntry()
                 }
             } while (entry != null)
         }
+    }
+
+    fun Sequence<Pair<ZipEntry, InputStream>>.readIntoMap() =
+        fold(emptyMap()) { last: Map<String, ByteArray>, (entry, stream) ->
+            last.plus(entry.name to stream.readBytes())
+        }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt
index cbbf8366f3d47ce6b05c2917b8a471ab7e685d91..e6a64015278bb9ab2a0067a626f4b433860ca64e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt
@@ -23,6 +23,7 @@ import androidx.room.TypeConverter
 import com.google.gson.Gson
 import com.google.gson.reflect.TypeToken
 import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.util.serialization.fromJson
 import org.joda.time.Instant
 import org.joda.time.LocalDate
 import org.joda.time.LocalTime
@@ -43,6 +44,14 @@ class CommonConverters {
         return gson.toJson(list)
     }
 
+    @TypeConverter
+    fun toStringList(string: String?): List<String>? =
+        string?.let { gson.fromJson(it) }
+
+    @TypeConverter
+    fun fromStringList(strings: List<String>?): String? =
+        strings?.let { gson.toJson(it) }
+
     @TypeConverter
     fun toUUID(value: String?): UUID? = value?.let { UUID.fromString(it) }
 
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 bfb0644bf67a04882cfe9e716c514ef51b2e2f32..a866c2998e38b8cf77fd4b8e1af84e9fd5b05456 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
@@ -7,6 +7,8 @@ import dagger.android.support.AndroidSupportInjectionModule
 import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.appconfig.AppConfigModule
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.bugreporting.BugReporter
+import de.rki.coronawarnapp.bugreporting.BugReportingModule
 import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule
 import de.rki.coronawarnapp.diagnosiskeys.download.KeyFileDownloader
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
@@ -22,11 +24,11 @@ import de.rki.coronawarnapp.service.ServiceBinder
 import de.rki.coronawarnapp.storage.SettingsRepository
 import de.rki.coronawarnapp.storage.interoperability.InteroperabilityRepository
 import de.rki.coronawarnapp.submission.SubmissionModule
+import de.rki.coronawarnapp.submission.SubmissionTaskModule
+import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.task.internal.TaskModule
 import de.rki.coronawarnapp.test.DeviceForTestersModule
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisInjectionHelper
-import de.rki.coronawarnapp.transaction.RiskLevelInjectionHelper
-import de.rki.coronawarnapp.transaction.SubmitDiagnosisInjectionHelper
 import de.rki.coronawarnapp.ui.ActivityBinder
 import de.rki.coronawarnapp.util.ConnectivityHelperInjection
 import de.rki.coronawarnapp.util.UtilModule
@@ -37,6 +39,7 @@ 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.util.serialization.SerializationModule
+import de.rki.coronawarnapp.util.worker.WorkerBinder
 import de.rki.coronawarnapp.verification.VerificationModule
 import javax.inject.Singleton
 
@@ -59,19 +62,20 @@ import javax.inject.Singleton
         DiagnosisKeysModule::class,
         AppConfigModule::class,
         SubmissionModule::class,
+        SubmissionTaskModule::class,
         VerificationModule::class,
         PlaybookModule::class,
         TaskModule::class,
         DeviceForTestersModule::class,
-        SerializationModule::class
+        BugReportingModule::class,
+        SerializationModule::class,
+        WorkerBinder::class
     ]
 )
 interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
 
     // TODO Remove once Singletons are gone
     val transRetrieveKeysInjection: RetrieveDiagnosisInjectionHelper
-    val transRiskLevelInjection: RiskLevelInjectionHelper
-    val transSubmitDiagnosisInjection: SubmitDiagnosisInjectionHelper
 
     val connectivityHelperInjection: ConnectivityHelperInjection
 
@@ -91,8 +95,12 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
 
     val interoperabilityRepository: InteroperabilityRepository
 
+    val taskController: TaskController
+
     @AppScope val appScope: AppCoroutineScope
 
+    val bugReporter: BugReporter
+
     @Component.Factory
     interface Factory {
         fun create(@BindsInstance app: CoronaWarnApplication): ApplicationComponent
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
index 359c2e948ed6215083176ba02a1635aa7bbdf50c..c3d6d5787f6c991b9a8498e6cc3b3ede70afa522 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
@@ -1,7 +1,6 @@
 package de.rki.coronawarnapp.util.flow
 
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
@@ -10,7 +9,8 @@ import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.channelFlow
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.distinctUntilChanged
-import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.shareIn
@@ -21,8 +21,9 @@ import kotlin.coroutines.CoroutineContext
 class HotDataFlow<T : Any>(
     loggingTag: String,
     scope: CoroutineScope,
-    coroutineContext: CoroutineContext = Dispatchers.Default,
+    coroutineContext: CoroutineContext = scope.coroutineContext,
     sharingBehavior: SharingStarted = SharingStarted.WhileSubscribed(),
+    forwardException: Boolean = true,
     private val startValueProvider: suspend CoroutineScope.() -> T
 ) {
     private val tag = "$loggingTag:HD"
@@ -37,34 +38,71 @@ class HotDataFlow<T : Any>(
         onBufferOverflow = BufferOverflow.SUSPEND
     )
 
-    private val internalFlow = channelFlow {
+    private val internalProducer: Flow<Holder<T>> = channelFlow {
         var currentValue = startValueProvider().also {
             Timber.tag(tag).v("startValue=%s", it)
-            send(it)
+            val updatedBy: suspend T.() -> T = { it }
+            send(Holder.Data(value = it, updatedBy = updatedBy))
         }
 
         updateActions.collect { updateAction ->
             currentValue = updateAction(currentValue).also {
                 currentValue = it
-                send(it)
+                send(Holder.Data(value = it, updatedBy = updateAction))
             }
         }
     }
 
-    val data: Flow<T> = internalFlow
-        .distinctUntilChanged()
-        .onStart { Timber.tag(tag).v("internal onStart") }
+    private val internalFlow = internalProducer
+        .onStart { Timber.tag(tag).v("Internal onStart") }
         .catch {
-            Timber.tag(tag).e(it, "internal Error")
-            throw it
+            if (forwardException) {
+                Timber.tag(tag).w(it, "Forwarding internal Error")
+                // Wrap the error to get it past `sharedIn`
+                emit(Holder.Error(error = it))
+            } else {
+                Timber.tag(tag).e(it, "Throwing internal Error")
+                throw it
+            }
         }
-        .onCompletion { Timber.tag(tag).v("internal onCompletion") }
+        .onCompletion { Timber.tag(tag).v("Internal onCompletion") }
         .shareIn(
             scope = scope + coroutineContext,
             replay = 1,
             started = sharingBehavior
         )
-        .mapNotNull { it }
+        .map {
+            when (it) {
+                is Holder.Data<T> -> it
+                is Holder.Error<T> -> throw it.error
+            }
+        }
+
+    val data: Flow<T> = internalFlow.map { it.value }.distinctUntilChanged()
 
     fun updateSafely(update: suspend T.() -> T) = updateActions.tryEmit(update)
+
+    suspend fun updateBlocking(update: suspend T.() -> T): T {
+        updateActions.tryEmit(update)
+        Timber.tag(tag).v("Waiting for update.")
+        return internalFlow.first {
+            val targetUpdate = it.updatedBy
+            Timber.tag(tag).v(
+                "Comparing %s with %s; match=%b",
+                targetUpdate, update, targetUpdate == update
+            )
+            it.updatedBy == update
+        }.value.also { Timber.tag(tag).v("Returning blocking update result: %s", it) }
+    }
+
+    internal sealed class Holder<T> {
+        data class Data<T>(
+            val value: T,
+            val updatedBy: suspend T.() -> T
+        ) : Holder<T>()
+
+        data class Error<T>(
+            val error: Throwable
+        ) : Holder<T>()
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/FormatterSubmissionHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/FormatterSubmissionHelper.kt
index df3c1283f4104a8323e413dd185f55b76abe23fb..156f8efee0289c727be6c85d0aa6e4a96b116674 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/FormatterSubmissionHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/FormatterSubmissionHelper.kt
@@ -55,12 +55,6 @@ fun formatTestResultSpinnerVisible(uiStateState: ApiRequestState?): Int =
 fun formatTestResultVisible(uiStateState: ApiRequestState?): Int =
     formatVisibility(uiStateState == ApiRequestState.SUCCESS)
 
-fun formatSubmitButtonEnabled(apiRequestState: ApiRequestState) =
-    apiRequestState == ApiRequestState.IDLE || apiRequestState == ApiRequestState.FAILED
-
-fun formatSubmitSpinnerVisible(apiRequestState: ApiRequestState) =
-    formatVisibility(apiRequestState == ApiRequestState.STARTED)
-
 fun formatTestResultStatusText(uiState: DeviceUIState?): String {
     val appContext = CoronaWarnApplication.getAppContext()
     return when (uiState) {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt
index e6df66e5b7996381dda133b616e34eb87f5b13c5..1ba8319fe9aae755a9d33947fc8744baca812e7a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt
@@ -25,8 +25,8 @@ class VerificationKeys @Inject constructor(
         Signature.getInstance(SecurityConstants.EXPORT_FILE_SIGNATURE_VERIFICATION_ALGORITHM)
 
     fun hasInvalidSignature(
-        export: ByteArray?,
-        signatureListBinary: ByteArray?
+        export: ByteArray,
+        signatureListBinary: ByteArray
     ): Boolean = SecurityHelper.withSecurityCatch {
         signature.getValidSignaturesForExport(export, signatureListBinary)
             .isEmpty()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt
similarity index 67%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt
index b79e725c5d935b80971ac86bf3ff39505eb5267a..601f833c3ded2777122e62d6814e07edff3380a3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt
@@ -1,8 +1,10 @@
-package de.rki.coronawarnapp.util.gson
+package de.rki.coronawarnapp.util.serialization
 
 import com.google.gson.Gson
+import com.google.gson.TypeAdapter
 import com.google.gson.reflect.TypeToken
 import java.io.File
+import kotlin.reflect.KClass
 
 inline fun <reified T> Gson.fromJson(json: String): T = fromJson(
     json,
@@ -16,3 +18,5 @@ inline fun <reified T> Gson.fromJson(file: File): T = file.reader().use {
 inline fun <reified T> Gson.toJson(data: T, file: File) = file.writer().use { writer ->
     toJson(data, writer)
 }
+
+fun <T : Any> KClass<T>.getDefaultGsonTypeAdapter(): TypeAdapter<T> = Gson().getAdapter(this.java)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
index e638c3811aad327869e5c7b6ba50deb0fefe409d..c2c33f43b43745b32465362577c0a0bffae874b9 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
@@ -5,6 +5,11 @@ import com.google.gson.GsonBuilder
 import dagger.Module
 import dagger.Provides
 import dagger.Reusable
+import de.rki.coronawarnapp.util.serialization.adapter.ByteArrayAdapter
+import de.rki.coronawarnapp.util.serialization.adapter.DurationAdapter
+import de.rki.coronawarnapp.util.serialization.adapter.InstantAdapter
+import org.joda.time.Duration
+import org.joda.time.Instant
 
 @Module
 class SerializationModule {
@@ -12,5 +17,9 @@ class SerializationModule {
     @BaseGson
     @Reusable
     @Provides
-    fun baseGson(): Gson = GsonBuilder().create()
+    fun baseGson(): Gson = GsonBuilder()
+        .registerTypeAdapter(Instant::class.java, InstantAdapter())
+        .registerTypeAdapter(Duration::class.java, DurationAdapter())
+        .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter())
+        .create()
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..10388e0c471d1324b63d868325e2e4c8a3d3f0c9
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt
@@ -0,0 +1,24 @@
+package de.rki.coronawarnapp.util.serialization.adapter
+
+import com.google.gson.JsonParseException
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonWriter
+import okio.ByteString.Companion.decodeBase64
+import okio.ByteString.Companion.toByteString
+import org.json.JSONObject.NULL
+
+class ByteArrayAdapter : TypeAdapter<ByteArray>() {
+    override fun write(out: JsonWriter, value: ByteArray?) {
+        if (value == null) out.nullValue()
+        else value.toByteString().base64().let { out.value(it) }
+    }
+
+    override fun read(reader: JsonReader): ByteArray? = when (reader.peek()) {
+        NULL -> reader.nextNull().let { null }
+        else -> {
+            val raw = reader.nextString()
+            raw.decodeBase64()?.toByteArray() ?: throw JsonParseException("Can't decode base64 ByteArray: $raw")
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0d194661ea3c1cbc85717aca1fc25ddb7d003a96
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt
@@ -0,0 +1,27 @@
+package de.rki.coronawarnapp.util.serialization.adapter
+
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonWriter
+import org.joda.time.Duration
+import org.json.JSONObject
+
+class DurationAdapter : TypeAdapter<Duration>() {
+    override fun write(out: JsonWriter, value: Duration?) {
+        if (value == null) {
+            out.nullValue()
+        } else {
+            out.value(value.millis)
+        }
+    }
+
+    override fun read(reader: JsonReader): Duration? = when (reader.peek()) {
+        JSONObject.NULL -> {
+            reader.nextNull()
+            null
+        }
+        else -> {
+            Duration.millis(reader.nextLong())
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..99ba6e6e18285126ddf9bf69c43ab57a8ae641c7
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt
@@ -0,0 +1,27 @@
+package de.rki.coronawarnapp.util.serialization.adapter
+
+import com.google.gson.TypeAdapter
+import com.google.gson.stream.JsonReader
+import com.google.gson.stream.JsonWriter
+import org.joda.time.Instant
+import org.json.JSONObject
+
+class InstantAdapter : TypeAdapter<Instant>() {
+    override fun write(out: JsonWriter, value: Instant?) {
+        if (value == null) {
+            out.nullValue()
+        } else {
+            out.value(value.millis)
+        }
+    }
+
+    override fun read(reader: JsonReader): Instant? = when (reader.peek()) {
+        JSONObject.NULL -> {
+            reader.nextNull()
+            null
+        }
+        else -> {
+            Instant.ofEpochMilli(reader.nextLong())
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9b491808f2bbdb37d9dada12a88968d1b66f1903
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactory.kt
@@ -0,0 +1,37 @@
+package de.rki.coronawarnapp.util.worker
+
+import android.content.Context
+import androidx.work.ListenableWorker
+import androidx.work.WorkerFactory
+import androidx.work.WorkerParameters
+import dagger.Reusable
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Provider
+
+@Reusable
+class CWAWorkerFactory @Inject constructor(
+    private val factories: @JvmSuppressWildcards Map<
+        Class<out ListenableWorker>, Provider<InjectedWorkerFactory<out ListenableWorker>>
+        >
+) : WorkerFactory() {
+
+    init {
+        Timber.v("CWAWorkerFactory ready. Known factories: %s", factories)
+    }
+
+    override fun createWorker(
+        appContext: Context,
+        workerClassName: String,
+        workerParameters: WorkerParameters
+    ): ListenableWorker? {
+        Timber.v("Looking up worker for %s", workerClassName)
+        val factory = factories.entries.find {
+            Class.forName(workerClassName).isAssignableFrom(it.key)
+        }?.value
+
+        requireNotNull(factory) { "Unknown worker: $workerClassName" }
+        Timber.v("Creating worker for %s with %s", workerClassName, workerParameters)
+        return factory.get().create(appContext, workerParameters)
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/InjectedWorkerFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/InjectedWorkerFactory.kt
new file mode 100644
index 0000000000000000000000000000000000000000..1986ef02b4e884754f24452004bdcf0b026f1215
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/InjectedWorkerFactory.kt
@@ -0,0 +1,9 @@
+package de.rki.coronawarnapp.util.worker
+
+import android.content.Context
+import androidx.work.ListenableWorker
+import androidx.work.WorkerParameters
+
+interface InjectedWorkerFactory<T : ListenableWorker> {
+    fun create(context: Context, workerParams: WorkerParameters): T
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3e7c0ee22be629676acafc785136f3a64af69f17
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt
@@ -0,0 +1,28 @@
+package de.rki.coronawarnapp.util.worker
+
+import android.content.Context
+import androidx.work.Configuration
+import androidx.work.WorkManager
+import de.rki.coronawarnapp.util.di.AppContext
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class WorkManagerSetup @Inject constructor(
+    @AppContext private val context: Context,
+    private val cwaWorkerFactory: CWAWorkerFactory
+) {
+
+    fun setup() {
+        Timber.v("Setting up WorkManager.")
+        val configuration = Configuration.Builder().apply {
+            setMinimumLoggingLevel(android.util.Log.DEBUG)
+            setWorkerFactory(cwaWorkerFactory)
+        }.build()
+
+        WorkManager.initialize(context, configuration)
+
+        Timber.v("WorkManager setup done.")
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7bae929f22ca532f2efcb9103cd5407e4a5e6438
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.util.worker
+
+import androidx.work.ListenableWorker
+import dagger.Binds
+import dagger.Module
+import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker
+import de.rki.coronawarnapp.worker.BackgroundNoiseOneTimeWorker
+import de.rki.coronawarnapp.worker.BackgroundNoisePeriodicWorker
+import de.rki.coronawarnapp.worker.DiagnosisKeyRetrievalOneTimeWorker
+import de.rki.coronawarnapp.worker.DiagnosisKeyRetrievalPeriodicWorker
+import de.rki.coronawarnapp.worker.DiagnosisTestResultRetrievalPeriodicWorker
+
+@Module
+abstract class WorkerBinder {
+
+    @Binds
+    @IntoMap
+    @WorkerKey(ExposureStateUpdateWorker::class)
+    abstract fun bindExposureStateUpdate(
+        factory: ExposureStateUpdateWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
+
+    @Binds
+    @IntoMap
+    @WorkerKey(BackgroundNoiseOneTimeWorker::class)
+    abstract fun backgroundNoiseOneTime(
+        factory: BackgroundNoiseOneTimeWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
+
+    @Binds
+    @IntoMap
+    @WorkerKey(BackgroundNoisePeriodicWorker::class)
+    abstract fun backgroundNoisePeriodic(
+        factory: BackgroundNoisePeriodicWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
+
+    @Binds
+    @IntoMap
+    @WorkerKey(DiagnosisKeyRetrievalOneTimeWorker::class)
+    abstract fun diagnosisKeyRetrievalOneTime(
+        factory: DiagnosisKeyRetrievalOneTimeWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
+
+    @Binds
+    @IntoMap
+    @WorkerKey(DiagnosisKeyRetrievalPeriodicWorker::class)
+    abstract fun diagnosisKeyRetrievalPeriodic(
+        factory: DiagnosisKeyRetrievalPeriodicWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
+
+    @Binds
+    @IntoMap
+    @WorkerKey(DiagnosisTestResultRetrievalPeriodicWorker::class)
+    abstract fun testResultRetrievalPeriodic(
+        factory: DiagnosisTestResultRetrievalPeriodicWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerKey.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerKey.kt
new file mode 100644
index 0000000000000000000000000000000000000000..27d263540659012a499772ea7cfeeca2c33bb736
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerKey.kt
@@ -0,0 +1,10 @@
+package de.rki.coronawarnapp.util.worker
+
+import androidx.work.ListenableWorker
+import dagger.MapKey
+import kotlin.reflect.KClass
+
+@Target(AnnotationTarget.FUNCTION)
+@Retention(AnnotationRetention.RUNTIME)
+@MapKey
+internal annotation class WorkerKey(val value: KClass<out ListenableWorker>)
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 724ac760cf52bd1843702506d759ca4f1616620d..4dfc187b4cb173eba23e66924001d1925a686999 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,22 +3,21 @@ package de.rki.coronawarnapp.worker
 import android.content.Context
 import androidx.work.CoroutineWorker
 import androidx.work.WorkerParameters
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.playbook.Playbook
-import de.rki.coronawarnapp.util.di.AppInjector
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
 
 /**
  * One time background noise worker
  *
  * @see BackgroundWorkScheduler
  */
-class BackgroundNoiseOneTimeWorker(
-    val context: Context,
-    workerParams: WorkerParameters
-) :
-    CoroutineWorker(context, workerParams) {
-
+class BackgroundNoiseOneTimeWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters,
     private val playbook: Playbook
-        get() = AppInjector.component.playbook
+) : CoroutineWorker(context, workerParams) {
 
     /**
      * Work execution
@@ -41,4 +40,7 @@ class BackgroundNoiseOneTimeWorker(
 
         return result
     }
+
+    @AssistedInject.Factory
+    interface Factory : InjectedWorkerFactory<BackgroundNoiseOneTimeWorker>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
index 3b9a93eb9fd246d0fa4734e4100ac78fa9140a37..3869efb0a2ff59d518a538c814ff821cb8b6e30f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
@@ -3,7 +3,10 @@ package de.rki.coronawarnapp.worker
 import android.content.Context
 import androidx.work.CoroutineWorker
 import androidx.work.WorkerParameters
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
 import de.rki.coronawarnapp.worker.BackgroundWorkScheduler.stop
 import org.joda.time.DateTime
 import org.joda.time.DateTimeZone
@@ -14,11 +17,10 @@ import timber.log.Timber
  *
  * @see BackgroundWorkScheduler
  */
-class BackgroundNoisePeriodicWorker(
-    val context: Context,
-    workerParams: WorkerParameters
-) :
-    CoroutineWorker(context, workerParams) {
+class BackgroundNoisePeriodicWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters
+) : CoroutineWorker(context, workerParams) {
 
     companion object {
         private val TAG: String? = BackgroundNoisePeriodicWorker::class.simpleName
@@ -61,4 +63,7 @@ class BackgroundNoisePeriodicWorker(
     private fun stopWorker() {
         BackgroundWorkScheduler.WorkType.BACKGROUND_NOISE_PERIODIC_WORK.stop()
     }
+
+    @AssistedInject.Factory
+    interface Factory : InjectedWorkerFactory<BackgroundNoisePeriodicWorker>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt
index 95cf66a25bc91be467412f2b161929af7e1d7594..275708013c4ed13dd391f2e78babab668dece30f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt
@@ -3,7 +3,10 @@ package de.rki.coronawarnapp.worker
 import android.content.Context
 import androidx.work.CoroutineWorker
 import androidx.work.WorkerParameters
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
 import timber.log.Timber
 
 /**
@@ -12,8 +15,10 @@ import timber.log.Timber
  *
  * @see BackgroundWorkScheduler
  */
-class DiagnosisKeyRetrievalOneTimeWorker(val context: Context, workerParams: WorkerParameters) :
-    CoroutineWorker(context, workerParams) {
+class DiagnosisKeyRetrievalOneTimeWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters
+) : CoroutineWorker(context, workerParams) {
 
     /**
      * Work execution
@@ -59,4 +64,7 @@ class DiagnosisKeyRetrievalOneTimeWorker(val context: Context, workerParams: Wor
         Timber.d("$id: doWork() finished with %s", result)
         return result
     }
+
+    @AssistedInject.Factory
+    interface Factory : InjectedWorkerFactory<DiagnosisKeyRetrievalOneTimeWorker>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
index f7baa0f0855e09a3fef92a67d03b125b25452a42..f0dc4742ce5993b5425f8feabb4d67173df09538 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
@@ -3,6 +3,9 @@ package de.rki.coronawarnapp.worker
 import android.content.Context
 import androidx.work.CoroutineWorker
 import androidx.work.WorkerParameters
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
 import timber.log.Timber
 
 /**
@@ -12,8 +15,10 @@ import timber.log.Timber
  * @see BackgroundWorkScheduler
  * @see DiagnosisKeyRetrievalOneTimeWorker
  */
-class DiagnosisKeyRetrievalPeriodicWorker(val context: Context, workerParams: WorkerParameters) :
-    CoroutineWorker(context, workerParams) {
+class DiagnosisKeyRetrievalPeriodicWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters
+) : CoroutineWorker(context, workerParams) {
 
     /**
      * Work execution
@@ -60,4 +65,7 @@ class DiagnosisKeyRetrievalPeriodicWorker(val context: Context, workerParams: Wo
         Timber.d("$id: doWork() finished with %s", result)
         return result
     }
+
+    @AssistedInject.Factory
+    interface Factory : InjectedWorkerFactory<DiagnosisKeyRetrievalPeriodicWorker>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
index 03543b63c27736c5bc9ca49fae917eeeec775290..e899972775e9bcaf7a645bdd3e2d6013f0985b9a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
@@ -4,6 +4,8 @@ import android.content.Context
 import androidx.core.app.NotificationCompat
 import androidx.work.CoroutineWorker
 import androidx.work.WorkerParameters
+import com.squareup.inject.assisted.Assisted
+import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.notification.NotificationHelper
@@ -11,6 +13,7 @@ import de.rki.coronawarnapp.service.submission.SubmissionService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.util.TimeAndDateExtensions
 import de.rki.coronawarnapp.util.formatter.TestResult
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
 import de.rki.coronawarnapp.worker.BackgroundWorkScheduler.stop
 import timber.log.Timber
 
@@ -19,11 +22,10 @@ import timber.log.Timber
  *
  * @see BackgroundWorkScheduler
  */
-class DiagnosisTestResultRetrievalPeriodicWorker(
-    val context: Context,
-    workerParams: WorkerParameters
-) :
-    CoroutineWorker(context, workerParams) {
+class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters
+) : CoroutineWorker(context, workerParams) {
 
     companion object {
         private val TAG: String? = DiagnosisTestResultRetrievalPeriodicWorker::class.simpleName
@@ -44,13 +46,15 @@ class DiagnosisTestResultRetrievalPeriodicWorker(
 
         Timber.d("Background job started. Run attempt: $runAttemptCount")
         BackgroundWorkHelper.sendDebugNotification(
-            "TestResult Executing: Start", "TestResult started. Run attempt: $runAttemptCount ")
+            "TestResult Executing: Start", "TestResult started. Run attempt: $runAttemptCount "
+        )
 
         if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
             Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling")
 
             BackgroundWorkHelper.sendDebugNotification(
-                "TestResult Executing: Failure", "TestResult failed with $runAttemptCount attempts")
+                "TestResult Executing: Failure", "TestResult failed with $runAttemptCount attempts"
+            )
 
             BackgroundWorkScheduler.scheduleDiagnosisKeyPeriodicWork()
             return Result.failure()
@@ -72,7 +76,8 @@ class DiagnosisTestResultRetrievalPeriodicWorker(
         }
 
         BackgroundWorkHelper.sendDebugNotification(
-            "TestResult Executing: End", "TestResult result: $result ")
+            "TestResult Executing: End", "TestResult result: $result "
+        )
 
         return result
     }
@@ -121,6 +126,10 @@ class DiagnosisTestResultRetrievalPeriodicWorker(
         BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop()
 
         BackgroundWorkHelper.sendDebugNotification(
-            "TestResult Stopped", "TestResult Stopped")
+            "TestResult Stopped", "TestResult Stopped"
+        )
     }
+
+    @AssistedInject.Factory
+    interface Factory : InjectedWorkerFactory<DiagnosisTestResultRetrievalPeriodicWorker>
 }
diff --git a/Corona-Warn-App/src/main/res/layout/fragment_submission_positive_other_warning.xml b/Corona-Warn-App/src/main/res/layout/fragment_submission_positive_other_warning.xml
index 555709a0ab052805a88e12fc87c3421347548227..fcf4c4a8300f153e0b8e3adc5bf5ee8544990ede 100644
--- a/Corona-Warn-App/src/main/res/layout/fragment_submission_positive_other_warning.xml
+++ b/Corona-Warn-App/src/main/res/layout/fragment_submission_positive_other_warning.xml
@@ -4,13 +4,9 @@
     xmlns:tools="http://schemas.android.com/tools">
 
     <data>
-
-        <import type="de.rki.coronawarnapp.util.formatter.FormatterSubmissionHelper" />
-
         <variable
-            name="submissionViewModel"
-            type="de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel" />
-
+            name="uiState"
+            type="de.rki.coronawarnapp.ui.submission.warnothers.WarnOthersState" />
     </data>
 
 
@@ -20,7 +16,7 @@
         android:layout_height="match_parent"
         android:contentDescription="@string/submission_positive_other_warning_title"
         android:fillViewport="true"
-        tools:context=".ui.submission.fragment.SubmissionResultPositiveOtherWarningFragment">
+        tools:context=".ui.submission.warnothers.SubmissionResultPositiveOtherWarningFragment">
 
         <include
             android:id="@+id/submission_positive_other_warning_header"
@@ -37,7 +33,7 @@
             layout="@layout/include_submission_positive_other_warning"
             android:layout_width="@dimen/match_constraint"
             android:layout_height="@dimen/match_constraint"
-            app:countryData="@{submissionViewModel.countryList}"
+            app:countryData="@{uiState.countryList}"
             app:layout_constraintBottom_toTopOf="@+id/guideline_action"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintHorizontal_bias="0.0"
@@ -50,7 +46,7 @@
             style="@style/buttonPrimary"
             android:layout_width="@dimen/match_constraint"
             android:layout_height="wrap_content"
-            android:enabled="@{FormatterSubmissionHelper.formatSubmitButtonEnabled(submissionViewModel.submissionState)}"
+            android:enabled="@{uiState != null &amp;&amp; uiState.isSubmitButtonEnabled()}"
             android:text="@string/submission_positive_other_warning_button"
             android:textAllCaps="true"
             app:layout_constraintBottom_toBottomOf="parent"
@@ -65,7 +61,7 @@
             android:layout_height="wrap_content"
             android:layout_marginTop="@dimen/spacing_large"
             android:indeterminate="true"
-            android:visibility="@{FormatterSubmissionHelper.formatSubmitSpinnerVisible(submissionViewModel.submissionState)}"
+            gone="@{uiState == null || !uiState.isSubmitSpinnerVisible()}"
             app:layout_constraintBottom_toBottomOf="@+id/submission_positive_other_warning_button_next"
             app:layout_constraintEnd_toEndOf="@+id/submission_positive_other_warning_button_next"
             app:layout_constraintStart_toStartOf="@+id/submission_positive_other_warning_button_next"
diff --git a/Corona-Warn-App/src/main/res/layout/fragment_submission_symptom_calendar.xml b/Corona-Warn-App/src/main/res/layout/fragment_submission_symptom_calendar.xml
index eb19ee84f777439eb95616fd29cd758dfc369bc5..a34166dd59df62849ed4df4572518c4ef5ea3e24 100644
--- a/Corona-Warn-App/src/main/res/layout/fragment_submission_symptom_calendar.xml
+++ b/Corona-Warn-App/src/main/res/layout/fragment_submission_symptom_calendar.xml
@@ -24,7 +24,7 @@
             android:layout_height="wrap_content"
             android:fillViewport="true"
             android:focusable="true"
-            tools:context=".ui.submission.fragment.SubmissionSymptomCalendarFragment">
+            tools:context=".ui.submission.symptoms.calendar.SubmissionSymptomCalendarFragment">
 
             <include
                 android:id="@+id/submission_symptom_calendar_header"
diff --git a/Corona-Warn-App/src/main/res/layout/fragment_submission_tan.xml b/Corona-Warn-App/src/main/res/layout/fragment_submission_tan.xml
index 3ef4b9f79ef0c89881003cd9e7f1ec963a4416c4..e499b7cef4937c65ecc526611b20d3eddeef6d98 100644
--- a/Corona-Warn-App/src/main/res/layout/fragment_submission_tan.xml
+++ b/Corona-Warn-App/src/main/res/layout/fragment_submission_tan.xml
@@ -3,11 +3,11 @@
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools">
 
-    <data>
 
+    <data>
         <variable
-            name="viewmodel"
-            type="de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionTanViewModel" />
+            name="uiState"
+            type="de.rki.coronawarnapp.ui.submission.tan.SubmissionTanViewModel.UIState" />
     </data>
 
     <androidx.constraintlayout.widget.ConstraintLayout
@@ -16,7 +16,7 @@
         android:layout_height="match_parent"
         android:contentDescription="@string/submission_tan_accessibility_title"
         android:fillViewport="true"
-        tools:context=".ui.submission.fragment.SubmissionTanFragment">
+        tools:context=".ui.submission.tan.SubmissionTanFragment">
 
         <include
             android:id="@+id/submission_tan_header"
@@ -38,8 +38,7 @@
             app:layout_constraintBottom_toTopOf="@id/guideline_action"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@+id/submission_tan_header"
-            app:viewmodel="@{viewmodel}" />
+            app:layout_constraintTop_toBottomOf="@+id/submission_tan_header" />
 
         <ProgressBar
             android:id="@+id/submission_tan_spinner"
@@ -57,7 +56,7 @@
             style="@style/buttonPrimary"
             android:layout_width="@dimen/match_constraint"
             android:layout_height="wrap_content"
-            android:enabled="@{viewmodel.isValidTanFormat}"
+            android:enabled="@{uiState.tanValid}"
             android:text="@string/submission_tan_button_text"
             android:textAllCaps="true"
             app:layout_constraintBottom_toBottomOf="parent"
diff --git a/Corona-Warn-App/src/main/res/layout/fragment_submission_test_result.xml b/Corona-Warn-App/src/main/res/layout/fragment_submission_test_result.xml
index 2f7825389e55c461017cc670b808d92fd318c09f..3521faa0f64126875ffb9317c6782159fa998fff 100644
--- a/Corona-Warn-App/src/main/res/layout/fragment_submission_test_result.xml
+++ b/Corona-Warn-App/src/main/res/layout/fragment_submission_test_result.xml
@@ -7,8 +7,8 @@
         <import type="de.rki.coronawarnapp.util.formatter.FormatterSubmissionHelper" />
 
         <variable
-            name="submissionViewModel"
-            type="de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel" />
+            name="uiState"
+            type="de.rki.coronawarnapp.ui.submission.testresult.TestResultUIState" />
     </data>
 
     <androidx.constraintlayout.widget.ConstraintLayout
@@ -34,7 +34,7 @@
             style="?android:attr/progressBarStyle"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-            android:visibility="@{FormatterSubmissionHelper.formatTestResultSpinnerVisible(submissionViewModel.uiStateState)}"
+            android:visibility="@{FormatterSubmissionHelper.formatTestResultSpinnerVisible(uiState.apiRequestState)}"
             app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
@@ -48,18 +48,17 @@
             android:layout_width="@dimen/match_constraint"
             android:layout_height="@dimen/match_constraint"
             android:layout_marginBottom="@dimen/button_padding_top_bottom"
-            android:visibility="@{FormatterSubmissionHelper.formatTestResultVisible(submissionViewModel.uiStateState)}"
+            android:visibility="@{FormatterSubmissionHelper.formatTestResultVisible(uiState.apiRequestState)}"
             app:layout_constraintBottom_toTopOf="@+id/include_submission_test_result_buttons"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
             app:layout_constraintTop_toBottomOf="@+id/submission_test_result_header"
-            app:submissionViewModel="@{submissionViewModel}" />
+            app:uiState="@{uiState}" />
 
         <androidx.constraintlayout.widget.Barrier
             android:id="@+id/include_submission_test_result_buttons"
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
-
             app:barrierAllowsGoneWidgets="false"
             app:barrierDirection="top"
             app:constraint_referenced_ids="submission_test_result_button_pending_refresh,submission_test_result_button_invalid_remove_test,submission_test_result_button_positive_continue,submission_test_result_button_negative_remove_test" />
@@ -70,7 +69,7 @@
             android:layout_width="@dimen/match_constraint"
             android:layout_height="wrap_content"
             android:text="@string/submission_test_result_pending_refresh_button"
-            android:visibility="@{FormatterSubmissionHelper.formatTestResultPendingStepsVisible(submissionViewModel.deviceUiState)}"
+            android:visibility="@{FormatterSubmissionHelper.formatTestResultPendingStepsVisible(uiState.deviceUiState)}"
             app:layout_constraintBottom_toTopOf="@+id/submission_test_result_button_pending_remove_test"
             app:layout_constraintEnd_toStartOf="@+id/guideline_end"
             app:layout_constraintStart_toStartOf="@id/guideline_start"
@@ -82,7 +81,7 @@
             android:layout_width="@dimen/match_constraint"
             android:layout_height="wrap_content"
             android:text="@string/submission_test_result_pending_remove_test_button"
-            android:visibility="@{FormatterSubmissionHelper.formatTestResultPendingStepsVisible(submissionViewModel.deviceUiState)}"
+            android:visibility="@{FormatterSubmissionHelper.formatTestResultPendingStepsVisible(uiState.deviceUiState)}"
             app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintEnd_toStartOf="@+id/guideline_end"
             app:layout_constraintStart_toStartOf="@id/guideline_start"
@@ -94,7 +93,7 @@
             android:layout_width="@dimen/match_constraint"
             android:layout_height="wrap_content"
             android:text="@string/submission_test_result_invalid_remove_test_button"
-            android:visibility="@{FormatterSubmissionHelper.formatTestResultInvalidStepsVisible(submissionViewModel.deviceUiState)}"
+            android:visibility="@{FormatterSubmissionHelper.formatTestResultInvalidStepsVisible(uiState.deviceUiState)}"
             app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintEnd_toStartOf="@+id/guideline_end"
             app:layout_constraintStart_toStartOf="@id/guideline_start"
@@ -106,7 +105,7 @@
             android:layout_width="@dimen/match_constraint"
             android:layout_height="wrap_content"
             android:text="@string/submission_test_result_positive_continue_button_with_symptoms"
-            android:visibility="@{FormatterSubmissionHelper.formatTestResultPositiveStepsVisible(submissionViewModel.deviceUiState)}"
+            android:visibility="@{FormatterSubmissionHelper.formatTestResultPositiveStepsVisible(uiState.deviceUiState)}"
             app:layout_constraintBottom_toTopOf="@+id/submission_test_result_button_positive_continue_without_symptoms"
             app:layout_constraintEnd_toStartOf="@+id/guideline_end"
             app:layout_constraintStart_toStartOf="@id/guideline_start"
@@ -118,7 +117,7 @@
             android:layout_width="@dimen/match_constraint"
             android:layout_height="wrap_content"
             android:text="@string/submission_test_result_positive_continue_button_wo_symptoms"
-            android:visibility="@{FormatterSubmissionHelper.formatTestResultPositiveStepsVisible(submissionViewModel.deviceUiState)}"
+            android:visibility="@{FormatterSubmissionHelper.formatTestResultPositiveStepsVisible(uiState.deviceUiState)}"
             app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintEnd_toStartOf="@+id/guideline_end"
             app:layout_constraintStart_toStartOf="@id/guideline_start"
@@ -130,7 +129,7 @@
             android:layout_width="@dimen/match_constraint"
             android:layout_height="wrap_content"
             android:text="@string/submission_test_result_negative_remove_test_button"
-            android:visibility="@{FormatterSubmissionHelper.formatTestResultNegativeStepsVisible(submissionViewModel.deviceUiState)}"
+            android:visibility="@{FormatterSubmissionHelper.formatTestResultNegativeStepsVisible(uiState.deviceUiState)}"
             app:layout_constraintBottom_toBottomOf="parent"
             app:layout_constraintEnd_toStartOf="@+id/guideline_end"
             app:layout_constraintStart_toStartOf="@id/guideline_start"
diff --git a/Corona-Warn-App/src/main/res/layout/include_submission_tan.xml b/Corona-Warn-App/src/main/res/layout/include_submission_tan.xml
index 795e2414432a623b48ec39c9887c2a07db4e690d..5d31152d6d36f837475f713187a17be0acc85746 100644
--- a/Corona-Warn-App/src/main/res/layout/include_submission_tan.xml
+++ b/Corona-Warn-App/src/main/res/layout/include_submission_tan.xml
@@ -2,18 +2,6 @@
 <layout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools">
-
-    <data>
-
-        <import type="de.rki.coronawarnapp.util.formatter.FormatterHelper" />
-
-        <import type="de.rki.coronawarnapp.util.formatter.FormatterSubmissionHelper" />
-
-        <variable
-            name="viewmodel"
-            type="de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionTanViewModel" />
-    </data>
-
     <ScrollView
         android:layout_width="match_parent"
         android:layout_height="match_parent">
@@ -35,7 +23,7 @@
                 app:layout_constraintStart_toStartOf="@+id/guideline_start"
                 app:layout_constraintTop_toTopOf="parent" />
 
-            <de.rki.coronawarnapp.ui.view.TanInput
+            <de.rki.coronawarnapp.ui.submission.tan.TanInput
                 android:id="@+id/submission_tan_input"
                 android:layout_width="@dimen/match_constraint"
                 android:layout_height="wrap_content"
diff --git a/Corona-Warn-App/src/main/res/layout/include_submission_test_result.xml b/Corona-Warn-App/src/main/res/layout/include_submission_test_result.xml
index b6b15d563c317d0d90058b10c1f10e64fd260c5b..a564e5c9632c893c096db4b4829bc72603ab5550 100644
--- a/Corona-Warn-App/src/main/res/layout/include_submission_test_result.xml
+++ b/Corona-Warn-App/src/main/res/layout/include_submission_test_result.xml
@@ -7,8 +7,8 @@
         <import type="de.rki.coronawarnapp.util.formatter.FormatterSubmissionHelper" />
 
         <variable
-            name="submissionViewModel"
-            type="de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel" />
+            name="uiState"
+            type="de.rki.coronawarnapp.ui.submission.testresult.TestResultUIState" />
     </data>
 
 
@@ -28,11 +28,11 @@
                 android:layout_marginTop="@dimen/spacing_small"
                 android:focusable="true"
                 android:importantForAccessibility="yes"
-                app:deviceUIState="@{submissionViewModel.deviceUiState}"
+                app:deviceUIState="@{uiState.deviceUiState}"
                 app:layout_constraintEnd_toEndOf="@+id/guideline_card_end"
                 app:layout_constraintStart_toStartOf="@+id/guideline_card_start"
                 app:layout_constraintTop_toTopOf="parent"
-                app:registerDate="@{submissionViewModel.testResultReceivedDate}" />
+                app:registerDate="@{uiState.testResultReceivedDate}" />
 
             <TextView
                 android:id="@+id/submission_test_result_subtitle"
@@ -52,7 +52,7 @@
                 android:layout_width="@dimen/match_constraint"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:visibility="@{FormatterSubmissionHelper.formatTestResultPendingStepsVisible(submissionViewModel.deviceUiState)}"
+                android:visibility="@{FormatterSubmissionHelper.formatTestResultPendingStepsVisible(uiState.deviceUiState)}"
                 app:layout_constraintEnd_toEndOf="@+id/guideline_end"
                 app:layout_constraintStart_toStartOf="@+id/guideline_start"
                 app:layout_constraintTop_toBottomOf="@+id/submission_test_result_subtitle" />
@@ -63,7 +63,7 @@
                 android:layout_width="@dimen/match_constraint"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:visibility="@{FormatterSubmissionHelper.formatTestResultNegativeStepsVisible(submissionViewModel.deviceUiState)}"
+                android:visibility="@{FormatterSubmissionHelper.formatTestResultNegativeStepsVisible(uiState.deviceUiState)}"
                 app:layout_constraintEnd_toEndOf="@+id/guideline_end"
                 app:layout_constraintStart_toStartOf="@+id/guideline_start"
                 app:layout_constraintTop_toBottomOf="@+id/submission_test_result_subtitle" />
@@ -74,7 +74,7 @@
                 android:layout_width="@dimen/match_constraint"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:visibility="@{FormatterSubmissionHelper.formatTestResultNegativeStepsVisible(submissionViewModel.deviceUiState)}"
+                android:visibility="@{FormatterSubmissionHelper.formatTestResultNegativeStepsVisible(uiState.deviceUiState)}"
                 app:layout_constraintEnd_toEndOf="parent"
                 app:layout_constraintStart_toStartOf="parent"
                 app:layout_constraintTop_toBottomOf="@+id/submission_test_result_negative_steps" />
@@ -85,7 +85,7 @@
                 android:layout_width="@dimen/match_constraint"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:visibility="@{FormatterSubmissionHelper.formatTestResultPositiveStepsVisible(submissionViewModel.deviceUiState)}"
+                android:visibility="@{FormatterSubmissionHelper.formatTestResultPositiveStepsVisible(uiState.deviceUiState)}"
                 app:layout_constraintEnd_toEndOf="@+id/guideline_end"
                 app:layout_constraintStart_toStartOf="@+id/guideline_start"
                 app:layout_constraintTop_toBottomOf="@+id/submission_test_result_subtitle" />
@@ -96,7 +96,7 @@
                 android:layout_width="@dimen/match_constraint"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:visibility="@{FormatterSubmissionHelper.formatTestResultInvalidStepsVisible(submissionViewModel.deviceUiState)}"
+                android:visibility="@{FormatterSubmissionHelper.formatTestResultInvalidStepsVisible(uiState.deviceUiState)}"
                 app:layout_constraintEnd_toEndOf="@+id/guideline_end"
                 app:layout_constraintStart_toStartOf="@+id/guideline_start"
                 app:layout_constraintTop_toBottomOf="@+id/submission_test_result_subtitle" />
diff --git a/Corona-Warn-App/src/main/res/menu/menu_main.xml b/Corona-Warn-App/src/main/res/menu/menu_main.xml
index 811d32f472235221f4f0022d1c690fe4883b126b..8fdae95ebad8a9f6d7f61889fa6b69008dab6321 100644
--- a/Corona-Warn-App/src/main/res/menu/menu_main.xml
+++ b/Corona-Warn-App/src/main/res/menu/menu_main.xml
@@ -1,5 +1,5 @@
-<menu xmlns:tools="http://schemas.android.com/tools"
-    xmlns:android="http://schemas.android.com/apk/res/android">
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
     <item
         android:id="@+id/menu_help"
         android:title="@string/menu_help" />
@@ -11,7 +11,7 @@
         android:title="@string/menu_settings" />
     <item
         android:id="@+id/menu_test"
-        android:visible="false"
         android:title="Test Menu"
+        android:visible="false"
         tools:ignore="HardcodedText" />
 </menu>
diff --git a/Corona-Warn-App/src/main/res/navigation/nav_graph.xml b/Corona-Warn-App/src/main/res/navigation/nav_graph.xml
index c5dbb30805719b2d3a7f9cee9404d2189cf76b45..44aea7e8c24b13c4aedcd91e535e6309c8f828b1 100644
--- a/Corona-Warn-App/src/main/res/navigation/nav_graph.xml
+++ b/Corona-Warn-App/src/main/res/navigation/nav_graph.xml
@@ -96,7 +96,7 @@
     <fragment
         android:id="@+id/onboardingDeltaInteroperabilityFragment"
         android:name="de.rki.coronawarnapp.ui.onboarding.OnboardingDeltaInteroperabilityFragment"
-        android:label="OnboardingDeltaInteroperabilityFragment" >
+        android:label="OnboardingDeltaInteroperabilityFragment">
         <action
             android:id="@+id/action_onboardingDeltaInteroperabilityFragment_to_informationTermsFragment"
             app:destination="@id/informationTermsFragment" />
@@ -224,7 +224,7 @@
     </fragment>
     <fragment
         android:id="@+id/submissionResultPositiveOtherWarningFragment"
-        android:name="de.rki.coronawarnapp.ui.submission.fragment.SubmissionResultPositiveOtherWarningFragment"
+        android:name="de.rki.coronawarnapp.ui.submission.warnothers.SubmissionResultPositiveOtherWarningFragment"
         android:label="fragment_submission_result_positive_other_warning"
         tools:layout="@layout/fragment_submission_positive_other_warning">
         <action
@@ -243,10 +243,13 @@
         <action
             android:id="@+id/action_submissionResultPositiveOtherWarningFragment_to_submissionSymptomIntroductionFragment"
             app:destination="@id/submissionSymptomIntroductionFragment" />
+        <argument
+            android:name="symptoms"
+            app:argType="de.rki.coronawarnapp.submission.Symptoms" />
     </fragment>
     <fragment
         android:id="@+id/submissionResultFragment"
-        android:name="de.rki.coronawarnapp.ui.submission.fragment.SubmissionTestResultFragment"
+        android:name="de.rki.coronawarnapp.ui.submission.testresult.SubmissionTestResultFragment"
         android:label="fragment_submission_result"
         tools:layout="@layout/fragment_submission_test_result">
         <argument
@@ -268,7 +271,7 @@
 
     <fragment
         android:id="@+id/submissionTanFragment"
-        android:name="de.rki.coronawarnapp.ui.submission.fragment.SubmissionTanFragment"
+        android:name="de.rki.coronawarnapp.ui.submission.tan.SubmissionTanFragment"
         android:label="fragment_submission_tan"
         tools:layout="@layout/fragment_submission_tan">
         <action
@@ -311,7 +314,7 @@
     </activity>
     <fragment
         android:id="@+id/submissionQRCodeInfoFragment"
-        android:name="de.rki.coronawarnapp.ui.submission.fragment.SubmissionQRCodeInfoFragment"
+        android:name="de.rki.coronawarnapp.ui.submission.qrcode.info.SubmissionQRCodeInfoFragment"
         android:label="SubmissionQRCodeInfoFragment">
         <action
             android:id="@+id/action_submissionQRCodeInfoFragment_to_submissionQRCodeScanFragment"
@@ -320,7 +323,7 @@
 
     <fragment
         android:id="@+id/submissionQRCodeScanFragment"
-        android:name="de.rki.coronawarnapp.ui.submission.fragment.SubmissionQRCodeScanFragment"
+        android:name="de.rki.coronawarnapp.ui.submission.qrcode.scan.SubmissionQRCodeScanFragment"
         android:label="SubmissionQRCodeScanFragment">
         <action
             android:id="@+id/action_submissionQRCodeScanFragment_to_submissionDispatcherFragment"
@@ -357,8 +360,9 @@
     </fragment>
     <fragment
         android:id="@+id/submissionSymptomIntroductionFragment"
-        android:name="de.rki.coronawarnapp.ui.submission.fragment.SubmissionSymptomIntroductionFragment"
+        android:name="de.rki.coronawarnapp.ui.submission.symptoms.introduction.SubmissionSymptomIntroductionFragment"
         android:label="SubmissionSymptomIntroductionFragment" >
+
         <action
             android:id="@+id/action_submissionSymptomIntroductionFragment_to_submissionSymptomCalendarFragment"
             app:destination="@id/submissionSymptomCalendarFragment" />
@@ -371,14 +375,18 @@
     </fragment>
     <fragment
         android:id="@+id/submissionSymptomCalendarFragment"
-        android:name="de.rki.coronawarnapp.ui.submission.fragment.SubmissionSymptomCalendarFragment"
-        android:label="SubmissionSymptomCalendarFragment" >
+        android:name="de.rki.coronawarnapp.ui.submission.symptoms.calendar.SubmissionSymptomCalendarFragment"
+        android:label="SubmissionSymptomCalendarFragment">
         <action
             android:id="@+id/action_submissionCalendarFragment_to_submissionSymptomIntroductionFragment"
             app:destination="@id/submissionSymptomIntroductionFragment" />
         <action
             android:id="@+id/action_submissionSymptomCalendarFragment_to_submissionResultPositiveOtherWarningFragment"
             app:destination="@id/submissionResultPositiveOtherWarningFragment" />
+        <argument
+            android:name="symptomIndication"
+            app:argType="de.rki.coronawarnapp.submission.Symptoms$Indication" />
     </fragment>
 
+
 </navigation>
diff --git a/Corona-Warn-App/src/main/res/values-bg/strings.xml b/Corona-Warn-App/src/main/res/values-bg/strings.xml
index 126c9226c3d051df1205aa46cd388339696363c1..7b20d46a9c3aa5b49191a72f9d811bc7c7f13a15 100644
--- a/Corona-Warn-App/src/main/res/values-bg/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-bg/strings.xml
@@ -193,6 +193,19 @@
     <!-- XTXT: risk card - outdated risk manual, calculation couldn't be updated in the last 48 hours -->
     <string name="risk_card_outdated_manual_risk_body">"Вашият статус на риск не е обновяван от повече от 48 часа. Моля, актуализирайте го."</string>
 
+    <!-- ####################################
+              Risk Card - Progress
+    ###################################### -->
+
+    <!-- XHED: risk card - progress - progress card headline displayed downloading key packages -->
+    <string name="risk_card_progress_download_headline">"Данните се изтеглят..."</string>
+    <!-- XTXT: risk card - progress - progress card body displayed downloading key packages -->
+    <string name="risk_card_progress_download_body">"Това може да отнеме няколко минути. Благодарим Ви за търпението!"</string>
+    <!-- XHED: risk card - progress - progress card headline displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_headline">"Извършва се проверка..."</string>
+    <!-- XTXT: risk card - progress - progress card body displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_body">"Данните за Вашия риск от заразяване се проверяват. Това може да отнеме няколко минути. Благодарим Ви за търпението!"</string>
+
     <!-- ####################################
                     Main
     ###################################### -->
@@ -362,9 +375,22 @@
     <string name="risk_details_explanation_faq_link">"https://www.coronawarn.app/en/faq/#encounter_but_green"</string>
 
     <!-- XHED: risk details - deadman notification title -->
-    <string name="risk_details_deadman_notification_title">"Актуализиране на данните за проверка на риска"</string>
+    <string name="risk_details_deadman_notification_title">"Вашият статус на риск"</string>
     <!-- YTXT: risk details - deadman notification text -->
-    <string name="risk_details_deadman_notification_body">"Моля, отворете приложението Corona-Warn-App, за да актуализирате регистрирането на излагания на риск."</string>
+    <string name="risk_details_deadman_notification_body">"Моля, не забравяйте да отваряте приложението Corona-Warn-App редовно, за да проверявате статуса си на риск."</string>
+
+    <!-- ####################################
+               Risk Lowered Dialog
+    ###################################### -->
+
+    <!-- XHED: risk lowered - dialog headline -->
+    <string name="risk_lowered_dialog_headline">"Промяна на Вашия статус на риск"</string>
+    <!-- XTXT: risk lowered - dialog content -->
+    <string name="risk_lowered_dialog_body">"Изминаха повече от 14 дни от последния контакт, който предизвика повишаване на нивото на Вашия риск. Затова рискът Ви от заразяване отново е определен като нисък.\n\nЕто какво трябва да направите:\n1. Ако нямате симптоми за заразяване с COVID-19, продължете да следвате общоприетите правила за спазване на дистанция и хигиена.\n2. Ако имате симптоми за заразяване с COVID-19, препоръчваме Ви да посетите своя лекар и да се тествате."</string>
+    <!-- XBUT: risk lowerd - dialog confirm -->
+    <string name="risk_lowered_dialog_button_confirm">"OK"</string>
+    <!-- XBUT: risk lowered - dialog cancel -->
+    <string name="risk_lowered_dialog_button_cancel">"Повече"</string>
 
     <!-- ####################################
               Onboarding
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 dcf2f3b12e477d4c423dd9b7522b2a2d8593fe54..47597832966fcef9f641b812858c7f2ec4cafb3f 100644
--- a/Corona-Warn-App/src/main/res/values-de/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-de/strings.xml
@@ -110,6 +110,10 @@
     <string name="notification_headline">"Corona-Warn-App"</string>
     <!-- XTXT: Notification body -->
     <string name="notification_body">"Es gibt Neuigkeiten von Ihrer Corona-Warn-App."</string>
+    <!-- XHED: Notification title - Reminder to share a positive test result-->
+    <string name="notification_headline_share_positive_result">"Helfen Sie mit!"</string>
+    <!-- XTXT: Notification body - Reminder to share a positive test result-->
+    <string name="notification_body_share_positive_result">"Bitte warnen Sie andere und teilen Sie Ihr Testergebnis."</string>
 
     <!-- ####################################
               App Auto Update
@@ -186,13 +190,19 @@
     <!-- XHED: risk card - tracing stopped headline, due to no possible calculation -->
     <string name="risk_card_no_calculation_possible_headline">"Risiko-Ermittlung gestoppt"</string>
     <!-- XTXT: risk card - last successfully calculated risk level -->
-    <string name="risk_card_no_calculation_possible_body_saved_risk">"Letzte Risiko-Ermittlung:"<xliff:g id="line_break">"\n"</xliff:g>"%1$s"</string>
+    <string name="risk_card_no_calculation_possible_body_saved_risk">"Letzte Risiko-Überprüfung:"<xliff:g id="line_break">"\n"</xliff:g>"%1$s"</string>
     <!-- XHED: risk card -  outdated risk headline, calculation isn't possible -->
     <string name="risk_card_outdated_risk_headline">"Risiko-Ermittlung nicht möglich"</string>
     <!-- XTXT: risk card - outdated risk, calculation couldn't be updated in the last 24 hours -->
     <string name="risk_card_outdated_risk_body">"Ihre Risiko-Ermittlung konnte seit mehr als 24 Stunden nicht aktualisiert werden."</string>
     <!-- XTXT: risk card - outdated risk manual, calculation couldn't be updated in the last 48 hours -->
     <string name="risk_card_outdated_manual_risk_body">"Ihr Risikostatus wurde seit mehr als 48 Stunden nicht aktualisiert. Bitte aktualisieren Sie Ihren Risikostatus."</string>
+    <!-- XHED: risk card - risk check failed headline, no internet connection -->
+    <string name="risk_card_check_failed_no_internet_headline">"Risiko-Überprüfung fehlgeschlagen"</string>
+    <!-- XTXT: risk card - risk check failed, please check your internet connection -->
+    <string name="risk_card_check_failed_no_internet_body">"Der Abgleich der Zufall-IDs mit dem Server ist fehlgeschlagen. Sie können den Abgleich manuell neu starten."</string>
+    <!-- XTXT: risk card - risk check failed, restart button -->
+    <string name="risk_card_check_failed_no_internet_restart_button">"Erneut starten"</string>
 
     <!-- ####################################
               Risk Card - Progress
diff --git a/Corona-Warn-App/src/main/res/values-en/strings.xml b/Corona-Warn-App/src/main/res/values-en/strings.xml
index 2a99beb77021185524ac302ae204f7930981cbe9..568659f0360416649e4005c198a66d174be6ff68 100644
--- a/Corona-Warn-App/src/main/res/values-en/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-en/strings.xml
@@ -193,6 +193,19 @@
     <!-- XTXT: risk card - outdated risk manual, calculation couldn't be updated in the last 48 hours -->
     <string name="risk_card_outdated_manual_risk_body">"Your risk status has not been updated for more than 48 hours. Please update your risk status."</string>
 
+    <!-- ####################################
+              Risk Card - Progress
+    ###################################### -->
+
+    <!-- XHED: risk card - progress - progress card headline displayed downloading key packages -->
+    <string name="risk_card_progress_download_headline">"Data is being downloaded…"</string>
+    <!-- XTXT: risk card - progress - progress card body displayed downloading key packages -->
+    <string name="risk_card_progress_download_body">"This may take several minutes. Thank you for your patience."</string>
+    <!-- XHED: risk card - progress - progress card headline displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_headline">"Check is running…"</string>
+    <!-- XTXT: risk card - progress - progress card body displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_body">"Your exposure data is being checked. This may take several minutes. Thank you for your patience."</string>
+
     <!-- ####################################
                     Main
     ###################################### -->
@@ -362,9 +375,22 @@
     <string name="risk_details_explanation_faq_link">"https://www.coronawarn.app/en/faq/#encounter_but_green"</string>
 
     <!-- XHED: risk details - deadman notification title -->
-    <string name="risk_details_deadman_notification_title">"Update Exposure Check"</string>
+    <string name="risk_details_deadman_notification_title">"Your Risk Status"</string>
     <!-- YTXT: risk details - deadman notification text -->
-    <string name="risk_details_deadman_notification_body">"Please open the Corona-Warn-App to update exposure logging."</string>
+    <string name="risk_details_deadman_notification_body">"Please remember to open the Corona-Warn-App regularly to check your risk status."</string>
+
+    <!-- ####################################
+               Risk Lowered Dialog
+    ###################################### -->
+
+    <!-- XHED: risk lowered - dialog headline -->
+    <string name="risk_lowered_dialog_headline">"Change of Your Risk Status"</string>
+    <!-- XTXT: risk lowered - dialog content -->
+    <string name="risk_lowered_dialog_body">"The last exposure that resulted in your increased risk was more than 14 days ago. Therefore, your risk of infection has been ranked as low again.\n\nThis is what you should do:\n1. If you do not have any COVID-19 symptoms, continue to follow the prevailing rules regarding distancing and hygiene.\n2. If you have COVID-19 symptoms, we recommend that you visit your doctor and get tested."</string>
+    <!-- XBUT: risk lowerd - dialog confirm -->
+    <string name="risk_lowered_dialog_button_confirm">"OK"</string>
+    <!-- XBUT: risk lowered - dialog cancel -->
+    <string name="risk_lowered_dialog_button_cancel">"More"</string>
 
     <!-- ####################################
               Onboarding
diff --git a/Corona-Warn-App/src/main/res/values-pl/strings.xml b/Corona-Warn-App/src/main/res/values-pl/strings.xml
index 6b68b80e85e659dca932244293a87ac5d587cb92..0f8a8191a508dc0d04aa93af8d0c2aca7ebc8576 100644
--- a/Corona-Warn-App/src/main/res/values-pl/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-pl/strings.xml
@@ -193,6 +193,19 @@
     <!-- XTXT: risk card - outdated risk manual, calculation couldn't be updated in the last 48 hours -->
     <string name="risk_card_outdated_manual_risk_body">"Twój status ryzyka nie był aktualizowany od ponad 48 godzin. Zaktualizuj swój status ryzyka."</string>
 
+    <!-- ####################################
+              Risk Card - Progress
+    ###################################### -->
+
+    <!-- XHED: risk card - progress - progress card headline displayed downloading key packages -->
+    <string name="risk_card_progress_download_headline">"Dane sÄ… pobierane..."</string>
+    <!-- XTXT: risk card - progress - progress card body displayed downloading key packages -->
+    <string name="risk_card_progress_download_body">"Może to potrwać kilka minut. Dziękujemy za cierpliwość."</string>
+    <!-- XHED: risk card - progress - progress card headline displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_headline">"Sprawdzanie w toku..."</string>
+    <!-- XTXT: risk card - progress - progress card body displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_body">"Twoje dane narażenia są sprawdzane. Może to potrwać kilka minut. Dziękujemy za cierpliwość."</string>
+
     <!-- ####################################
                     Main
     ###################################### -->
@@ -362,9 +375,22 @@
     <string name="risk_details_explanation_faq_link">"https://www.coronawarn.app/en/faq/#encounter_but_green"</string>
 
     <!-- XHED: risk details - deadman notification title -->
-    <string name="risk_details_deadman_notification_title">"Aktualizuj sprawdzanie narażeń"</string>
+    <string name="risk_details_deadman_notification_title">"Twój status ryzyka"</string>
     <!-- YTXT: risk details - deadman notification text -->
-    <string name="risk_details_deadman_notification_body">"Otwórz aplikację Corona-Warn-App, aby zaktualizować rejestrowanie narażenia."</string>
+    <string name="risk_details_deadman_notification_body">"Pamiętaj, aby regularnie otwierać aplikację Corona-Warn-App w celu sprawdzenia swojego statusu ryzyka."</string>
+
+    <!-- ####################################
+               Risk Lowered Dialog
+    ###################################### -->
+
+    <!-- XHED: risk lowered - dialog headline -->
+    <string name="risk_lowered_dialog_headline">"Zmiana Twojego statusu ryzyka"</string>
+    <!-- XTXT: risk lowered - dialog content -->
+    <string name="risk_lowered_dialog_body">"Ostatnie narażenie, które spowodowało zwiększenie Twojego ryzyka, miało miejsce ponad 14 dni temu. Z tego powodu ryzyko zakażenia zostało ponownie sklasyfikowane jako niskie.\n\nZalecenia:\n1. Jeśli nie masz żadnych objawów zakażenia COVID-19, przestrzegaj nadal obowiązujących zasad dotyczących zachowania dystansu i higieny.\n2. Jeżeli masz objawy zakażenia COVID-19, zalecamy wizytę u lekarza i wykonanie testu."</string>
+    <!-- XBUT: risk lowerd - dialog confirm -->
+    <string name="risk_lowered_dialog_button_confirm">"OK"</string>
+    <!-- XBUT: risk lowered - dialog cancel -->
+    <string name="risk_lowered_dialog_button_cancel">"Więcej"</string>
 
     <!-- ####################################
               Onboarding
diff --git a/Corona-Warn-App/src/main/res/values-ro/strings.xml b/Corona-Warn-App/src/main/res/values-ro/strings.xml
index f8f09e0558c0f2320db5f9a66f5bc9f33188fbb6..6c78674ea18deac47d80a941d4aefde469f79695 100644
--- a/Corona-Warn-App/src/main/res/values-ro/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-ro/strings.xml
@@ -193,6 +193,19 @@
     <!-- XTXT: risk card - outdated risk manual, calculation couldn't be updated in the last 48 hours -->
     <string name="risk_card_outdated_manual_risk_body">"Starea riscului dvs. nu a fost actualizată timp de peste 48 de ore. Vă rugăm să activați starea riscului dvs."</string>
 
+    <!-- ####################################
+              Risk Card - Progress
+    ###################################### -->
+
+    <!-- XHED: risk card - progress - progress card headline displayed downloading key packages -->
+    <string name="risk_card_progress_download_headline">"Datele sunt în curs de descărcare..."</string>
+    <!-- XTXT: risk card - progress - progress card body displayed downloading key packages -->
+    <string name="risk_card_progress_download_body">"Aceasta poate dura câteva minute. Vă mulțumim pentru răbdare."</string>
+    <!-- XHED: risk card - progress - progress card headline displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_headline">"Verificarea este în curs…"</string>
+    <!-- XTXT: risk card - progress - progress card body displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_body">"Datele dvs. privind expunerea sunt verificate. Aceasta poate dura câteva minute. Vă mulțumim pentru răbdare."</string>
+
     <!-- ####################################
                     Main
     ###################################### -->
@@ -362,9 +375,22 @@
     <string name="risk_details_explanation_faq_link">"https://www.coronawarn.app/en/faq/#encounter_but_green"</string>
 
     <!-- XHED: risk details - deadman notification title -->
-    <string name="risk_details_deadman_notification_title">"Actualizare verificare expunere"</string>
+    <string name="risk_details_deadman_notification_title">"Starea riscului dvs."</string>
     <!-- YTXT: risk details - deadman notification text -->
-    <string name="risk_details_deadman_notification_body">"Deschideți aplicația Corona-Warn pentru a actualiza înregistrarea în jurnal a expunerilor."</string>
+    <string name="risk_details_deadman_notification_body">"Nu uitați să deschideți frecvent aplicația Corona-Warn pentru a verifica starea riscului dvs."</string>
+
+    <!-- ####################################
+               Risk Lowered Dialog
+    ###################################### -->
+
+    <!-- XHED: risk lowered - dialog headline -->
+    <string name="risk_lowered_dialog_headline">"Modificarea stării riscului dvs."</string>
+    <!-- XTXT: risk lowered - dialog content -->
+    <string name="risk_lowered_dialog_body">"Ultima expunere care a generat riscul dvs. crescut a avut loc cu mai mult de 14 zile în urmă. Prin urmare, riscul dvs. de infectare a fost clasificat ca fiind din nou redus.\n\nIată ce trebuie să faceți:\n1. Dacă nu aveți simptome specifice COVID-19, continuați să respectați regulile generale privind distanțarea și igiena.\n2. Dacă aveți simptome specifice COVID-19, vă recomandăm să mergeți la medic și să vă testați."</string>
+    <!-- XBUT: risk lowerd - dialog confirm -->
+    <string name="risk_lowered_dialog_button_confirm">"OK"</string>
+    <!-- XBUT: risk lowered - dialog cancel -->
+    <string name="risk_lowered_dialog_button_cancel">"Mai mult"</string>
 
     <!-- ####################################
               Onboarding
diff --git a/Corona-Warn-App/src/main/res/values-tr/strings.xml b/Corona-Warn-App/src/main/res/values-tr/strings.xml
index 360ec99f7eb882d431f78fb5143e94d5f15e4ada..02255db8b322545744f9c3fa76f6a5958bb01296 100644
--- a/Corona-Warn-App/src/main/res/values-tr/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-tr/strings.xml
@@ -193,6 +193,19 @@
     <!-- XTXT: risk card - outdated risk manual, calculation couldn't be updated in the last 48 hours -->
     <string name="risk_card_outdated_manual_risk_body">"Risk durumunuz 48 saatten uzun süredir güncellenmedi. Lütfen risk durumunuzu güncelleyin."</string>
 
+    <!-- ####################################
+              Risk Card - Progress
+    ###################################### -->
+
+    <!-- XHED: risk card - progress - progress card headline displayed downloading key packages -->
+    <string name="risk_card_progress_download_headline">"Veri indiriliyor…"</string>
+    <!-- XTXT: risk card - progress - progress card body displayed downloading key packages -->
+    <string name="risk_card_progress_download_body">"Bu işlem birkaç dakika sürebilir. Gösterdiğiniz sabır için teşekkür ederiz."</string>
+    <!-- XHED: risk card - progress - progress card headline displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_headline">"Kontrol devam ediyor…"</string>
+    <!-- XTXT: risk card - progress - progress card body displayed calculating the risklevel -->
+    <string name="risk_card_progress_calculation_body">"Maruz kalma verileriniz kontrol ediliyor. Bu işlem birkaç dakika sürebilir. Gösterdiğiniz sabır için teşekkür ederiz."</string>
+
     <!-- ####################################
                     Main
     ###################################### -->
@@ -362,9 +375,22 @@
     <string name="risk_details_explanation_faq_link">"https://www.coronawarn.app/en/faq/#encounter_but_green"</string>
 
     <!-- XHED: risk details - deadman notification title -->
-    <string name="risk_details_deadman_notification_title">"Maruz Kalma Kontrolünü Güncelle"</string>
+    <string name="risk_details_deadman_notification_title">"Risk Durumunuz"</string>
     <!-- YTXT: risk details - deadman notification text -->
-    <string name="risk_details_deadman_notification_body">"Maruz kalma günlüğünü güncellemek için lütfen Corona-Warn-App\'i açın."</string>
+    <string name="risk_details_deadman_notification_body">"Risk durumunuzu kontrol etmek için lütfen Corona-Warn-App\'i düzenli olarak açmayı unutmayın."</string>
+
+    <!-- ####################################
+               Risk Lowered Dialog
+    ###################################### -->
+
+    <!-- XHED: risk lowered - dialog headline -->
+    <string name="risk_lowered_dialog_headline">"Risk Durumunuzda DeÄŸiÅŸiklik"</string>
+    <!-- XTXT: risk lowered - dialog content -->
+    <string name="risk_lowered_dialog_body">"Risk durumunuzun artmasına neden olan son maruz kalma 14 günden uzun süre önce gerçekleşti. Bu nedenle enfeksiyon riskiniz yeniden düşük olarak derecelendirildi.\n\nYapmanız gerekenler:\n1. Herhangi bir COVID-19 belirtiniz yoksa mesafe ve hijyen konusunda belirlenen kurallara riayet etmenizi öneririz.\n2. COVID-19 belirtiniz varsa doktorunuzu ziyaret edip test yaptırmanızı öneririz."</string>
+    <!-- XBUT: risk lowerd - dialog confirm -->
+    <string name="risk_lowered_dialog_button_confirm">"Tamam"</string>
+    <!-- XBUT: risk lowered - dialog cancel -->
+    <string name="risk_lowered_dialog_button_cancel">"Daha Fazla"</string>
 
     <!-- ####################################
               Onboarding
diff --git a/Corona-Warn-App/src/main/res/values/legal_strings.xml b/Corona-Warn-App/src/main/res/values/legal_strings.xml
index 934a3272773c74bf6f1ad4208db15aee93c3af7f..244c58b9a2d4988dad72bd47697c3bd55be146e0 100644
--- a/Corona-Warn-App/src/main/res/values/legal_strings.xml
+++ b/Corona-Warn-App/src/main/res/values/legal_strings.xml
@@ -12,5 +12,5 @@
     <!-- XHED: onboarding(tracing) - headline for consent information -->
     <string name="onboarding_tracing_headline_consent" translatable="false">"Declaration of consent"</string>
     <!-- YTXT: onboarding(tracing) - body for consent information -->
-    <string name="onboarding_tracing_body_consent" translatable="false">"You need to enable exposure logging to find out whether you have had possible exposures involving app users in the participating countries and are therefore at risk of infection yourself. By tapping on the “Activate exposure logging” button, you agree to enabling the exposure logging feature and to the associated data processing by the app.\n\nIn order to use the exposure logging feature, you will also have to enable the “COVID-19 Exposure Notifications” functionality provided by Google on your Android smartphone and grant the Corona-Warn-App permission to use this.\n\nWhen COVID-19 Exposure Notifications are enabled, your Android smartphone continuously generates random IDs and sends them via Bluetooth so that they can be received by other smartphones near you. Your Android smartphone, in turn, receives the random IDs of other smartphones. Your own random IDs and those received from other smartphones are recorded by your Android smartphone and stored there for 14 days.\n\nFor exposure logging, the app downloads a list, which is updated daily, of the random IDs of all users who have shared their random IDs via their official coronavirus app. This list is then compared with the random IDs of other users which have been recorded by your smartphone.\n\nThe app will inform you if it detects a possible exposure. In this case, the app gains access to the data recorded by your smartphone about the possible exposure (date, duration and Bluetooth signal strength of the contact). The Bluetooth signal strength is used to derive the physical distance to the other user (the stronger the signal, the smaller the distance). The app analyses this information in order to calculate your risk of infection and to give you recommendations for what to do next. This analysis is only performed locally on your smartphone.\n\nApart from you, nobody (not even the RKI or the health authorities of participating countries) will know whether a possible exposure has been detected and what risk of infection has been identified for you.\n\nTo withdraw your consent to the exposure logging feature, you can disable the feature by using the toggle switch in the app or delete the app. If you would like to use the exosure logging feature again, you can toggle the feature back on or reinstall the app. If you disable the exposure logging feature, the app will no longer check for possible exposures. If you also wish to stop your device sending and receiving random IDs, you will need to disable COVID-19 Exposure Notifications in your Android smartphone settings. Please note that your own random IDs and those received from other smartphones which are stored by your Android smartphone’s COVID-19 Exposure Notification functionality will not be deleted by the app. You can only permanently delete these in your Android smartphone settings.\n\nThe app’s privacy notice (including information about the data processing carried out for the transnational exposure logging feature) can be found in the menu under „App Information“ > „Data Privacy“."</string>
+    <string name="onboarding_tracing_body_consent" translatable="false">"You need to enable exposure logging to find out whether you have had possible exposures involving app users in the participating countries and are therefore at risk of infection yourself. By tapping on the “Activate exposure logging” button, you agree to enabling the exposure logging feature and to the associated data processing by the app.\n\nIn order to use the exposure logging feature, you will also have to enable the “COVID-19 Exposure Notifications” functionality provided by Google on your Android smartphone and grant the Corona-Warn-App permission to use this.\n\nWhen COVID-19 Exposure Notifications are enabled, your Android smartphone continuously generates random IDs and sends them via Bluetooth so that they can be received by other smartphones near you. Your Android smartphone, in turn, receives the random IDs of other smartphones. Your own random IDs and those received from other smartphones are recorded by your Android smartphone and stored there for 14 days.\n\nFor exposure logging, the app downloads a list, which is updated daily, of the random IDs of all users who have shared their random IDs via their official coronavirus app. This list is then compared with the random IDs of other users which have been recorded by your smartphone.\n\nThe app will inform you if it detects a possible exposure. In this case, the app gains access to the data recorded by your smartphone about the possible exposure (date, duration and Bluetooth signal strength of the contact). The Bluetooth signal strength is used to derive the physical distance to the other user (the stronger the signal, the smaller the distance). The app analyses this information in order to calculate your risk of infection and to give you recommendations for what to do next. This analysis is only performed locally on your smartphone.\n\nApart from you, nobody (not even the RKI or the health authorities of participating countries) will know whether a possible exposure has been detected and what risk of infection has been identified for you.\n\nTo withdraw your consent to the exposure logging feature, you can disable the feature by using the toggle switch in the app or delete the app. If you would like to use the exposure logging feature again, you can toggle the feature back on or reinstall the app. If you disable the exposure logging feature, the app will no longer check for possible exposures. If you also wish to stop your device sending and receiving random IDs, you will need to disable COVID-19 Exposure Notifications in your Android smartphone settings. Please note that your own random IDs and those received from other smartphones which are stored by your Android smartphone’s COVID-19 Exposure Notification functionality will not be deleted by the app. You can only permanently delete these in your Android smartphone settings.\n\nThe app’s privacy notice (including information about the data processing carried out for the transnational exposure logging feature) can be found in the menu under „App Information“ > „Data Privacy“."</string>
 </resources>
diff --git a/Corona-Warn-App/src/main/res/values/strings.xml b/Corona-Warn-App/src/main/res/values/strings.xml
index ff0ae9749ef306cf35e9a1cdd891dade56734fa8..d135fef1639ced57593c5caa26055e71dc66be0c 100644
--- a/Corona-Warn-App/src/main/res/values/strings.xml
+++ b/Corona-Warn-App/src/main/res/values/strings.xml
@@ -116,6 +116,10 @@
     <string name="notification_headline">"Corona-Warn-App"</string>
     <!-- XTXT: Notification body -->
     <string name="notification_body">"You have new messages from your Corona-Warn-App."</string>
+    <!-- XHED: Notification title - Reminder to share a positive test result-->
+    <string name="notification_headline_share_positive_result">"Helfen Sie mit!"</string>
+    <!-- XTXT: Notification body - Reminder to share a positive test result-->
+    <string name="notification_body_share_positive_result">"Bitte warnen Sie andere und teilen Sie Ihr Testergebnis."</string>
 
     <!-- ####################################
               App Auto Update
@@ -199,19 +203,25 @@
     <string name="risk_card_outdated_risk_body">"Your exposure logging could not be updated for more than 24 hours."</string>
     <!-- XTXT: risk card - outdated risk manual, calculation couldn't be updated in the last 48 hours -->
     <string name="risk_card_outdated_manual_risk_body">"Your risk status has not been updated for more than 48 hours. Please update your risk status."</string>
+    <!-- XHED: risk card - risk check failed headline, no internet connection -->
+    <string name="risk_card_check_failed_no_internet_headline" />
+    <!-- XTXT: risk card - risk check failed, please check your internet connection -->
+    <string name="risk_card_check_failed_no_internet_body" />
+    <!-- XTXT: risk card - risk check failed, restart button -->
+    <string name="risk_card_check_failed_no_internet_restart_button" />
 
     <!-- ####################################
-                  Risk Card - Progress
+              Risk Card - Progress
     ###################################### -->
 
     <!-- XHED: risk card - progress - progress card headline displayed downloading key packages -->
-    <string name="risk_card_progress_download_headline">""</string>
+    <string name="risk_card_progress_download_headline">"Data is being downloaded…"</string>
     <!-- XTXT: risk card - progress - progress card body displayed downloading key packages -->
-    <string name="risk_card_progress_download_body">""</string>
+    <string name="risk_card_progress_download_body">"This may take several minutes. Thank you for your patience."</string>
     <!-- XHED: risk card - progress - progress card headline displayed calculating the risklevel -->
-    <string name="risk_card_progress_calculation_headline">""</string>
+    <string name="risk_card_progress_calculation_headline">"Check is running…"</string>
     <!-- XTXT: risk card - progress - progress card body displayed calculating the risklevel -->
-    <string name="risk_card_progress_calculation_body">""</string>
+    <string name="risk_card_progress_calculation_body">"Your exposure data is being checked. This may take several minutes. Thank you for your patience."</string>
 
     <!-- ####################################
                     Main
@@ -382,22 +392,22 @@
     <string name="risk_details_explanation_faq_link">"https://www.coronawarn.app/en/faq/#encounter_but_green"</string>
 
     <!-- XHED: risk details - deadman notification title -->
-    <string name="risk_details_deadman_notification_title">"Update Exposure Check"</string>
+    <string name="risk_details_deadman_notification_title">"Your Risk Status"</string>
     <!-- YTXT: risk details - deadman notification text -->
-    <string name="risk_details_deadman_notification_body">"Please open the Corona-Warn-App to update exposure logging."</string>
+    <string name="risk_details_deadman_notification_body">"Please remember to open the Corona-Warn-App regularly to check your risk status."</string>
 
     <!-- ####################################
                Risk Lowered Dialog
     ###################################### -->
 
     <!-- XHED: risk lowered - dialog headline -->
-    <string name="risk_lowered_dialog_headline" />
+    <string name="risk_lowered_dialog_headline">"Change of Your Risk Status"</string>
     <!-- XTXT: risk lowered - dialog content -->
-    <string name="risk_lowered_dialog_body" />
+    <string name="risk_lowered_dialog_body">"The last exposure that resulted in your increased risk was more than 14 days ago. Therefore, your risk of infection has been ranked as low again.\n\nThis is what you should do:\n1. If you do not have any COVID-19 symptoms, continue to follow the prevailing rules regarding distancing and hygiene.\n2. If you have COVID-19 symptoms, we recommend that you visit your doctor and get tested."</string>
     <!-- XBUT: risk lowerd - dialog confirm -->
-    <string name="risk_lowered_dialog_button_confirm" />
+    <string name="risk_lowered_dialog_button_confirm">"OK"</string>
     <!-- XBUT: risk lowered - dialog cancel -->
-    <string name="risk_lowered_dialog_button_cancel" />
+    <string name="risk_lowered_dialog_button_cancel">"More"</string>
 
     <!-- ####################################
               Onboarding
@@ -1398,4 +1408,6 @@
     <string name="interoperability_onboarding_list_subtitle_failrequest_no_network">"Your Internet connection may have been lost. Please ensure that you are connected to the Internet."</string>
     <!-- XBUT: Title for the interoperability onboarding Settings-Button if no network is available -->
     <string name="interoperability_onboarding_list_button_title_no_network">"Open Device Settings"</string>
+
+
 </resources>
\ No newline at end of file
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
index 972136da0c94f4ce8b65306cbfec4c9102c445d9..00fcd329fc1e5456231e032cdfe1788531ba5f6f 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt
@@ -1,37 +1,37 @@
 package de.rki.coronawarnapp.appconfig
 
-import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
-import de.rki.coronawarnapp.util.security.VerificationKeys
-import io.kotest.assertions.throwables.shouldThrow
+import de.rki.coronawarnapp.util.TimeStamper
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
+import io.mockk.Runs
 import io.mockk.clearAllMocks
 import io.mockk.coEvery
-import io.mockk.coVerify
+import io.mockk.coVerifySequence
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
-import io.mockk.mockk
-import io.mockk.verify
-import okhttp3.ResponseBody.Companion.toResponseBody
-import okio.ByteString.Companion.decodeHex
+import io.mockk.just
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import org.joda.time.Duration
+import org.joda.time.Instant
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 import testhelpers.BaseIOTest
+import testhelpers.TestDispatcherProvider
+import testhelpers.coroutines.runBlockingTest2
+import testhelpers.coroutines.test
 import java.io.File
-import java.io.IOException
 
 class AppConfigProviderTest : BaseIOTest() {
 
-    @MockK lateinit var api: AppConfigApiV1
-    @MockK lateinit var verificationKeys: VerificationKeys
-    @MockK lateinit var appConfigStorage: AppConfigStorage
+    @MockK lateinit var source: AppConfigSource
+    @MockK lateinit var configData: ConfigData
+    @MockK lateinit var timeStamper: TimeStamper
 
     private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
 
-    private val defaultHomeCountry = LocationCode("DE")
-
-    private var mockConfigStorage: ByteArray? = null
+    private lateinit var testConfigDownload: ConfigData
 
     @BeforeEach
     fun setup() {
@@ -39,9 +39,16 @@ class AppConfigProviderTest : BaseIOTest() {
         testDir.mkdirs()
         testDir.exists() shouldBe true
 
-        coEvery { appConfigStorage.isAppConfigAvailable() } answers { mockConfigStorage != null }
-        coEvery { appConfigStorage.getAppConfigRaw() } answers { mockConfigStorage }
-        coEvery { appConfigStorage.setAppConfigRaw(any()) } answers { mockConfigStorage = arg(0) }
+        testConfigDownload = DefaultConfigData(
+            serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
+            localOffset = Duration.ZERO,
+            mappedConfig = configData,
+            isFallback = false
+        )
+        coEvery { source.clear() } just Runs
+        coEvery { source.retrieveConfig() } returns testConfigDownload
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
     }
 
     @AfterEach
@@ -50,160 +57,53 @@ class AppConfigProviderTest : BaseIOTest() {
         testDir.deleteRecursively()
     }
 
-    private fun createDownloadServer(
-        homeCountry: LocationCode = defaultHomeCountry
-    ) = AppConfigProvider(
-        appConfigAPI = { api },
-        verificationKeys = verificationKeys,
-        homeCountry = homeCountry,
-        configStorage = appConfigStorage,
-        cache = mockk()
+    private fun createInstance(scope: CoroutineScope) = AppConfigProvider(
+        source = source,
+        dispatcherProvider = TestDispatcherProvider,
+        scope = scope
     )
 
     @Test
-    suspend fun `application config download`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        val downloadServer = createDownloadServer()
-
-        val rawConfig = downloadServer.downloadAppConfig()
-        rawConfig shouldBe APPCONFIG_RAW.toByteArray()
-
-        verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) }
-    }
-
-    @Test
-    suspend fun `application config data is faulty`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex()
-            .toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        val downloadServer = createDownloadServer()
+    fun `appConfig is observable`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance(this)
 
-        shouldThrow<ApplicationConfigurationInvalidException> {
-            downloadServer.downloadAppConfig()
-        }
-    }
+        val testCollector = instance.currentConfig.test(startOnScope = this)
 
-    @Test
-    suspend fun `application config verification fails`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
-            .toResponseBody()
+        instance.getAppConfig()
+        instance.clear()
+        instance.getAppConfig()
 
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
+        advanceUntilIdle()
 
-        val downloadServer = createDownloadServer()
+        testCollector.latestValues shouldBe listOf(
+            null,
+            testConfigDownload,
+            null,
+            testConfigDownload
+        )
 
-        shouldThrow<ApplicationConfigurationCorruptException> {
-            downloadServer.downloadAppConfig()
+        coVerifySequence {
+            source.retrieveConfig()
+            source.clear()
+            source.retrieveConfig()
         }
     }
 
     @Test
-    suspend fun `successful download stores new config`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
-            .toResponseBody()
+    fun `clear clears storage and current config`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance(this)
 
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
+        instance.getAppConfig() shouldBe testConfigDownload
+        instance.currentConfig.first() shouldBe testConfigDownload
 
-        val downloadServer = createDownloadServer()
-        downloadServer.getAppConfig()
+        instance.clear()
 
-        mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
-        coVerify { appConfigStorage.setAppConfigRaw(APPCONFIG_RAW.toByteArray()) }
-    }
-
-    @Test
-    suspend fun `failed download doesn't overwrite valid config`() {
-        mockConfigStorage = APPCONFIG_RAW.toByteArray()
-        coEvery { api.getApplicationConfiguration("DE") } throws IOException()
+        instance.currentConfig.first() shouldBe null
 
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        createDownloadServer().getAppConfig()
-
-        coVerify(exactly = 0) { appConfigStorage.setAppConfigRaw(any()) }
-        mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
-    }
-
-    @Test
-    suspend fun `failed verification doesn't overwrite valid config`() {
-        mockConfigStorage = APPCONFIG_RAW.toByteArray()
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE
-            .toResponseBody()
+        coVerifySequence {
+            source.retrieveConfig()
 
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
-
-        createDownloadServer().getAppConfig()
-
-        coVerify(exactly = 0) { appConfigStorage.setAppConfigRaw(any()) }
-        mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray()
-    }
-
-    @Test
-    suspend fun `fallback to last config if verification fails`() {
-        mockConfigStorage = APPCONFIG_RAW.toByteArray()
-
-        coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex()
-            .toResponseBody()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } throws Exception()
-        createDownloadServer().getAppConfig().minRiskScore shouldBe 11
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
-        createDownloadServer().getAppConfig().minRiskScore shouldBe 11
-    }
-
-    @Test
-    suspend fun `fallback to last config if download fails`() {
-        mockConfigStorage = APPCONFIG_RAW.toByteArray()
-
-        coEvery { api.getApplicationConfiguration("DE") } throws Exception()
-
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        createDownloadServer().getAppConfig().minRiskScore shouldBe 11
-    }
-
-    // Because the UI requires this to detect when to show alternative UI elements
-    @Test
-    suspend fun `if supportedCountryList is empty, we do not insert DE as fallback`() {
-        coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody()
-        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
-
-        createDownloadServer().getAppConfig().supportedCountriesList shouldBe emptyList()
-    }
-
-    companion object {
-        private val APPCONFIG_BUNDLE =
-            (
-                "504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" +
-                    "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" +
-                    "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" +
-                    "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" +
-                    "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" +
-                    "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" +
-                    "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" +
-                    "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" +
-                    "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" +
-                    "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" +
-                    "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" +
-                    "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" +
-                    "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" +
-                    "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" +
-                    "0200020070000000ae0100000000"
-                ).decodeHex()
-        private val APPCONFIG_RAW =
-            (
-                "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
-                    "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
-                    "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
-                    "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
-                    "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
-                    "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
-                ).decodeHex()
+            source.clear()
+        }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a4ba54e99f2a9ad6630597cbfcec4f3601a91021
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt
@@ -0,0 +1,144 @@
+package de.rki.coronawarnapp.appconfig
+
+import de.rki.coronawarnapp.appconfig.download.AppConfigServer
+import de.rki.coronawarnapp.appconfig.download.AppConfigStorage
+import de.rki.coronawarnapp.appconfig.download.ConfigDownload
+import de.rki.coronawarnapp.appconfig.mapping.ConfigParser
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifyOrder
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import okio.ByteString.Companion.decodeHex
+import org.joda.time.Duration
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+import testhelpers.TestDispatcherProvider
+import testhelpers.coroutines.runBlockingTest2
+import java.io.File
+import java.io.IOException
+
+class AppConfigSourceTest : BaseIOTest() {
+
+    @MockK lateinit var configServer: AppConfigServer
+    @MockK lateinit var configStorage: AppConfigStorage
+    @MockK lateinit var configParser: ConfigParser
+    @MockK lateinit var configData: ConfigData
+    @MockK lateinit var timeStamper: TimeStamper
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+
+    private var testConfigDownload = ConfigDownload(
+        rawData = APPCONFIG_RAW,
+        serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
+        localOffset = Duration.standardHours(1)
+    )
+
+    private var mockConfigStorage: ConfigDownload? = null
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        coEvery { configStorage.getStoredConfig() } answers { mockConfigStorage }
+        coEvery { configStorage.setStoredConfig(any()) } answers {
+            mockConfigStorage = arg(0)
+        }
+
+        coEvery { configServer.downloadAppConfig() } returns testConfigDownload
+        every { configServer.clearCache() } just Runs
+
+        every { configParser.parse(APPCONFIG_RAW) } returns configData
+
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createInstance() = AppConfigSource(
+        server = configServer,
+        storage = configStorage,
+        parser = configParser,
+        dispatcherProvider = TestDispatcherProvider
+    )
+
+    @Test
+    fun `successful download stores new config`() = runBlockingTest2(ignoreActive = true) {
+        val source = createInstance()
+        source.retrieveConfig() shouldBe DefaultConfigData(
+            serverTime = mockConfigStorage!!.serverTime,
+            localOffset = mockConfigStorage!!.localOffset,
+            mappedConfig = configData,
+            isFallback = false
+        )
+
+        mockConfigStorage shouldBe testConfigDownload
+
+        coVerify { configStorage.setStoredConfig(testConfigDownload) }
+    }
+
+    @Test
+    fun `fallback to last config if download fails`() = runBlockingTest2(ignoreActive = true) {
+        mockConfigStorage = testConfigDownload
+        coEvery { configServer.downloadAppConfig() } throws Exception()
+
+        createInstance().retrieveConfig() shouldBe DefaultConfigData(
+            serverTime = mockConfigStorage!!.serverTime,
+            localOffset = mockConfigStorage!!.localOffset,
+            mappedConfig = configData,
+            isFallback = true
+        )
+    }
+
+    @Test
+    fun `failed download doesn't overwrite valid config`() = runBlockingTest2(ignoreActive = true) {
+        mockConfigStorage = testConfigDownload
+        coEvery { configServer.downloadAppConfig() } throws IOException()
+
+        createInstance().retrieveConfig()
+
+        mockConfigStorage shouldBe testConfigDownload
+
+        coVerify(exactly = 0) { configStorage.setStoredConfig(any()) }
+    }
+
+    @Test
+    fun `clear clears caches`() = runBlockingTest2(ignoreActive = true) {
+        val instance = createInstance()
+
+        instance.clear()
+
+        advanceUntilIdle()
+
+        coVerifyOrder {
+            configStorage.setStoredConfig(null)
+            configServer.clearCache()
+        }
+    }
+
+    companion object {
+        private val APPCONFIG_RAW = (
+            "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+                "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+                "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+                "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+                "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+                "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+            ).decodeHex().toByteArray()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt
deleted file mode 100644
index aea45f221b950b4b2ac503079adb1c0691004e64..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package de.rki.coronawarnapp.appconfig
-
-import android.content.Context
-import io.kotest.matchers.shouldBe
-import io.mockk.MockKAnnotations
-import io.mockk.clearAllMocks
-import io.mockk.every
-import io.mockk.impl.annotations.MockK
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
-import testhelpers.BaseIOTest
-import java.io.File
-
-class AppConfigStorageTest : BaseIOTest() {
-
-    @MockK private lateinit var context: Context
-
-    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
-    private val privateFiles = File(testDir, "files")
-    private val storageDir = File(privateFiles, "appconfig_storage")
-    private val configPath = File(storageDir, "appconfig")
-    private val testByteArray = "The Cake Is A Lie".toByteArray()
-
-    @BeforeEach
-    fun setup() {
-        MockKAnnotations.init(this)
-        every { context.filesDir } returns privateFiles
-    }
-
-    @AfterEach
-    fun teardown() {
-        clearAllMocks()
-        testDir.deleteRecursively()
-    }
-
-    private fun createStorage() = AppConfigStorage(context)
-
-    @Test
-    suspend fun `config availability is determined by file existence and min size`() {
-        storageDir.mkdirs()
-        val storage = createStorage()
-        storage.isAppConfigAvailable() shouldBe false
-        configPath.createNewFile()
-        storage.isAppConfigAvailable() shouldBe false
-
-        configPath.writeBytes(ByteArray(128) { 1 })
-        storage.isAppConfigAvailable() shouldBe false
-
-        configPath.writeBytes(ByteArray(129) { 1 })
-        storage.isAppConfigAvailable() shouldBe true
-    }
-
-    @Test
-    suspend fun `simple read and write config`() {
-        configPath.exists() shouldBe false
-        val storage = createStorage()
-        configPath.exists() shouldBe false
-
-        storage.setAppConfigRaw(testByteArray)
-
-        configPath.exists() shouldBe true
-        configPath.readBytes() shouldBe testByteArray
-
-        storage.getAppConfigRaw() shouldBe testByteArray
-    }
-
-    @Test
-    suspend fun `nulling and overwriting`() {
-        val storage = createStorage()
-        configPath.exists() shouldBe false
-
-        storage.getAppConfigRaw() shouldBe null
-        storage.setAppConfigRaw(null)
-        configPath.exists() shouldBe false
-
-        storage.getAppConfigRaw() shouldBe null
-        storage.setAppConfigRaw(testByteArray)
-        storage.getAppConfigRaw() shouldBe testByteArray
-        configPath.exists() shouldBe true
-        configPath.readBytes() shouldBe testByteArray
-
-        storage.setAppConfigRaw(null)
-        storage.getAppConfigRaw() shouldBe null
-        configPath.exists() shouldBe false
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt
similarity index 83%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt
index 2e2d2cb5a31ecd7bfd98cc95dc61793c22341a88..da1ab2f7d7e9dd50d4286a5f55827b76f55e2ad9 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt
@@ -1,6 +1,7 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import android.content.Context
+import de.rki.coronawarnapp.appconfig.AppConfigModule
 import de.rki.coronawarnapp.environment.download.DownloadCDNModule
 import de.rki.coronawarnapp.http.HttpModule
 import io.kotest.matchers.shouldBe
@@ -8,7 +9,6 @@ import io.mockk.MockKAnnotations
 import io.mockk.clearAllMocks
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
-import io.mockk.mockk
 import kotlinx.coroutines.runBlocking
 import okhttp3.ConnectionSpec
 import okhttp3.mockwebserver.MockResponse
@@ -32,7 +32,6 @@ class AppConfigApiTest : BaseIOTest() {
     private val cacheFiles = File(testDir, "cache")
     private val cacheDir = File(cacheFiles, "http_app-config")
 
-
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
@@ -69,7 +68,7 @@ class AppConfigApiTest : BaseIOTest() {
             cache = cache
         )
     }
-    
+
     @Test
     fun `application config download`() {
         val api = createAPI()
@@ -77,7 +76,9 @@ class AppConfigApiTest : BaseIOTest() {
         webServer.enqueue(MockResponse().setBody("~appconfig"))
 
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
 
         val request = webServer.takeRequest(5, TimeUnit.SECONDS)!!
@@ -96,7 +97,9 @@ class AppConfigApiTest : BaseIOTest() {
 
         webServer.enqueue(configResponse)
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
         cacheDir.exists() shouldBe true
         cacheDir.listFiles()!!.size shouldBe 3
@@ -108,7 +111,9 @@ class AppConfigApiTest : BaseIOTest() {
 
         webServer.enqueue(configResponse)
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
         cacheDir.exists() shouldBe true
         cacheDir.listFiles()!!.size shouldBe 3
@@ -119,7 +124,9 @@ class AppConfigApiTest : BaseIOTest() {
 
         webServer.enqueue(configResponse)
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
         cacheDir.exists() shouldBe true
         cacheDir.listFiles()!!.size shouldBe 3
@@ -141,7 +148,9 @@ class AppConfigApiTest : BaseIOTest() {
 
         webServer.enqueue(configResponse)
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
         cacheDir.exists() shouldBe true
         cacheDir.listFiles()!!.size shouldBe 3
@@ -154,7 +163,9 @@ class AppConfigApiTest : BaseIOTest() {
         webServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_DURING_REQUEST_BODY))
 
         runBlocking {
-            api.getApplicationConfiguration("DE").string() shouldBe "~appconfig"
+            api.getApplicationConfiguration("DE").apply {
+                body()!!.string() shouldBe "~appconfig"
+            }
         }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt
similarity index 92%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt
index fdae6dd05d6b673b95c5b49a3a5edf39d76c9124..eae101cd8e6281f82ba2658056819e564473fbe0 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt
@@ -1,6 +1,7 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.download
 
 import android.content.Context
+import de.rki.coronawarnapp.appconfig.AppConfigModule
 import io.kotest.assertions.throwables.shouldNotThrowAny
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..eda32b98ef41d9a00ff05020c9e24f3551e5cbd8
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt
@@ -0,0 +1,199 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.security.VerificationKeys
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.spyk
+import io.mockk.verify
+import kotlinx.coroutines.test.runBlockingTest
+import okhttp3.Headers
+import okhttp3.ResponseBody.Companion.toResponseBody
+import okio.ByteString.Companion.decodeHex
+import org.joda.time.Duration
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import retrofit2.Response
+import testhelpers.BaseIOTest
+import java.io.File
+
+class AppConfigServerTest : BaseIOTest() {
+
+    @MockK lateinit var api: AppConfigApiV1
+    @MockK lateinit var verificationKeys: VerificationKeys
+    @MockK lateinit var timeStamper: TimeStamper
+    private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
+
+    private val defaultHomeCountry = LocationCode("DE")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        testDir.mkdirs()
+        testDir.exists() shouldBe true
+
+        every { timeStamper.nowUTC } returns Instant.ofEpochMilli(123456789)
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns false
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createInstance(homeCountry: LocationCode = defaultHomeCountry) = AppConfigServer(
+        api = { api },
+        verificationKeys = verificationKeys,
+        homeCountry = homeCountry,
+        cache = mockk(),
+        timeStamper = timeStamper
+    )
+
+    @Test
+    fun `application config download`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody(),
+            Headers.headersOf("Date", "Tue, 03 Nov 2020 08:46:03 GMT")
+        )
+
+        val downloadServer = createInstance()
+
+        val configDownload = downloadServer.downloadAppConfig()
+        configDownload shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.parse("2020-11-03T08:46:03.000Z"),
+            localOffset = Duration(
+                Instant.parse("2020-11-03T08:46:03.000Z"),
+                Instant.ofEpochMilli(123456789)
+            )
+        )
+
+        verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) }
+    }
+
+    @Test
+    fun `application config data is faulty`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            "123ABC".decodeHex().toResponseBody()
+        )
+
+        val downloadServer = createInstance()
+
+        shouldThrow<ApplicationConfigurationInvalidException> {
+            downloadServer.downloadAppConfig()
+        }
+    }
+
+    @Test
+    fun `application config verification fails`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody()
+        )
+        every { verificationKeys.hasInvalidSignature(any(), any()) } returns true
+
+        val downloadServer = createInstance()
+
+        shouldThrow<ApplicationConfigurationCorruptException> {
+            downloadServer.downloadAppConfig()
+        }
+    }
+
+    @Test
+    fun `missing server date leads to local time fallback`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody()
+        )
+
+        val downloadServer = createInstance()
+
+        val configDownload = downloadServer.downloadAppConfig()
+        configDownload shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.ofEpochMilli(123456789),
+            localOffset = Duration.ZERO
+        )
+    }
+
+    @Test
+    fun `local offset is the difference between server time and local time`() = runBlockingTest {
+        coEvery { api.getApplicationConfiguration("DE") } returns Response.success(
+            APPCONFIG_BUNDLE.toResponseBody(),
+            Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT")
+        )
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
+
+        val downloadServer = createInstance()
+
+        downloadServer.downloadAppConfig() shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.parse("2020-11-03T06:35:16.000Z"),
+            localOffset = Duration.standardHours(-1)
+        )
+    }
+
+    @Test
+    fun `local offset uses cached timestamps for cached responses`() = runBlockingTest {
+        val response = spyk(
+            Response.success(
+                APPCONFIG_BUNDLE.toResponseBody(),
+                Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT")
+            )
+        )
+
+        val mockCacheResponse = mockk<okhttp3.Response>()
+        // The cached one is 2 hours before our local time, so the offset will be -2 hours
+        every { mockCacheResponse.sentRequestAtMillis } returns Instant.parse("2020-11-03T04:35:16.000Z").millis
+        every { response.raw().cacheResponse } returns mockCacheResponse
+
+        coEvery { api.getApplicationConfiguration("DE") } returns response
+        every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z")
+
+        val downloadServer = createInstance()
+
+        downloadServer.downloadAppConfig() shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.parse("2020-11-03T06:35:16.000Z"),
+            localOffset = Duration.standardHours(-2)
+        )
+    }
+
+    companion object {
+        private val APPCONFIG_BUNDLE =
+            (
+                "504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" +
+                    "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" +
+                    "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" +
+                    "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" +
+                    "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" +
+                    "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" +
+                    "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" +
+                    "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" +
+                    "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" +
+                    "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" +
+                    "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" +
+                    "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" +
+                    "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" +
+                    "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" +
+                    "0200020070000000ae0100000000"
+                ).decodeHex()
+        private val APPCONFIG_RAW =
+            (
+                "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+                    "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+                    "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+                    "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+                    "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+                    "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+                ).decodeHex().toByteArray()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fa15cab904896ee99211904a97fbf2411fbe4621
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt
@@ -0,0 +1,149 @@
+package de.rki.coronawarnapp.appconfig.download
+
+import android.content.Context
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.serialization.SerializationModule
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.test.runBlockingTest
+import okio.ByteString.Companion.decodeHex
+import okio.ByteString.Companion.toByteString
+import org.joda.time.Duration
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+import testhelpers.extensions.toComparableJson
+import java.io.File
+
+class AppConfigStorageTest : BaseIOTest() {
+
+    @MockK private lateinit var context: Context
+    @MockK private lateinit var timeStamper: TimeStamper
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val privateFiles = File(testDir, "files")
+    private val storageDir = File(privateFiles, "appconfig_storage")
+    private val legacyConfigPath = File(storageDir, "appconfig")
+    private val configPath = File(storageDir, "appconfig.json")
+
+    private val testConfigDownload = ConfigDownload(
+        rawData = APPCONFIG_RAW,
+        serverTime = Instant.parse("2020-11-03T05:35:16.000Z"),
+        localOffset = Duration.standardHours(1)
+    )
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { context.filesDir } returns privateFiles
+
+        every { timeStamper.nowUTC } returns Instant.ofEpochMilli(1234)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createStorage() = AppConfigStorage(
+        context = context,
+        timeStamper = timeStamper,
+        baseGson = SerializationModule().baseGson()
+    )
+
+    @Test
+    fun `simple read and write config`() = runBlockingTest {
+        configPath.exists() shouldBe false
+        val storage = createStorage()
+        configPath.exists() shouldBe false
+
+        storage.setStoredConfig(testConfigDownload)
+
+        configPath.exists() shouldBe true
+        configPath.readText().toComparableJson() shouldBe """
+            {
+                "rawData": "$APPCONFIG_BASE64",
+                "serverTime": 1604381716000,
+                "localOffset": 3600000
+            }
+        """.toComparableJson()
+
+        storage.getStoredConfig() shouldBe testConfigDownload
+    }
+
+    @Test
+    fun `nulling and overwriting`() = runBlockingTest {
+        val storage = createStorage()
+        configPath.exists() shouldBe false
+
+        storage.getStoredConfig() shouldBe null
+        storage.setStoredConfig(null)
+        configPath.exists() shouldBe false
+
+        storage.getStoredConfig() shouldBe null
+        storage.setStoredConfig(testConfigDownload)
+        storage.getStoredConfig() shouldBe testConfigDownload
+
+        configPath.exists() shouldBe true
+        configPath.readText().toComparableJson() shouldBe """
+            {
+                "rawData": "$APPCONFIG_BASE64",
+                "serverTime": 1604381716000,
+                "localOffset": 3600000
+            }
+        """.toComparableJson()
+
+        storage.setStoredConfig(null)
+        storage.getStoredConfig() shouldBe null
+        configPath.exists() shouldBe false
+    }
+
+    @Test
+    fun `if no fallback exists, but we have a legacy config, use that`() = runBlockingTest {
+        configPath.exists() shouldBe false
+        legacyConfigPath.exists() shouldBe false
+
+        legacyConfigPath.parentFile!!.mkdirs()
+        legacyConfigPath.writeBytes(APPCONFIG_RAW)
+
+        val storage = createStorage()
+
+        storage.getStoredConfig() shouldBe ConfigDownload(
+            rawData = APPCONFIG_RAW,
+            serverTime = Instant.ofEpochMilli(1234),
+            localOffset = Duration.ZERO
+        )
+    }
+
+    @Test
+    fun `writing a new config deletes any legacy configsconfig`() = runBlockingTest {
+        legacyConfigPath.parentFile!!.mkdirs()
+        legacyConfigPath.writeBytes(APPCONFIG_RAW)
+        configPath.exists() shouldBe false
+
+        val storage = createStorage()
+        storage.setStoredConfig(testConfigDownload)
+
+        legacyConfigPath.exists() shouldBe false
+        configPath.exists() shouldBe true
+    }
+
+    companion object {
+        private val APPCONFIG_RAW = (
+            "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+                "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+                "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+                "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+                "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+                "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+            ).decodeHex().toByteArray()
+
+        private val APPCONFIG_BASE64 = APPCONFIG_RAW.toByteString().base64()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt
similarity index 93%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt
index d817a316743f01bdfd03b5966a627f93af82ce5a..ad61b6ac73aa40167bddf07bb1f9b2578d879247 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.appconfig
+package de.rki.coronawarnapp.appconfig.mapping
 
 import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
 import io.kotest.matchers.shouldBe
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2ef853f468f8e5b58bf7db38802a617b5c0d76b5
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt
@@ -0,0 +1,45 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class CWAConfigMapperTest : BaseTest() {
+
+    private fun createInstance() = CWAConfigMapper()
+
+    @Test
+    fun `simple creation`() {
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .addAllSupportedCountries(listOf("DE", "NL"))
+            .build()
+        createInstance().map(rawConfig).apply {
+            this.appVersion shouldBe rawConfig.appVersion
+            this.supportedCountries shouldBe listOf("DE", "NL")
+        }
+    }
+
+    @Test
+    fun `invalid supported countries are filtered out`() {
+        // Could happen due to protobuf scheme missmatch
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .addAllSupportedCountries(listOf("plausible deniability"))
+            .build()
+        createInstance().map(rawConfig).apply {
+            this.appVersion shouldBe rawConfig.appVersion
+            this.supportedCountries shouldBe emptyList()
+        }
+    }
+
+    @Test
+    fun `if supportedCountryList is empty, we do not insert DE as fallback`() {
+        // Because the UI requires this to detect when to show alternative UI elements
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .build()
+        createInstance().map(rawConfig).apply {
+            this.appVersion shouldBe rawConfig.appVersion
+            this.supportedCountries shouldBe emptyList()
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..18ee07f72f83cfdbfccae776f6df5edc21a23083
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt
@@ -0,0 +1,70 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.appconfig.CWAConfig
+import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.appconfig.RiskCalculationConfig
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import io.mockk.verifySequence
+import okio.ByteString.Companion.decodeHex
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class ConfigParserTest : BaseTest() {
+    @MockK lateinit var cwaConfigMapper: CWAConfig.Mapper
+    @MockK lateinit var keyDownloadConfigMapper: KeyDownloadConfig.Mapper
+    @MockK lateinit var exposureDetectionConfigMapper: ExposureDetectionConfig.Mapper
+    @MockK lateinit var riskCalculationConfigMapper: RiskCalculationConfig.Mapper
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { cwaConfigMapper.map(any()) } returns mockk()
+        every { keyDownloadConfigMapper.map(any()) } returns mockk()
+        every { exposureDetectionConfigMapper.map(any()) } returns mockk()
+        every { riskCalculationConfigMapper.map(any()) } returns mockk()
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance(): ConfigParser = ConfigParser(
+        cwaConfigMapper = cwaConfigMapper,
+        keyDownloadConfigMapper = keyDownloadConfigMapper,
+        exposureDetectionConfigMapper = exposureDetectionConfigMapper,
+        riskCalculationConfigMapper = riskCalculationConfigMapper
+    )
+
+    @Test
+    fun `simple init`() {
+        createInstance().parse(APPCONFIG_RAW.toByteArray()).apply {
+
+            verifySequence {
+                cwaConfigMapper.map(any())
+                keyDownloadConfigMapper.map(any())
+                exposureDetectionConfigMapper.map(any())
+                riskCalculationConfigMapper.map(any())
+            }
+        }
+    }
+
+    companion object {
+        private val APPCONFIG_RAW = (
+            "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+                "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+                "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+                "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+                "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+                "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+            ).decodeHex()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d8ce3fd278925df0151f609f257866f3db3371b9
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt
@@ -0,0 +1,19 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class DownloadConfigMapperTest : BaseTest() {
+    private fun createInstance() = DownloadConfigMapper()
+
+    @Test
+    fun `simple creation`() {
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .build()
+        createInstance().map(rawConfig).apply {
+            keyDownloadParameters shouldBe rawConfig.androidKeyDownloadParameters
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2552a72dc3554e0879ad28a1bfaf2da77d2dca15
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt
@@ -0,0 +1,22 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class ExposureDetectionConfigMapperTest : BaseTest() {
+
+    private fun createInstance() = ExposureDetectionConfigMapper()
+
+    @Test
+    fun `simple creation`() {
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .setMinRiskScore(1)
+            .build()
+        createInstance().map(rawConfig).apply {
+            exposureDetectionConfiguration shouldBe rawConfig.mapRiskScoreToExposureConfiguration()
+            exposureDetectionParameters shouldBe rawConfig.androidExposureDetectionParameters
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e0cf0e3c09a213d3f214cb292efca309a4824e67
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt
@@ -0,0 +1,22 @@
+package de.rki.coronawarnapp.appconfig.mapping
+
+import de.rki.coronawarnapp.server.protocols.internal.AppConfig
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class RiskCalculationConfigMapperTest : BaseTest() {
+
+    private fun createInstance() = RiskCalculationConfigMapper()
+
+    @Test
+    fun `simple creation`() {
+        val rawConfig = AppConfig.ApplicationConfiguration.newBuilder()
+            .build()
+        createInstance().map(rawConfig).apply {
+            this.attenuationDuration shouldBe rawConfig.attenuationDuration
+            this.minRiskScore shouldBe rawConfig.minRiskScore
+            this.riskScoreClasses shouldBe rawConfig.riskScoreClasses
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/DefaultPlaybookTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/http/playbook/DefaultPlaybookTest.kt
index 0b12d642f1af921448573e95ea908ac221fbb8e1..7a710623431ea78e0b7e1d380754985a0145c980 100644
--- 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
@@ -85,7 +85,7 @@ class DefaultPlaybookTest : BaseTest() {
     fun `submission matches request pattern`(): Unit = runBlocking {
         coEvery { verificationServer.retrieveTan(any()) } returns "tan"
 
-        createPlaybook().submission(
+        createPlaybook().submit(
             Playbook.SubmissionData(
                 registrationToken = "token",
                 temporaryExposureKeys = listOf(),
@@ -107,7 +107,7 @@ class DefaultPlaybookTest : BaseTest() {
         coEvery { verificationServer.retrieveTan(any()) } throws TestException()
 
         shouldThrow<TestException> {
-            createPlaybook().submission(
+            createPlaybook().submit(
                 Playbook.SubmissionData(
                     registrationToken = "token",
                     temporaryExposureKeys = listOf(),
@@ -221,7 +221,7 @@ class DefaultPlaybookTest : BaseTest() {
         coEvery { verificationServer.retrieveTan(any()) } throws TestException()
 
         shouldThrow<TestException> {
-            createPlaybook().submission(
+            createPlaybook().submit(
                 Playbook.SubmissionData(
                     registrationToken = "token",
                     temporaryExposureKeys = listOf(),
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/MainActivityViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/MainActivityViewModelTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e2fd710f02003cc72502096e33e44a24dbf0002f
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/MainActivityViewModelTest.kt
@@ -0,0 +1,69 @@
+package de.rki.coronawarnapp.main
+
+import de.rki.coronawarnapp.environment.EnvironmentSetup
+import de.rki.coronawarnapp.ui.main.MainActivityViewModel
+import de.rki.coronawarnapp.util.CWADebug
+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.mockkObject
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import testhelpers.BaseTest
+import testhelpers.TestDispatcherProvider
+import testhelpers.extensions.CoroutinesTestExtension
+import testhelpers.extensions.InstantExecutorExtension
+
+@ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class)
+class MainActivityViewModelTest : BaseTest() {
+
+    @MockK lateinit var environmentSetup: EnvironmentSetup
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        mockkObject(CWADebug)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance(): MainActivityViewModel = MainActivityViewModel(
+        dispatcherProvider = TestDispatcherProvider,
+        environmentSetup = environmentSetup
+    )
+
+    @Test
+    fun `environment toast is visible test environments`() {
+        every { CWADebug.isDeviceForTestersBuild } returns true
+        every { environmentSetup.currentEnvironment } returns EnvironmentSetup.Type.DEV
+
+        val vm = createInstance()
+        vm.showEnvironmentHint.value shouldBe EnvironmentSetup.Type.DEV.rawKey
+    }
+
+    @Test
+    fun `environment toast is only visible in deviceForTesters flavor`() {
+        every { CWADebug.isDeviceForTestersBuild } returns false
+        every { environmentSetup.currentEnvironment } returns EnvironmentSetup.Type.DEV
+
+        val vm = createInstance()
+        vm.showEnvironmentHint.value shouldBe null
+    }
+
+    @Test
+    fun `environment toast is not visible in production`() {
+        every { CWADebug.isDeviceForTestersBuild } returns true
+        every { environmentSetup.currentEnvironment } returns EnvironmentSetup.Type.PRODUCTION
+
+        val vm = createInstance()
+        vm.showEnvironmentHint.value shouldBe null
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
index ccd2091de7f0e3dbfeff98aad50e80b312bd739b..91e19f01dea1acbf3fe3d8c71e97398ecdcc1a2b 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
@@ -11,7 +11,6 @@ import de.rki.coronawarnapp.ui.main.home.TracingHeaderState
 import de.rki.coronawarnapp.ui.tracing.card.TracingCardState
 import de.rki.coronawarnapp.ui.tracing.card.TracingCardStateProvider
 import de.rki.coronawarnapp.ui.viewmodel.SettingsViewModel
-import de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel
 import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
@@ -39,7 +38,6 @@ class HomeFragmentViewModelTest : BaseTest() {
     @MockK lateinit var context: Context
     @MockK lateinit var errorResetTool: EncryptionErrorResetTool
     @MockK lateinit var settingsViewModel: SettingsViewModel
-    @MockK lateinit var submissionViewModel: SubmissionViewModel
     @MockK lateinit var tracingCardStateProvider: TracingCardStateProvider
     @MockK lateinit var submissionCardsStateProvider: SubmissionCardsStateProvider
     @MockK lateinit var tracingRepository: TracingRepository
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
index c9ecb45bccb3930a85efa6bdd454be17d4459bfd..541eedf627670fbf71a60a819a15cf71e661771d 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
@@ -158,7 +158,7 @@ class ENFClientTest : BaseTest() {
                 mapOf(
                     "1" to Calculation(
                         identifier = "1",
-                        startedAt = Instant.EPOCH.plus(5),
+                        startedAt = Instant.EPOCH.plus(5)
                     ),
                     "2" to Calculation(
                         identifier = "2",
@@ -176,7 +176,7 @@ class ENFClientTest : BaseTest() {
                 mapOf(
                     "1" to Calculation(
                         identifier = "1",
-                        startedAt = Instant.EPOCH,
+                        startedAt = Instant.EPOCH
                     ),
                     "2" to Calculation(
                         identifier = "2",
@@ -251,7 +251,7 @@ class ENFClientTest : BaseTest() {
                     "2" to Calculation(
                         identifier = "2",
                         result = Calculation.Result.UPDATED_STATE,
-                        startedAt = Instant.EPOCH,
+                        startedAt = Instant.EPOCH
                     ),
                     "3" to Calculation(
                         identifier = "3",
@@ -266,4 +266,3 @@ class ENFClientTest : BaseTest() {
         }
     }
 }
-
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt
index 5b1be36e8bec29072f2aa32140cbc1a4a091214b..4a7c2a019b42c627cf506154457c74be5bf5b1b4 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt
@@ -2,8 +2,8 @@ package de.rki.coronawarnapp.nearby.modules.calculationtracker
 
 import android.content.Context
 import com.google.gson.GsonBuilder
-import de.rki.coronawarnapp.util.gson.fromJson
 import de.rki.coronawarnapp.util.serialization.SerializationModule
+import de.rki.coronawarnapp.util.serialization.fromJson
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
 import io.mockk.clearAllMocks
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt
index 5ae6cad6afb8e39c1389f8f668d1556c34f29ed1..976ba6a4b0cc0015cc0636c19c747fac90921fee 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt
@@ -61,7 +61,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `data is restored from storage`() = runBlockingTest2(permanentJobs = true) {
+    fun `data is restored from storage`() = runBlockingTest2(ignoreActive = true) {
         val calcData = Calculation(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH
@@ -73,7 +73,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `tracking a new calculation`() = runBlockingTest2(permanentJobs = true) {
+    fun `tracking a new calculation`() = runBlockingTest2(ignoreActive = true) {
         createInstance(scope = this).apply {
             val expectedIdentifier = UUID.randomUUID().toString()
             trackNewCalaculation(expectedIdentifier)
@@ -101,7 +101,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `finish an existing calcluation`() = runBlockingTest2(permanentJobs = true) {
+    fun `finish an existing calcluation`() = runBlockingTest2(ignoreActive = true) {
         val calcData = Calculation(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH
@@ -116,7 +116,6 @@ class DefaultCalculationTrackerTest : BaseTest() {
             )
         }
 
-
         every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1)
 
         createInstance(scope = this).apply {
@@ -137,7 +136,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `a late calculation overwrites timeout state`() = runBlockingTest2(permanentJobs = true) {
+    fun `a late calculation overwrites timeout state`() = runBlockingTest2(ignoreActive = true) {
         val calcData = Calculation(
             identifier = UUID.randomUUID().toString(),
             startedAt = Instant.EPOCH,
@@ -166,7 +165,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `no more than 10 calcluations are tracked`() = runBlockingTest2(permanentJobs = true) {
+    fun `no more than 10 calcluations are tracked`() = runBlockingTest2(ignoreActive = true) {
         val calcData = (1..15L).map {
             val calcData = Calculation(
                 identifier = "$it",
@@ -190,7 +189,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
     }
 
     @Test
-    fun `15 minute timeout on ongoing calcs`() = runBlockingTest2(permanentJobs = true) {
+    fun `15 minute timeout on ongoing calcs`() = runBlockingTest2(ignoreActive = true) {
         every { timeStamper.nowUTC } returns Instant.EPOCH
             .plus(Duration.standardMinutes(15))
             .plus(2)
@@ -233,7 +232,7 @@ class DefaultCalculationTrackerTest : BaseTest() {
             timeoutIgnoresFinishedCalcs.identifier to timeoutIgnoresFinishedCalcs,
             timeoutRunningOnEdge.identifier to timeoutRunningOnEdge,
             noTimeoutCalcRunning.identifier to noTimeoutCalcRunning,
-            noTimeOutCalcFinished.identifier to noTimeOutCalcFinished,
+            noTimeOutCalcFinished.identifier to noTimeOutCalcFinished
         )
 
         coEvery { storage.load() } returns calcData
@@ -256,7 +255,6 @@ class DefaultCalculationTrackerTest : BaseTest() {
 
                 this["3"] shouldBe timeoutRunningOnEdge
 
-
                 this["4"] shouldBe noTimeoutCalcRunning
                 this["5"] shouldBe noTimeOutCalcFinished
             }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/DefaultRiskScoreAnalysisTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/DefaultRiskScoreAnalysisTest.kt
deleted file mode 100644
index dd2cac3e9e09e3585da83bddcf86fe3e63d1ae62..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/DefaultRiskScoreAnalysisTest.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package de.rki.coronawarnapp.risk
-
-import org.junit.Assert
-import org.junit.Test
-
-class DefaultRiskScoreAnalysisTest {
-
-    @Test
-    fun test_withinDefinedLevelThreshold() {
-        val instance = DefaultRiskScoreAnalysis()
-
-        // positive
-        Assert.assertTrue(instance.withinDefinedLevelThreshold(2.0, 1, 3))
-
-        // negative
-        Assert.assertFalse(instance.withinDefinedLevelThreshold(4.0, 1, 3))
-
-        // edge cases
-        Assert.assertTrue(instance.withinDefinedLevelThreshold(1.0, 1, 3))
-        Assert.assertTrue(instance.withinDefinedLevelThreshold(3.0, 1, 3))
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelCalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelsTest.kt
similarity index 69%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelCalculationTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelsTest.kt
index a01f0dc77c3c50f53316f709c46672c0aee3ea12..845722a2c23174f01becc23d183dc78053e30226 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelCalculationTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelsTest.kt
@@ -1,16 +1,47 @@
 package de.rki.coronawarnapp.risk
 
 import com.google.android.gms.nearby.exposurenotification.ExposureSummary
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.impl.annotations.MockK
 import junit.framework.TestCase.assertEquals
+import org.junit.Before
 import org.junit.Test
+import testhelpers.BaseTest
 
-class RiskLevelCalculationTest {
+class RiskLevelsTest : BaseTest() {
+
+    @MockK lateinit var appConfigProvider: AppConfigProvider
+    private lateinit var riskLevels: DefaultRiskLevels
+
+    @Before
+    fun setUp() {
+        MockKAnnotations.init(this)
+        riskLevels = DefaultRiskLevels(appConfigProvider)
+    }
+
+    @Test
+    fun `is within defined level threshold`() {
+        riskLevels.withinDefinedLevelThreshold(2.0, 1, 3) shouldBe true
+    }
+
+    @Test
+    fun `is not within defined level threshold`() {
+        riskLevels.withinDefinedLevelThreshold(4.0, 1, 3) shouldBe false
+    }
+
+    @Test
+    fun `is within defined level threshold - edge cases`() {
+        riskLevels.withinDefinedLevelThreshold(1.0, 1, 3) shouldBe true
+        riskLevels.withinDefinedLevelThreshold(3.0, 1, 3) shouldBe true
+    }
 
     @Test
     fun calculateRiskScoreZero() {
         val riskScore =
-            DefaultRiskLevelCalculation().calculateRiskScore(
+            riskLevels.calculateRiskScore(
                 buildAttenuationDuration(0.5, 0.5, 1.0),
                 buildSummary(0, 0, 0, 0)
             )
@@ -21,7 +52,7 @@ class RiskLevelCalculationTest {
     @Test
     fun calculateRiskScoreLow() {
         val riskScore =
-            DefaultRiskLevelCalculation().calculateRiskScore(
+            riskLevels.calculateRiskScore(
                 buildAttenuationDuration(0.5, 0.5, 1.0),
                 buildSummary(156, 10, 10, 10)
             )
@@ -32,7 +63,7 @@ class RiskLevelCalculationTest {
     @Test
     fun calculateRiskScoreMid() {
         val riskScore =
-            DefaultRiskLevelCalculation().calculateRiskScore(
+            riskLevels.calculateRiskScore(
                 buildAttenuationDuration(0.5, 0.5, 1.0),
                 buildSummary(256, 15, 15, 15)
             )
@@ -43,7 +74,7 @@ class RiskLevelCalculationTest {
     @Test
     fun calculateRiskScoreHigh() {
         val riskScore =
-            DefaultRiskLevelCalculation().calculateRiskScore(
+            riskLevels.calculateRiskScore(
                 buildAttenuationDuration(0.5, 0.5, 1.0),
                 buildSummary(512, 30, 30, 30)
             )
@@ -54,7 +85,7 @@ class RiskLevelCalculationTest {
     @Test
     fun calculateRiskScoreMax() {
         val riskScore =
-            DefaultRiskLevelCalculation().calculateRiskScore(
+            riskLevels.calculateRiskScore(
                 buildAttenuationDuration(0.5, 0.5, 1.0),
                 buildSummary(4096, 30, 30, 30)
             )
@@ -65,7 +96,7 @@ class RiskLevelCalculationTest {
     @Test
     fun calculateRiskScoreCapped() {
         val riskScore =
-            DefaultRiskLevelCalculation().calculateRiskScore(
+            riskLevels.calculateRiskScore(
                 buildAttenuationDuration(0.5, 0.5, 1.0),
                 buildSummary(4096, 45, 45, 45)
             )
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt
index ee4cd09b339d00404b05056550bb3403939be060..e94de291f5bca96a1adefb5d57eade3d92760167 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt
@@ -9,7 +9,12 @@ import org.junit.Before
 import org.junit.Test
 
 class ScanResultTest {
-    private val guid = "123456-12345678-1234-4DA7-B166-B86D85475064"
+    private val guidUpperCase = "123456-12345678-1234-4DA7-B166-B86D85475064"
+    private val guidLowerCase = "123456-12345678-1234-4da7-b166-b86d85475064"
+    private val guidMixedCase = "123456-12345678-1234-4dA7-b166-B86d85475064"
+    private val localhostUpperCase = "HTTPS://LOCALHOST/?"
+    private val localhostLowerCase = "https://localhost/?"
+    private val localhostMixedCase = "https://LOCALHOST/?"
 
     @MockK
     private lateinit var scanResult: QRScanResult
@@ -21,33 +26,72 @@ class ScanResultTest {
         every { scanResult.isValid } returns false
     }
 
+    private fun buildQRCodeCases(prefixString: String, guid: String, conditionToMatch: Boolean) {
+        scanResult = QRScanResult("$prefixString$guid")
+        scanResult.isValid shouldBe conditionToMatch
+    }
+
     @Test
     fun containsValidGUID() {
         // valid test
-        scanResult = QRScanResult("https://localhost/?$guid")
-        scanResult.isValid shouldBe true
+
+        buildQRCodeCases(localhostUpperCase, guidUpperCase, true)
+        buildQRCodeCases(localhostLowerCase, guidUpperCase, true)
+        buildQRCodeCases(localhostMixedCase, guidUpperCase, true)
+
+        buildQRCodeCases(localhostUpperCase, guidLowerCase, true)
+        buildQRCodeCases(localhostLowerCase, guidLowerCase, true)
+        buildQRCodeCases(localhostMixedCase, guidLowerCase, true)
+
+        buildQRCodeCases(localhostUpperCase, guidMixedCase, true)
+        buildQRCodeCases(localhostLowerCase, guidMixedCase, true)
+        buildQRCodeCases(localhostMixedCase, guidMixedCase, true)
+    }
+
+    @Test
+    fun containsInvalidGUID() {
+        //extra slashes should be invalid.
+        buildQRCodeCases("HTTPS:///LOCALHOST/?", guidUpperCase, false)
+        buildQRCodeCases("HTTPS://LOCALHOST//?", guidUpperCase, false)
+        buildQRCodeCases("HTTPS://LOCALHOST///?", guidUpperCase, false)
 
         // more invalid tests checks
-        scanResult = QRScanResult("http://localhost/?$guid")
-        scanResult.isValid shouldBe false
-        scanResult = QRScanResult("https://localhost/?")
-        scanResult.isValid shouldBe false
-        scanResult = QRScanResult("htps://wrongformat.com")
-        scanResult.isValid shouldBe false
-        scanResult =
-            QRScanResult("https://localhost/%20?3D6D08-3567F3F2-4DCF-43A3-8737-4CD1F87D6FDA")
-        scanResult.isValid shouldBe false
-        scanResult =
-            QRScanResult("https://some-host.com/?3D6D08-3567F3F2-4DCF-43A3-8737-4CD1F87D6FDA")
-        scanResult.isValid shouldBe false
-        scanResult = QRScanResult("https://localhost/?3567F3F2-4DCF-43A3-8737-4CD1F87D6FDA")
-        scanResult.isValid shouldBe false
-        scanResult = QRScanResult("https://localhost/?4CD1F87D6FDA")
-        scanResult.isValid shouldBe false
+        buildQRCodeCases("http://localhost/?", guidUpperCase, false)
+        buildQRCodeCases("https://localhost/?", "", false)
+        buildQRCodeCases(
+            "https://localhost/%20?3D6D08-3567F3F2-4DCF-43A3-8737-4CD1F87D6FDA",
+            "",
+            false
+        )
+        buildQRCodeCases(
+            "https://some-host.com/?3D6D08-3567F3F2-4DCF-43A3-8737-4CD1F87D6FDA",
+            "",
+            false
+        )
+        buildQRCodeCases(
+            "https://localhost/?3567F3F2-4DCF-43A3-8737-4CD1F87D6FDA",
+            "",
+            false
+        )
+        buildQRCodeCases(
+            "https://localhost/?4CD1F87D6FDA",
+            "",
+            false
+        )
     }
 
     @Test
     fun extractGUID() {
-        QRScanResult("https://localhost/?$guid").guid shouldBe guid
+        QRScanResult("$localhostUpperCase$guidUpperCase").guid shouldBe guidUpperCase
+        QRScanResult("$localhostUpperCase$guidLowerCase").guid shouldBe guidLowerCase
+        QRScanResult("$localhostUpperCase$guidMixedCase").guid shouldBe guidMixedCase
+
+        QRScanResult("$localhostLowerCase$guidUpperCase").guid shouldBe guidUpperCase
+        QRScanResult("$localhostLowerCase$guidLowerCase").guid shouldBe guidLowerCase
+        QRScanResult("$localhostLowerCase$guidMixedCase").guid shouldBe guidMixedCase
+
+        QRScanResult("$localhostMixedCase$guidUpperCase").guid shouldBe guidUpperCase
+        QRScanResult("$localhostMixedCase$guidLowerCase").guid shouldBe guidLowerCase
+        QRScanResult("$localhostMixedCase$guidMixedCase").guid shouldBe guidMixedCase
     }
 }
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
deleted file mode 100644
index 5b070bfb4607c79ee19e4c294493c9043ac02fd3..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/SubmissionConstantsTest.kt
+++ /dev/null
@@ -1,14 +0,0 @@
-package de.rki.coronawarnapp.service.submission
-
-import org.junit.Assert
-import org.junit.Test
-
-class SubmissionConstantsTest {
-
-    @Test
-    fun allSubmissionConstants() {
-        Assert.assertEquals(QRScanResult.MAX_QR_CODE_LENGTH, 150)
-        Assert.assertEquals(QRScanResult.MAX_GUID_LENGTH, 80)
-        Assert.assertEquals(QRScanResult.GUID_SEPARATOR, '?')
-    }
-}
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 f5084219d09a5c1c661af6962dce58a69e44ea8a..553c5c3a6c4de1c6550e402b2220e54d1ffa824f 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
@@ -7,7 +7,6 @@ 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
@@ -52,7 +51,6 @@ class SubmissionServiceTest {
         mockkObject(BackgroundNoise.Companion)
         every { BackgroundNoise.getInstance() } returns backgroundNoise
 
-        mockkObject(SubmitDiagnosisKeysTransaction)
         mockkObject(LocalData)
 
         mockkObject(SubmissionRepository)
@@ -148,29 +146,6 @@ class SubmissionServiceTest {
         }
     }
 
-    @Test
-    fun submitExposureKeysWithoutRegistrationTokenFails(): Unit = runBlocking {
-        shouldThrow<NoRegistrationTokenSetException> {
-            SubmissionService.asyncSubmitExposureKeys(listOf(), symptoms)
-        }
-    }
-
-    @Test
-    fun submitExposureKeysSucceeds() {
-        every { LocalData.registrationToken() } returns registrationToken
-        coEvery {
-            SubmitDiagnosisKeysTransaction.start(
-                registrationToken,
-                any(),
-                symptoms
-            )
-        } just Runs
-
-        runBlocking {
-            SubmissionService.asyncSubmitExposureKeys(listOf(), symptoms)
-        }
-    }
-
     @Test
     fun deleteRegistrationTokenSucceeds() {
         every { LocalData.registrationToken(null) } just Runs
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
index c2609308be22b5ded8788ddc3813f3866b92cded..1b8b83751b885da0467e9b6ffc869eb08a8d4eef 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
@@ -124,8 +124,8 @@ class TaskControllerTest : BaseIOTest() {
             path = File(testDir, UUID.randomUUID().toString())
         )
         val request = DefaultTaskRequest(
-            arguments = arguments,
-            type = QueueingTask::class
+            type = QueueingTask::class,
+            arguments = arguments
         )
 
         arguments.path.exists() shouldBe false
@@ -189,8 +189,8 @@ class TaskControllerTest : BaseIOTest() {
             path = File(testDir, UUID.randomUUID().toString())
         )
         val request = DefaultTaskRequest(
-            arguments = arguments,
-            type = QueueingTask::class
+            type = QueueingTask::class,
+            arguments = arguments
         )
 
         arguments.path.exists() shouldBe false
@@ -228,8 +228,8 @@ class TaskControllerTest : BaseIOTest() {
             path = File(testDir, UUID.randomUUID().toString())
         )
         val request = DefaultTaskRequest(
-            arguments = arguments,
-            type = QueueingTask::class
+            type = QueueingTask::class,
+            arguments = arguments
         )
         instance.submit(request)
         delay(1000)
@@ -254,8 +254,8 @@ class TaskControllerTest : BaseIOTest() {
         arguments.path.exists() shouldBe false
 
         val request1 = DefaultTaskRequest(
-            arguments = arguments,
-            type = QueueingTask::class
+            type = QueueingTask::class,
+            arguments = arguments
         )
         instance.submit(request1)
 
@@ -305,14 +305,14 @@ class TaskControllerTest : BaseIOTest() {
         arguments.path.exists() shouldBe false
 
         val request1 = DefaultTaskRequest(
-            arguments = arguments,
-            type = SkippingTask::class
+            type = SkippingTask::class,
+            arguments = arguments
         )
         instance.submit(request1)
 
         val request2 = DefaultTaskRequest(
-            arguments = arguments,
-            type = SkippingTask::class
+            type = SkippingTask::class,
+            arguments = arguments
         )
         instance.submit(request2)
 
@@ -346,14 +346,14 @@ class TaskControllerTest : BaseIOTest() {
         arguments.path.exists() shouldBe false
 
         val request1 = DefaultTaskRequest(
-            arguments = arguments,
-            type = QueueingTask::class
+            type = QueueingTask::class,
+            arguments = arguments
         )
 
         // Class needs to be different, typing is based on that.
         val request2 = DefaultTaskRequest(
-            arguments = arguments,
-            type = SkippingTask::class
+            type = SkippingTask::class,
+            arguments = arguments
         )
 
         val instance = createInstance(scope = this)
@@ -397,8 +397,8 @@ class TaskControllerTest : BaseIOTest() {
             path = File(testDir, UUID.randomUUID().toString())
         )
         val request = DefaultTaskRequest(
-            arguments = arguments,
-            type = QueueingTask::class
+            type = QueueingTask::class,
+            arguments = arguments
         )
 
         arguments.path.exists() shouldBe false
@@ -426,8 +426,8 @@ class TaskControllerTest : BaseIOTest() {
         val instance = createInstance(scope = this)
 
         val request = DefaultTaskRequest(
-            arguments = TimeoutTaskArguments(),
-            type = TimeoutTask::class
+            type = TimeoutTask::class,
+            arguments = TimeoutTaskArguments()
         )
 
         instance.submit(request)
@@ -451,12 +451,12 @@ class TaskControllerTest : BaseIOTest() {
         val instance = createInstance(scope = this)
 
         val taskWithTimeout = DefaultTaskRequest(
-            arguments = TimeoutTaskArguments(),
-            type = TimeoutTask::class
+            type = TimeoutTask::class,
+            arguments = TimeoutTaskArguments()
         )
         val taskWithoutTimeout = DefaultTaskRequest(
-            arguments = TimeoutTaskArguments(delay = 5000),
-            type = TimeoutTask::class
+            type = TimeoutTask::class,
+            arguments = TimeoutTaskArguments(delay = 5000)
         )
         val taskWithoutTimeout2 = taskWithoutTimeout.toNewTask()
 
@@ -492,20 +492,20 @@ class TaskControllerTest : BaseIOTest() {
         val instance = createInstance(scope = this)
 
         val task1WithTimeout = DefaultTaskRequest(
-            arguments = TimeoutTaskArguments(),
-            type = TimeoutTask::class
+            type = TimeoutTask::class,
+            arguments = TimeoutTaskArguments()
         )
         val task2WithTimeout = DefaultTaskRequest(
-            arguments = TimeoutTaskArguments(),
-            type = TimeoutTask2::class
+            type = TimeoutTask2::class,
+            arguments = TimeoutTaskArguments()
         )
         val task1WithoutTimeout = DefaultTaskRequest(
-            arguments = TimeoutTaskArguments(delay = 5000),
-            type = TimeoutTask::class
+            type = TimeoutTask::class,
+            arguments = TimeoutTaskArguments(delay = 5000)
         )
         val task2WithoutTimeout = DefaultTaskRequest(
-            arguments = TimeoutTaskArguments(delay = 5000),
-            type = TimeoutTask2::class
+            type = TimeoutTask2::class,
+            arguments = TimeoutTaskArguments(delay = 5000)
         )
 
         instance.submit(task1WithTimeout)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt
index 3c832ad293951d1072960f4ffbefaee9fe1b0333..f04714e342280f267dfd7a55d7f34365b6b43c90 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt
@@ -18,7 +18,7 @@ class SkippingTask : QueueingTask() {
     }
 
     class Factory @Inject constructor(
-        private val taskByDagger: Provider<QueueingTask>,
+        private val taskByDagger: Provider<QueueingTask>
     ) : TaskFactory<DefaultProgress, Result> {
 
         override val config: TaskFactory.Config =
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt
index 7879b015a9d57e50a59e433c055959f2642b6a30..d5e513abb6def320fef21f7ebd13c607b82bb1e8 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt
@@ -9,7 +9,6 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.asFlow
 import timber.log.Timber
-import javax.inject.Inject
 import javax.inject.Provider
 
 abstract class BaseTimeoutTask : Task<DefaultProgress, TimeoutTaskResult> {
@@ -39,7 +38,7 @@ abstract class BaseTimeoutTask : Task<DefaultProgress, TimeoutTaskResult> {
         isCanceled = true
     }
 
-    abstract class Factory @Inject constructor(
+    abstract class Factory constructor(
         private val taskByDagger: Provider<BaseTimeoutTask>
     ) : TaskFactory<DefaultProgress, TimeoutTaskResult> {
 
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
index 81581412be578ed1f02be138075b250f0c7dcf01..4cbe2bda9a570500b6b6d56b6b23d3bcf139fc19 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
@@ -1,9 +1,10 @@
 package de.rki.coronawarnapp.transaction
 
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.ConfigData
 import de.rki.coronawarnapp.environment.EnvironmentSetup
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.util.GoogleAPIVersion
 import de.rki.coronawarnapp.util.di.AppInjector
@@ -37,6 +38,8 @@ class RetrieveDiagnosisKeysTransactionTest {
 
     @MockK lateinit var mockEnfClient: ENFClient
     @MockK lateinit var environmentSetup: EnvironmentSetup
+    @MockK lateinit var configProvider: AppConfigProvider
+    @MockK lateinit var configData: ConfigData
 
     @BeforeEach
     fun setUp() {
@@ -50,17 +53,21 @@ class RetrieveDiagnosisKeysTransactionTest {
                 mockEnfClient,
                 environmentSetup
             )
+            every { appConfigProvider } returns configProvider
         }
+
+        coEvery { configProvider.getAppConfig() } returns configData
+        every { configData.supportedCountries } returns emptyList()
+        every { configData.exposureDetectionConfiguration } returns mockk()
+
         every { AppInjector.component } returns appComponent
 
         mockkObject(InternalExposureNotificationClient)
-        mockkObject(ApplicationConfigurationService)
         mockkObject(RetrieveDiagnosisKeysTransaction)
         mockkObject(LocalData)
 
         coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
 
-        coEvery { ApplicationConfigurationService.asyncRetrieveExposureConfiguration() } returns mockk()
         every { LocalData.googleApiToken(any()) } just Runs
         every { LocalData.lastTimeDiagnosisKeysFromServerFetch() } returns Date()
         every { LocalData.lastTimeDiagnosisKeysFromServerFetch(any()) } just Runs
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RiskLevelTransactionTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RiskLevelTransactionTest.kt
deleted file mode 100644
index ea8c5d2192fd484c46e862fea33580b70bf2d66b..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RiskLevelTransactionTest.kt
+++ /dev/null
@@ -1,592 +0,0 @@
-package de.rki.coronawarnapp.transaction
-
-import android.content.Context
-import com.google.android.gms.nearby.exposurenotification.ExposureSummary
-import de.rki.coronawarnapp.CoronaWarnApplication
-import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
-import de.rki.coronawarnapp.risk.RiskLevel
-import de.rki.coronawarnapp.risk.RiskLevel.INCREASED_RISK
-import de.rki.coronawarnapp.risk.RiskLevel.LOW_LEVEL_RISK
-import de.rki.coronawarnapp.risk.RiskLevel.NO_CALCULATION_POSSIBLE_TRACING_OFF
-import de.rki.coronawarnapp.risk.RiskLevel.UNDETERMINED
-import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_INITIAL
-import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS
-import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
-import de.rki.coronawarnapp.risk.RiskLevelCalculation
-import de.rki.coronawarnapp.risk.RiskScoreAnalysis
-import de.rki.coronawarnapp.risk.TimeVariables
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig
-import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass
-import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass
-import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass.RiskScoreClass
-import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass.RiskScoreClassification
-import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
-import de.rki.coronawarnapp.storage.ExposureSummaryRepository
-import de.rki.coronawarnapp.storage.LocalData
-import de.rki.coronawarnapp.storage.RiskLevelRepository
-import de.rki.coronawarnapp.util.ConnectivityHelper
-import de.rki.coronawarnapp.util.di.AppInjector
-import de.rki.coronawarnapp.util.di.ApplicationComponent
-import io.mockk.MockKAnnotations
-import io.mockk.Runs
-import io.mockk.coEvery
-import io.mockk.coVerifyOrder
-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.unmockkAll
-import kotlinx.coroutines.runBlocking
-import org.junit.After
-import org.junit.Assert
-import org.junit.Before
-import org.junit.Test
-import java.util.UUID
-import java.util.concurrent.TimeUnit
-
-class RiskLevelTransactionTest {
-
-    @MockK
-    private lateinit var esRepositoryMock: ExposureSummaryRepository
-
-    @MockK
-    private lateinit var context: Context
-
-    @Before
-    fun setUp() {
-        MockKAnnotations.init(this)
-
-        mockkObject(AppInjector)
-        val appComponent = mockk<ApplicationComponent>().apply {
-            every { transRiskLevelInjection } returns RiskLevelInjectionHelper(
-                TransactionCoroutineScope()
-            )
-        }
-        every { AppInjector.component } returns appComponent
-
-        mockkObject(InternalExposureNotificationClient)
-        mockkObject(ApplicationConfigurationService)
-        mockkObject(LocalData)
-        every { LocalData.lastSuccessfullyCalculatedRiskLevel() } returns UNDETERMINED
-        mockkObject(RiskLevelRepository)
-        mockkObject(RiskLevelTransaction)
-        mockkObject(TimeVariables)
-        mockkObject(ExposureSummaryRepository.Companion)
-        mockkObject(RiskLevel.Companion)
-        mockkObject(ConnectivityHelper)
-        mockkObject(CoronaWarnApplication)
-
-        every { ExposureSummaryRepository.getExposureSummaryRepository() } returns esRepositoryMock
-
-        every { RiskLevelRepository.getLastCalculatedScore() } returns UNDETERMINED
-
-        every { RiskLevelRepository.setRiskLevelScore(any()) } just Runs
-        every { RiskLevel.riskLevelChangedBetweenLowAndHigh(any(), any()) } returns false
-        every { LocalData.lastTimeRiskLevelCalculation() } returns System.currentTimeMillis()
-        every { LocalData.lastTimeRiskLevelCalculation(any()) } just Runs
-        every { LocalData.googleApiToken() } returns UUID.randomUUID().toString()
-        every { ConnectivityHelper.isNetworkEnabled(any()) } returns true
-        every { CoronaWarnApplication.getAppContext() } returns context
-    }
-
-    /** Test case for [NO_CALCULATION_POSSIBLE_TRACING_OFF] */
-    @Test
-    fun noCalculationPossibleTracingOff() {
-
-        val testRiskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF
-
-        // tracing is deactivated
-        coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns false
-
-        runBlocking {
-
-            RiskLevelTransaction.start()
-
-            coVerifyOrder {
-                RiskLevelTransaction.start()
-
-                RiskLevelTransaction["executeCheckTracing"]()
-                RiskLevelTransaction["isValidResult"](testRiskLevel)
-
-                RiskLevelRepository.setRiskLevelScore(testRiskLevel)
-                RiskLevelTransaction["executeRiskLevelCalculationDateUpdate"]()
-                RiskLevelTransaction["executeClose"]()
-            }
-        }
-    }
-
-    /** Test case for [UNKNOWN_RISK_INITIAL] if no keys are fetched from server */
-    @Test
-    fun unknownRiskInitialNoKeys() {
-
-        val testRiskLevel = UNKNOWN_RISK_INITIAL
-
-        // tracing is activated
-        coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
-
-        // we have not fetched keys from server yet
-        every { TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() } returns null
-
-        runBlocking {
-
-            RiskLevelTransaction.start()
-
-            coVerifyOrder {
-                RiskLevelTransaction.start()
-
-                RiskLevelTransaction["executeCheckTracing"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialNoKeys"]()
-                RiskLevelTransaction["isValidResult"](testRiskLevel)
-
-                RiskLevelRepository.setRiskLevelScore(testRiskLevel)
-                RiskLevelTransaction["executeRiskLevelCalculationDateUpdate"]()
-                RiskLevelTransaction["executeClose"]()
-            }
-        }
-    }
-
-    /** Test case for [UNKNOWN_RISK_OUTDATED_RESULTS] if keys are outdated */
-    @Test
-    fun unknownRiskOutdatedResults() {
-
-        val testRiskLevel = UNKNOWN_RISK_OUTDATED_RESULTS
-
-        val twoHoursAboveMaxStale =
-            TimeUnit.HOURS.toMillis(TimeVariables.getMaxStaleExposureRiskRange().plus(2).toLong())
-
-        // tracing is activated
-        coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
-
-        // the last time we fetched keys from the server is above the threshold
-        every { TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() } returns System.currentTimeMillis()
-            .minus(twoHoursAboveMaxStale)
-
-        // active tracing time is 1h above the threshold
-        every { TimeVariables.getTimeActiveTracingDuration() } returns TimeUnit.HOURS.toMillis(
-            TimeVariables.getMinActivatedTracingTime().plus(1).toLong()
-        )
-
-        // background jobs are enabled
-        every { ConnectivityHelper.autoModeEnabled(CoronaWarnApplication.getAppContext()) } returns true
-
-        runBlocking {
-
-            RiskLevelTransaction.start()
-
-            coVerifyOrder {
-                RiskLevelTransaction.start()
-
-                RiskLevelTransaction["executeCheckTracing"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialNoKeys"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskOutdatedResults"]()
-                RiskLevelTransaction["isValidResult"](testRiskLevel)
-
-                RiskLevelRepository.setRiskLevelScore(testRiskLevel)
-                RiskLevelTransaction["executeRiskLevelCalculationDateUpdate"]()
-                RiskLevelTransaction["executeClose"]()
-            }
-        }
-    }
-
-    /** Test case for [UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL] if keys are outdated and background
-     * jobs are disabled */
-    @Test
-    fun unknownRiskOutdatedResultsManual() {
-
-        val testRiskLevel = UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
-
-        val twoHoursAboveMaxStale =
-            TimeUnit.HOURS.toMillis(TimeVariables.getMaxStaleExposureRiskRange().plus(2).toLong())
-
-        // tracing is activated
-        coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
-
-        // the last time we fetched keys from the server is above the threshold
-        every { TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() } returns System.currentTimeMillis()
-            .minus(twoHoursAboveMaxStale)
-
-        // active tracing time is 1h above the threshold
-        every { TimeVariables.getTimeActiveTracingDuration() } returns TimeUnit.HOURS.toMillis(
-            TimeVariables.getMinActivatedTracingTime().plus(1).toLong()
-        )
-
-        // background jobs are disabled
-        every { ConnectivityHelper.autoModeEnabled(CoronaWarnApplication.getAppContext()) } returns false
-
-        runBlocking {
-
-            RiskLevelTransaction.start()
-
-            coVerifyOrder {
-                RiskLevelTransaction.start()
-
-                RiskLevelTransaction["executeCheckTracing"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialNoKeys"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskOutdatedResults"]()
-                RiskLevelTransaction["isValidResult"](testRiskLevel)
-
-                RiskLevelRepository.setRiskLevelScore(testRiskLevel)
-                RiskLevelTransaction["executeRiskLevelCalculationDateUpdate"]()
-                RiskLevelTransaction["executeClose"]()
-            }
-        }
-    }
-
-    /** Test case for [INCREASED_RISK]  */
-    @Test
-    fun increasedRisk() {
-
-        val testRiskLevel = INCREASED_RISK
-
-        val testAppConfig = buildTestAppConfig()
-
-        val testExposureSummary = buildSummary(1600, 0, 30, 15)
-
-        // tracing is activated
-        coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
-
-        // the last time we fetched keys from the server happened 30 mins ago (within maxStale)
-        every { TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() } returns System.currentTimeMillis()
-            .minus(TimeUnit.MINUTES.toMillis(30))
-
-        // We traced only 2h
-        every { TimeVariables.getTimeActiveTracingDuration() } returns TimeUnit.HOURS.toMillis(2)
-
-        // the risk score of the last exposure summary is above the high min threshold
-        coEvery { ApplicationConfigurationService.asyncRetrieveApplicationConfiguration() } returns testAppConfig
-        coEvery { InternalExposureNotificationClient.asyncGetExposureSummary(any()) } returns testExposureSummary
-
-        runBlocking {
-
-            RiskLevelTransaction.start()
-
-            coVerifyOrder {
-                RiskLevelTransaction.start()
-
-                RiskLevelTransaction["executeCheckTracing"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialNoKeys"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskOutdatedResults"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckAppConnectivity"]()
-
-                RiskLevelTransaction["executeRetrieveApplicationConfiguration"]()
-
-                RiskLevelTransaction["executeRetrieveExposureSummary"]()
-
-                RiskLevelTransaction["executeCheckIncreasedRisk"](
-                    testAppConfig,
-                    testExposureSummary
-                )
-                RiskLevelTransaction["isValidResult"](testRiskLevel)
-
-                RiskLevelRepository.setRiskLevelScore(testRiskLevel)
-                RiskLevelTransaction["executeRiskLevelCalculationDateUpdate"]()
-                RiskLevelTransaction["executeClose"]()
-            }
-        }
-    }
-
-    /** Test case for [UNKNOWN_RISK_INITIAL] if tracing threshold is not reached */
-    @Test
-    fun unknownRiskInitialTracingDuration() {
-
-        val testRiskLevel = UNKNOWN_RISK_INITIAL
-
-        val testAppConfig = buildTestAppConfig()
-
-        val testExposureSummary = buildSummary()
-
-        val twoHoursBelowMinActiveTracingDuration =
-            TimeUnit.HOURS.toMillis(TimeVariables.getMinActivatedTracingTime().minus(2).toLong())
-
-        // tracing is activated
-        coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
-
-        // the last time we fetched keys from the server happened 30 mins ago (within maxStale)
-        every { TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() } returns System.currentTimeMillis()
-            .minus(TimeUnit.MINUTES.toMillis(30))
-
-        // we only traced 2 hours
-        every { TimeVariables.getTimeActiveTracingDuration() } returns twoHoursBelowMinActiveTracingDuration
-
-        // the exposure summary risk score is not below high min score
-        coEvery { ApplicationConfigurationService.asyncRetrieveApplicationConfiguration() } returns testAppConfig
-        coEvery { InternalExposureNotificationClient.asyncGetExposureSummary(any()) } returns testExposureSummary
-
-        runBlocking {
-
-            RiskLevelTransaction.start()
-
-            coVerifyOrder {
-                RiskLevelTransaction.start()
-
-                RiskLevelTransaction["executeCheckTracing"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialNoKeys"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskOutdatedResults"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckAppConnectivity"]()
-
-                RiskLevelTransaction["executeRetrieveApplicationConfiguration"]()
-
-                RiskLevelTransaction["executeRetrieveExposureSummary"]()
-
-                RiskLevelTransaction["executeCheckIncreasedRisk"](
-                    testAppConfig,
-                    testExposureSummary
-                )
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialTracingDuration"]()
-                RiskLevelTransaction["isValidResult"](testRiskLevel)
-
-                RiskLevelRepository.setRiskLevelScore(testRiskLevel)
-                RiskLevelTransaction["executeRiskLevelCalculationDateUpdate"]()
-                RiskLevelTransaction["executeClose"]()
-            }
-        }
-    }
-
-    /** Test case for [LOW_LEVEL_RISK] */
-    @Test
-    fun lowRisk() {
-
-        val testRiskLevel = LOW_LEVEL_RISK
-
-        val testAppConfig = buildTestAppConfig()
-
-        val testExposureSummary = buildSummary(10)
-
-        val twoHoursAboveMinActiveTracingDuration =
-            TimeUnit.HOURS.toMillis(TimeVariables.getMinActivatedTracingTime().plus(2).toLong())
-
-        // tracing is activated
-        coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
-
-        // the last time we fetched keys from the server happened 30 mins ago (within maxStale)
-        every { TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() } returns System.currentTimeMillis()
-            .minus(TimeUnit.MINUTES.toMillis(30))
-
-        // the active tracing duration is above the threshold
-        every { TimeVariables.getTimeActiveTracingDuration() } returns twoHoursAboveMinActiveTracingDuration
-
-        coEvery { ApplicationConfigurationService.asyncRetrieveApplicationConfiguration() } returns testAppConfig
-        coEvery { InternalExposureNotificationClient.asyncGetExposureSummary(any()) } returns testExposureSummary
-
-        runBlocking {
-
-            RiskLevelTransaction.start()
-
-            coVerifyOrder {
-                RiskLevelTransaction.start()
-
-                RiskLevelTransaction["executeCheckTracing"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialNoKeys"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskOutdatedResults"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckAppConnectivity"]()
-
-                RiskLevelTransaction["executeRetrieveApplicationConfiguration"]()
-
-                RiskLevelTransaction["executeRetrieveExposureSummary"]()
-
-                RiskLevelTransaction["executeCheckIncreasedRisk"](
-                    testAppConfig,
-                    testExposureSummary
-                )
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialTracingDuration"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelRepository.setRiskLevelScore(testRiskLevel)
-                RiskLevelTransaction["executeRiskLevelCalculationDateUpdate"]()
-                RiskLevelTransaction["executeClose"]()
-            }
-        }
-    }
-
-    /** Test case if app is not connected */
-    @Test
-    fun checkAppConnectivity() {
-
-        val testRiskLevel = INCREASED_RISK
-
-        // tracing is activated
-        coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
-
-        // the last time we fetched keys from the server happened 30 mins ago (within maxStale)
-        every { TimeVariables.getLastTimeDiagnosisKeysFromServerFetch() } returns System.currentTimeMillis()
-            .minus(TimeUnit.MINUTES.toMillis(30))
-
-        // active tracing time is 1h above the threshold
-        every { TimeVariables.getTimeActiveTracingDuration() } returns TimeUnit.HOURS.toMillis(
-            TimeVariables.getMinActivatedTracingTime().plus(1).toLong()
-        )
-
-        every { RiskLevelRepository.getLastCalculatedScore() } returns testRiskLevel
-
-        every { ConnectivityHelper.isNetworkEnabled(context) } returns false
-
-        runBlocking {
-
-            RiskLevelTransaction.start()
-
-            coVerifyOrder {
-                RiskLevelTransaction.start()
-
-                RiskLevelTransaction["executeCheckTracing"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskInitialNoKeys"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckUnknownRiskOutdatedResults"]()
-                RiskLevelTransaction["isValidResult"](UNDETERMINED)
-
-                RiskLevelTransaction["executeCheckAppConnectivity"]()
-                RiskLevelRepository.setLastCalculatedRiskLevelAsCurrent()
-                RiskLevelTransaction["executeClose"]()
-            }
-        }
-    }
-
-    @Test
-    fun test_getRiskLevel() {
-
-        // if risk score is within defined level threshold
-        // expected: INCREASED_RISK
-
-        val testAppConfig = buildTestAppConfig()
-        Assert.assertEquals(
-            RiskLevel.INCREASED_RISK, RiskLevelTransaction.getRiskLevel(
-                object : RiskLevelCalculation {
-                    override fun calculateRiskScore(
-                        attenuationParameters: AttenuationDurationOuterClass.AttenuationDuration,
-                        exposureSummary: ExposureSummary
-                    ) = 0.0
-                },
-                object : RiskScoreAnalysis {
-                    override fun withinDefinedLevelThreshold(
-                        riskScore: Double,
-                        min: Int,
-                        max: Int
-                    ) = true
-                },
-                testAppConfig.attenuationDuration,
-                buildSummary(1600, 0, 30, 15),
-                testAppConfig.riskScoreClasses
-            )
-        )
-    }
-
-    @After
-    fun cleanUp() {
-        unmockkAll()
-    }
-
-    private fun buildTestAppConfig(
-        lowMax: Int = 2749,
-        highMin: Int = 2750,
-        highMax: Int = 4096
-    ): AppConfig.ApplicationConfiguration {
-        return AppConfig.ApplicationConfiguration
-            .newBuilder()
-            .setRiskScoreClasses(buildRiskScoreClassification(lowMax, highMin, highMax))
-            .setAttenuationDuration(buildAttenuationDuration())
-            .build()
-    }
-
-    private fun buildAttenuationDuration(): AttenuationDurationOuterClass.AttenuationDuration {
-        return AttenuationDurationOuterClass.AttenuationDuration
-            .newBuilder()
-            .setRiskScoreNormalizationDivisor(25)
-            .setDefaultBucketOffset(0)
-            .setThresholds(
-                AttenuationDurationOuterClass.Thresholds
-                    .newBuilder()
-                    .setLower(50)
-                    .setUpper(70)
-                    .build()
-            )
-            .setWeights(
-                AttenuationDurationOuterClass.Weights
-                    .newBuilder()
-                    .setHigh(1.0)
-                    .setMid(1.0)
-                    .setLow(1.0)
-                    .build()
-            )
-            .build()
-    }
-
-    private fun buildRiskScoreClassification(
-        lowMax: Int,
-        highMin: Int,
-        highMax: Int
-    ): RiskScoreClassification {
-        val mockUrl = "https://corona-warn.app"
-        val lowLabel = "LOW"
-        val highLabel = "HIGH"
-
-        val lowClass = RiskScoreClass.newBuilder()
-            .setLabel(lowLabel)
-            .setMax(lowMax)
-            .setUrl(mockUrl)
-            .build()
-
-        val highClass = RiskScoreClass.newBuilder()
-            .setLabel(highLabel)
-            .setMin(highMin)
-            .setMax(highMax)
-            .setUrl(mockUrl)
-            .build()
-        val riskScoreClasses = mutableListOf(lowClass, highClass)
-
-        return RiskScoreClassification
-            .newBuilder()
-            .addAllRiskClasses(riskScoreClasses)
-            .build()
-    }
-
-    private fun buildSummary(
-        maxRisk: Int = 0,
-        lowAttenuation: Int = 0,
-        midAttenuation: Int = 0,
-        highAttenuation: Int = 0
-    ): ExposureSummary {
-        val intArray = IntArray(3)
-        intArray[0] = lowAttenuation
-        intArray[1] = midAttenuation
-        intArray[2] = highAttenuation
-        return ExposureSummary.ExposureSummaryBuilder()
-            .setMaximumRiskScore(maxRisk)
-            .setAttenuationDurations(intArray)
-            .build()
-    }
-}
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
deleted file mode 100644
index 26fc4c64c3c379927edbc3b57b7c25ca4cf2dace..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/SubmitDiagnosisKeysTransactionTest.kt
+++ /dev/null
@@ -1,180 +0,0 @@
-package de.rki.coronawarnapp.transaction
-
-import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
-import com.google.protobuf.ByteString
-import de.rki.coronawarnapp.appconfig.AppConfigProvider
-import de.rki.coronawarnapp.playbook.BackgroundNoise
-import de.rki.coronawarnapp.playbook.Playbook
-import de.rki.coronawarnapp.server.protocols.external.exposurenotification.TemporaryExposureKeyExportOuterClass
-import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration
-import de.rki.coronawarnapp.service.submission.SubmissionService
-import de.rki.coronawarnapp.storage.LocalData
-import de.rki.coronawarnapp.submission.ExposureKeyHistoryCalculations
-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.coVerifySequence
-import io.mockk.every
-import io.mockk.impl.annotations.MockK
-import io.mockk.just
-import io.mockk.mockkObject
-import kotlinx.coroutines.runBlocking
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
-import java.time.Instant
-
-class SubmitDiagnosisKeysTransactionTest {
-
-    @MockK lateinit var backgroundNoise: BackgroundNoise
-    @MockK lateinit var mockPlaybook: Playbook
-    @MockK lateinit var appConfigProvider: AppConfigProvider
-    @MockK lateinit var appComponent: ApplicationComponent
-    @MockK lateinit var exposureKeyHistoryCalculations: ExposureKeyHistoryCalculations
-
-    private val registrationToken = "123"
-
-    private val symptoms = Symptoms(Symptoms.StartOf.OneToTwoWeeksAgo, Symptoms.Indication.POSITIVE)
-    private val defaultCountries = listOf("DE", "NL", "FR")
-
-    @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,
-            exposureKeyHistoryCalculations
-        )
-        mockkObject(AppInjector)
-        every { AppInjector.component } returns appComponent
-
-        every {
-            exposureKeyHistoryCalculations.transformToKeyHistoryInExternalFormat(
-                any(),
-                any()
-            )
-        } returns emptyList()
-
-        mockkObject(BackgroundNoise.Companion)
-        every { BackgroundNoise.getInstance() } returns backgroundNoise
-
-        mockkObject(LocalData)
-        mockkObject(SubmissionService)
-        mockkObject(BackgroundWorkScheduler)
-        every { BackgroundWorkScheduler.stopWorkScheduler() } just Runs
-        every { LocalData.numberOfSuccessfulSubmissions(any()) } just Runs
-    }
-
-    @AfterEach
-    fun cleanUp() {
-        clearAllMocks()
-    }
-
-    @Test
-    fun `submission without keys`(): Unit = runBlocking {
-        coEvery { mockPlaybook.submission(any()) } returns Unit
-
-        SubmitDiagnosisKeysTransaction.start(registrationToken, listOf(), symptoms)
-
-        coVerifySequence {
-            appConfigProvider.getAppConfig()
-            mockPlaybook.submission(
-                Playbook.SubmissionData(
-                    registrationToken = registrationToken,
-                    temporaryExposureKeys = emptyList(),
-                    consentToFederation = true,
-                    visistedCountries = defaultCountries
-                )
-            )
-            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
-
-        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 `submission with keys`() {
-        val intervalNumber = (Instant.now().toEpochMilli() / (60 * 10 * 1000)).toInt()
-        runBlocking {
-            val key = TemporaryExposureKey.TemporaryExposureKeyBuilder()
-                .setKeyData(ByteArray(1))
-                .setRollingPeriod(144)
-                .setRollingStartIntervalNumber(intervalNumber)
-                .setTransmissionRiskLevel(1)
-                .setDaysSinceOnsetOfSymptoms(10)
-                .build()
-
-            every {
-                exposureKeyHistoryCalculations.transformToKeyHistoryInExternalFormat(
-                    any(),
-                    any()
-                )
-            } returns listOf(
-                TemporaryExposureKeyExportOuterClass.TemporaryExposureKey.newBuilder()
-                    .setKeyData(ByteString.copyFrom(ByteArray(1)))
-                    .setRollingPeriod(144)
-                    .setRollingStartIntervalNumber(intervalNumber)
-                    .setTransmissionRiskLevel(1)
-                    .setDaysSinceOnsetOfSymptoms(10)
-                    .build()
-            )
-
-            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 intervalNumber
-                        transmissionRiskLevel shouldBe 1
-                        daysSinceOnsetOfSymptoms shouldBe 10
-                    }
-                    it.consentToFederation shouldBe true
-                    it.visistedCountries shouldBe defaultCountries
-                }
-                Unit
-            }
-
-            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/submission/SubmissionTanViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/SubmissionTanViewModelTest.kt
deleted file mode 100644
index 8f49bcb3f001e37e3e3012895d795f3217c9485b..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/SubmissionTanViewModelTest.kt
+++ /dev/null
@@ -1,48 +0,0 @@
-package de.rki.coronawarnapp.ui.submission
-
-import de.rki.coronawarnapp.storage.SubmissionRepository
-import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionTanViewModel
-import io.mockk.Runs
-import io.mockk.every
-import io.mockk.just
-import io.mockk.mockk
-import io.mockk.verify
-import org.junit.Assert.assertEquals
-import org.junit.Assert.assertFalse
-import org.junit.Assert.assertTrue
-import org.junit.Test
-
-class SubmissionTanViewModelTest {
-    private var viewModel: SubmissionTanViewModel = SubmissionTanViewModel()
-
-    @Test
-    fun tanFormatValid() {
-        viewModel.tan.postValue("ZWFPC7NG47")
-        viewModel.isValidTanFormat.value?.let { assertTrue(it) }
-
-        viewModel.tan.postValue("ABC")
-        viewModel.isValidTanFormat.value?.let { assertFalse(it) }
-
-        viewModel.tan.postValue("ZWFPC7NG48")
-        viewModel.isValidTanFormat.value?.let { assertFalse(it) }
-
-        viewModel.tan.postValue("ZWFPC7NG4A")
-        viewModel.isValidTanFormat.value?.let { assertFalse(it) }
-    }
-
-    @Test
-    fun testTanStorage() {
-        val sr = mockk<SubmissionRepository> {
-            every { setTeletan(any()) } just Runs
-        }
-        val tan = "ZWFPC7NG47"
-        sr.setTeletan(tan)
-
-        verify(exactly = 1) {
-            sr.setTeletan(
-                withArg {
-                    assertEquals(it, tan)
-                })
-        }
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeInfoFragmentViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragmentViewModelTest.kt
similarity index 93%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeInfoFragmentViewModelTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragmentViewModelTest.kt
index 44023d6cd1cc8c3e8b8efe8b3846df0cbd45e1ea..d42ae7c1d4307f20c66c0c795901e657fb8a97a9 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionQRCodeInfoFragmentViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/info/SubmissionQRCodeInfoFragmentViewModelTest.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.submission.viewmodel
+package de.rki.coronawarnapp.ui.submission.qrcode.info
 
 import io.kotest.matchers.shouldBe
 import org.junit.jupiter.api.Test
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/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
similarity index 88%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/viewmodel/SubmissionViewModelTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
index cbc54ad5d3a78b2dddbe89f2a525c0576ca03672..b518ae31a0ba845187e3889b415605b4507da462 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/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.ui.viewmodel
+package de.rki.coronawarnapp.ui.submission.qrcode.scan
 
 import de.rki.coronawarnapp.playbook.BackgroundNoise
 import de.rki.coronawarnapp.storage.LocalData
@@ -14,10 +14,11 @@ import org.junit.Assert
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.extension.ExtendWith
+import testhelpers.BaseTest
 import testhelpers.extensions.InstantExecutorExtension
 
 @ExtendWith(InstantExecutorExtension::class)
-class SubmissionViewModelTest {
+class SubmissionQRCodeScanViewModelTest : BaseTest() {
 
     @MockK lateinit var backgroundNoise: BackgroundNoise
 
@@ -32,7 +33,7 @@ class SubmissionViewModelTest {
         every { BackgroundNoise.getInstance() } returns backgroundNoise
     }
 
-    private fun createViewModel() = SubmissionViewModel()
+    private fun createViewModel() = SubmissionQRCodeScanViewModel()
 
     @Test
     fun scanStatusValid() {
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..fe2bd9043d442472d3a2b8739ffa07a583545f85
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.ui.submission.tan
+
+import de.rki.coronawarnapp.storage.SubmissionRepository
+import io.kotest.matchers.shouldBe
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import testhelpers.BaseTest
+import testhelpers.TestDispatcherProvider
+import testhelpers.extensions.CoroutinesTestExtension
+import testhelpers.extensions.InstantExecutorExtension
+
+@ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class)
+class SubmissionTanViewModelTest : BaseTest() {
+
+    private fun createInstance() = SubmissionTanViewModel(
+        dispatcherProvider = TestDispatcherProvider
+    )
+
+    @Test
+    fun tanFormatValid() {
+        val viewModel = createInstance()
+        viewModel.state.observeForever { }
+
+        viewModel.onTanChanged("ZWFPC7NG47")
+        viewModel.state.value!!.isTanValid shouldBe true
+
+        viewModel.onTanChanged("ABC")
+        viewModel.state.value!!.isTanValid shouldBe false
+
+        viewModel.onTanChanged("ZWFPC7NG48")
+        viewModel.state.value!!.isTanValid shouldBe false
+
+        viewModel.onTanChanged("ZWFPC7NG4A")
+        viewModel.state.value!!.isTanValid shouldBe false
+    }
+
+    @Test
+    fun testTanStorage() {
+        val sr = mockk<SubmissionRepository> {
+            every { setTeletan(any()) } just Runs
+        }
+        val tan = "ZWFPC7NG47"
+        sr.setTeletan(tan)
+
+        verify(exactly = 1) {
+            sr.setTeletan(
+                withArg {
+                    it shouldBe tan
+                }
+            )
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/TanHelperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt
similarity index 63%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/TanHelperTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt
index 7a70f8c5493057354b1953815cd5869051bb252d..4a011b972587c5bc14a74e2b0bf002a1e70259ce 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/TanHelperTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt
@@ -1,10 +1,10 @@
-package de.rki.coronawarnapp.util
+package de.rki.coronawarnapp.ui.submission.tan
 
-import org.hamcrest.CoreMatchers
-import org.hamcrest.MatcherAssert
-import org.junit.Test
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
 
-class TanHelperTest {
+class TanTest : BaseTest() {
 
     @Test
     fun isValidCharacter() {
@@ -14,10 +14,7 @@ class TanHelperTest {
             "J", "K", "M", "N", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"
         )
         for (character in validCharacters) {
-            MatcherAssert.assertThat(
-                TanHelper.isTanCharacterValid(character),
-                CoreMatchers.equalTo(true)
-            )
+            Tan.isTanCharacterValid(character) shouldBe true
         }
 
         // invalid
@@ -26,10 +23,7 @@ class TanHelperTest {
             "c", "ö", "ß", "é", ".", " ", "€", "(", ")", ";", ","
         )
         for (character in invalidCharacters) {
-            MatcherAssert.assertThat(
-                TanHelper.isTanCharacterValid(character),
-                CoreMatchers.equalTo(false)
-            )
+            Tan.isTanCharacterValid(character) shouldBe false
         }
     }
 
@@ -40,10 +34,7 @@ class TanHelperTest {
             "ABCD", "2345", "PTPHM35RP4", "AAAAAAAAAA", "BBBBB"
         )
         for (text in validStrings) {
-            MatcherAssert.assertThat(
-                TanHelper.allCharactersValid(text),
-                CoreMatchers.equalTo(true)
-            )
+            Tan.allCharactersValid(text) shouldBe true
         }
 
         // invalid input strings
@@ -51,10 +42,7 @@ class TanHelperTest {
             "ABCDÖ", "01234", "PTPHM15RP4", "AAAAAA AAA", "BB.BBB"
         )
         for (text in invalidStrings) {
-            MatcherAssert.assertThat(
-                TanHelper.allCharactersValid(text),
-                CoreMatchers.equalTo(false)
-            )
+            Tan.allCharactersValid(text) shouldBe false
         }
     }
 
@@ -65,10 +53,7 @@ class TanHelperTest {
             "9A3B578UMG", "DEU7TKSV3H", "PTPHM35RP4", "V923D59AT8", "H9NC5CQ34E"
         )
         for (tan in validTans) {
-            MatcherAssert.assertThat(
-                TanHelper.isChecksumValid(tan),
-                CoreMatchers.equalTo(true)
-            )
+            Tan.isChecksumValid(tan) shouldBe true
         }
 
         // invalid
@@ -81,10 +66,7 @@ class TanHelperTest {
             "9A3B578UM0", "DEU7TKSV31", "Q4XBJCB43", "929B96CA8"
         )
         for (tan in invalidTans) {
-            MatcherAssert.assertThat(
-                TanHelper.isChecksumValid(tan),
-                CoreMatchers.equalTo(false)
-            )
+            Tan.isChecksumValid(tan) shouldBe false
         }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
index 9a22c516dbff73ac424f0d72bb83f95d219ab459..93154d75926eaf91c65b80950faeac0735a74072 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
@@ -249,61 +249,61 @@ class TracingDetailsStateTest : BaseTest() {
     fun `risk details buttons visibility`() {
         createInstance(
             riskLevelScore = RiskLevelConstants.INCREASED_RISK,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe true
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe true
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.LOW_LEVEL_RISK,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.UNKNOWN_RISK_INITIAL,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.INCREASED_RISK,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe true
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe true
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe true
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.LOW_LEVEL_RISK,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe true
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.UNKNOWN_RISK_INITIAL,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             areRiskDetailsButtonsVisible() shouldBe true
         }
@@ -338,62 +338,62 @@ class TracingDetailsStateTest : BaseTest() {
     fun `risk details update button visibility`() {
         createInstance(
             riskLevelScore = RiskLevelConstants.INCREASED_RISK,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.LOW_LEVEL_RISK,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.UNKNOWN_RISK_INITIAL,
-            isBackgroundJobEnabled = true,
+            isBackgroundJobEnabled = true
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe false
         }
 
         createInstance(
             riskLevelScore = RiskLevelConstants.INCREASED_RISK,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe true
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe false
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.LOW_LEVEL_RISK,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe true
         }
         createInstance(
             riskLevelScore = RiskLevelConstants.UNKNOWN_RISK_INITIAL,
-            isBackgroundJobEnabled = false,
+            isBackgroundJobEnabled = false
         ).apply {
             isRiskDetailsUpdateButtonVisible() shouldBe true
         }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt
index ad8d1c58d286307a36d310eff65450b97042a18e..3384063bf964993859ccb719cdf16ffe23aa32d1 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt
@@ -14,7 +14,8 @@ import java.io.File
 
 class HashExtensionsTest : BaseIOTest() {
 
-    private val testInput = "The Cake Is A Lie"
+    private val testInputText = "The Cake Is A Lie"
+    private val testInputByteArray = testInputText.toByteArray()
     private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!)
 
     @BeforeEach
@@ -31,17 +32,32 @@ class HashExtensionsTest : BaseIOTest() {
 
     @Test
     fun `hash string to MD5`() {
-        testInput.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179"
+        testInputText.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179"
     }
 
     @Test
     fun `hash string to SHA256`() {
-        testInput.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61"
+        testInputText.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61"
     }
 
     @Test
     fun `hash string to SHA1`() {
-        testInput.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76"
+        testInputText.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76"
+    }
+
+    @Test
+    fun `hash bytearray to MD5`() {
+        testInputByteArray.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179"
+    }
+
+    @Test
+    fun `hash bytearray to SHA256`() {
+        testInputByteArray.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61"
+    }
+
+    @Test
+    fun `hash bytearray to SHA1`() {
+        testInputByteArray.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76"
     }
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
index 0a5702aa451a11d0064f51a1d9f97fa5e54ab376..3de7010f7bf7105aa25a8738dbcd6772f0c140b4 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
@@ -1,5 +1,6 @@
 package de.rki.coronawarnapp.util.flow
 
+import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.matchers.shouldBe
 import io.kotest.matchers.types.instanceOf
 import io.mockk.coEvery
@@ -7,10 +8,13 @@ import io.mockk.coVerify
 import io.mockk.mockk
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.test.TestCoroutineScope
+import kotlinx.coroutines.withTimeoutOrNull
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 import testhelpers.coroutines.runBlockingTest2
@@ -22,23 +26,47 @@ import kotlin.concurrent.thread
 class HotDataFlowTest : BaseTest() {
 
     @Test
-    fun `init call only happens on first collection`() {
+    fun `init happens on first collection and exception is forwarded`() {
         val testScope = TestCoroutineScope()
         val hotData = HotDataFlow<String>(
             loggingTag = "tag",
             scope = testScope,
             coroutineContext = Dispatchers.Unconfined,
-            startValueProvider = {
-                throw IOException()
-            }
+            startValueProvider = { throw IOException() }
         )
 
-        testScope.apply {
-            runBlockingTest2(permanentJobs = true) {
+        runBlocking {
+            // This blocking scope get's the init exception as the first caller
+            shouldThrow<IOException> {
                 hotData.data.first()
             }
-            uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
         }
+
+        testScope.advanceUntilIdle()
+
+        testScope.uncaughtExceptions.singleOrNull() shouldBe null
+    }
+
+    @Test
+    fun `exception is not forwarded if flag is set`() {
+        val testScope = TestCoroutineScope()
+        val hotData = HotDataFlow<String>(
+            loggingTag = "tag",
+            scope = testScope,
+            coroutineContext = Dispatchers.Unconfined,
+            forwardException = false,
+            startValueProvider = { throw IOException() }
+        )
+        runBlocking {
+            withTimeoutOrNull(500) {
+                // This blocking scope get's the init exception as the first caller
+                hotData.data.firstOrNull()
+            } shouldBe null
+        }
+
+        testScope.advanceUntilIdle()
+
+        testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
     }
 
     @Test
@@ -141,7 +169,6 @@ class HotDataFlowTest : BaseTest() {
         hotData.updateSafely { "2" }
         hotData.updateSafely { "1" }
 
-
         runBlocking {
             testCollector.await { list, l -> list.size == 3 }
             testCollector.latestValues shouldBe listOf("1", "2", "1")
@@ -161,7 +188,6 @@ class HotDataFlowTest : BaseTest() {
             sharingBehavior = SharingStarted.Lazily
         )
 
-
         testScope.runBlockingTest2(permanentJobs = true) {
             val sub1 = hotData.data.test().start(scope = this)
             val sub2 = hotData.data.test().start(scope = this)
@@ -181,4 +207,35 @@ class HotDataFlowTest : BaseTest() {
         }
         coVerify(exactly = 1) { valueProvider.invoke(any()) }
     }
+
+    @Test
+    fun `blocking update is actually blocking`() = runBlocking {
+        val testScope = TestCoroutineScope()
+        val hotData = HotDataFlow(
+            loggingTag = "tag",
+            scope = testScope,
+            coroutineContext = testScope.coroutineContext,
+            startValueProvider = {
+                delay(2000)
+                2
+            },
+            sharingBehavior = SharingStarted.Lazily
+        )
+
+        hotData.updateSafely {
+            delay(2000)
+            this + 1
+        }
+
+        val testCollector = hotData.data.test(startOnScope = testScope)
+
+        testScope.advanceUntilIdle()
+
+        hotData.updateBlocking { this - 3 } shouldBe 0
+
+        testCollector.await { list, i -> i == 3 }
+        testCollector.latestValues shouldBe listOf(2, 3, 0)
+
+        testCollector.cancel()
+    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..f5e88d201075dd4f3f38dd53c5a5772422862a55
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt
@@ -0,0 +1,73 @@
+package de.rki.coronawarnapp.util.serialization.adapter
+
+import com.google.gson.GsonBuilder
+import com.google.gson.JsonParseException
+import de.rki.coronawarnapp.util.serialization.fromJson
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.shouldBe
+import okio.ByteString.Companion.decodeHex
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class ByteArrayAdapterTest : BaseTest() {
+
+    private val gson = GsonBuilder()
+        .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter())
+        .create()
+
+    // This is actually an app config, some cases like did not trigger a few serialization issues in the server test.
+    private val goodByteArray = (
+        "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" +
+            "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" +
+            "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" +
+            "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" +
+            "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" +
+            "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804"
+        ).decodeHex().toByteArray()
+
+    @Test
+    fun `serialize and deserialize`() {
+        val serialized: String = gson.toJson(TestData(goodByteArray))
+
+        gson.fromJson<TestData>(serialized) shouldBe TestData(goodByteArray)
+    }
+
+    @Test
+    fun `malformed base64 should throw specific exception`() {
+        shouldThrow<JsonParseException> {
+            """
+                {
+                    "byteArray": "Don't feed this to your base 64 decoder :("
+                }
+            """.trimIndent().let { gson.fromJson<TestData>(it) }
+        }
+    }
+
+    @Test
+    fun `empty base64 string is OK`() {
+        """
+            {
+                "byteArray": ""
+            }
+        """.trimIndent().let {
+            gson.fromJson<TestData>(it) shouldBe TestData(ByteArray(0))
+        }
+    }
+
+    data class TestData(
+        val byteArray: ByteArray
+    ) {
+        override fun equals(other: Any?): Boolean {
+            if (this === other) return true
+            if (javaClass != other?.javaClass) return false
+
+            other as TestData
+
+            if (!byteArray.contentEquals(other.byteArray)) return false
+
+            return true
+        }
+
+        override fun hashCode(): Int = byteArray.contentHashCode()
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..37b956e809b2c776896f4f06f37b3f45952c08c4
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt
@@ -0,0 +1,80 @@
+package de.rki.coronawarnapp.util.worker
+
+import androidx.work.ListenableWorker
+import dagger.Component
+import dagger.Module
+import dagger.Provides
+import de.rki.coronawarnapp.playbook.Playbook
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.util.di.AssistedInjectModule
+import io.github.classgraph.ClassGraph
+import io.kotest.matchers.collections.shouldContainAll
+import io.mockk.mockk
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import timber.log.Timber
+import javax.inject.Provider
+import javax.inject.Singleton
+
+class WorkerBinderTest : BaseTest() {
+
+    /**
+     * If one of our factories is not part of the factory map provided to **[CWAWorkerFactory]**,
+     * then the lookup will fail and an exception thrown.
+     * This can't be checked at compile-time and may create subtle errors that will not immediately be caught.
+     *
+     * This test uses the ClassGraph library to scan our package, find all worker classes,
+     * and makes sure that they are all bound into our factory map.
+     * Creating a new factory that is not registered or removing one from **[WorkerBinder]**
+     * will cause this test to fail.
+     */
+    @Test
+    fun `all worker factory are bound into the factory map`() {
+        val component = DaggerWorkerTestComponent.factory().create()
+        val factories = component.factories
+
+        Timber.v("We know %d worker factories.", factories.size)
+        factories.keys.forEach {
+            Timber.v("Registered: ${it.name}")
+        }
+        require(component.factories.isNotEmpty())
+
+        val scanResult = ClassGraph()
+            .acceptPackages("de.rki.coronawarnapp")
+            .enableClassInfo()
+            .scan()
+
+        val ourWorkerClasses = scanResult
+            .getSubclasses("androidx.work.ListenableWorker")
+            .filterNot { it.name.startsWith("androidx.work") }
+
+        Timber.v("Our project contains %d worker classes.", ourWorkerClasses.size)
+        ourWorkerClasses.forEach { Timber.v("Existing: ${it.name}") }
+
+        val boundFactories = factories.keys.map { it.name }
+        val existingFactories = ourWorkerClasses.map { it.name }
+        boundFactories shouldContainAll existingFactories
+    }
+}
+
+@Singleton
+@Component(modules = [AssistedInjectModule::class, WorkerBinder::class, MockProvider::class])
+interface WorkerTestComponent {
+
+    val factories: @JvmSuppressWildcards Map<Class<out ListenableWorker>, Provider<InjectedWorkerFactory<out ListenableWorker>>>
+
+    @Component.Factory
+    interface Factory {
+        fun create(): WorkerTestComponent
+    }
+}
+
+@Module
+class MockProvider {
+    // For BackgroundNoiseOneTimeWorker
+    @Provides
+    fun playbook(): Playbook = mockk()
+
+    @Provides
+    fun taskController(): TaskController = mockk()
+}
diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
index bcf45781d2a11ea590622c2f157acee224bd5e02..b5a62565aa2ee00e0fb214a9d1c6d53453d7199a 100644
--- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
+++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
@@ -13,13 +13,13 @@ fun TestCoroutineScope.runBlockingTest2(
     permanentJobs: Boolean = false,
     block: suspend TestCoroutineScope.() -> Unit
 ): Unit = runBlockingTest2(
-    permanentJobs = permanentJobs,
+    ignoreActive = permanentJobs,
     context = coroutineContext,
     testBody = block
 )
 
 fun runBlockingTest2(
-    permanentJobs: Boolean = false,
+    ignoreActive: Boolean = false,
     context: CoroutineContext = EmptyCoroutineContext,
     testBody: suspend TestCoroutineScope.() -> Unit
 ) {
@@ -31,14 +31,12 @@ fun runBlockingTest2(
                     testBody = testBody
                 )
             } catch (e: UncompletedCoroutinesError) {
-                if (!permanentJobs) throw e
+                if (!ignoreActive) throw e
             }
         }
     } catch (e: Exception) {
-        if (!permanentJobs || (e.message != "This job has not completed yet")) {
+        if (!ignoreActive || (e.message != "This job has not completed yet")) {
             throw e
         }
     }
 }
-
-
diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt
index d375d845247ab0d43918afb07792929fe63d84b1..b132c1dec5cc6518c811289293f39578b911b531 100644
--- a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt
+++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt
@@ -1,57 +1,29 @@
 package de.rki.coronawarnapp.test.api.ui
 
 import android.content.Context
-import androidx.lifecycle.Observer
 import de.rki.coronawarnapp.environment.EnvironmentSetup
-import de.rki.coronawarnapp.storage.TestSettings
-import io.kotest.matchers.shouldBe
+import de.rki.coronawarnapp.task.TaskController
 import io.mockk.MockKAnnotations
-import io.mockk.Runs
 import io.mockk.clearAllMocks
-import io.mockk.every
 import io.mockk.impl.annotations.MockK
-import io.mockk.just
-import io.mockk.mockk
-import io.mockk.verify
-import kotlinx.coroutines.ExperimentalCoroutinesApi
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.extension.ExtendWith
 import testhelpers.BaseTest
 import testhelpers.extensions.CoroutinesTestExtension
 import testhelpers.extensions.InstantExecutorExtension
-import testhelpers.flakyTest
-import kotlin.time.ExperimentalTime
 
-@ExperimentalTime
-@ExperimentalCoroutinesApi
 @ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class)
 class TestForApiFragmentViewModelTest : BaseTest() {
 
-    @MockK private lateinit var environmentSetup: EnvironmentSetup
     @MockK private lateinit var context: Context
-    @MockK private lateinit var testSettings: TestSettings
+    @MockK lateinit var taskController: TaskController
 
     private var currentEnvironment = EnvironmentSetup.Type.DEV
 
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
-        currentEnvironment = EnvironmentSetup.Type.DEV
-
-        every { environmentSetup.defaultEnvironment } returns EnvironmentSetup.Type.DEV
-        every { environmentSetup.submissionCdnUrl } returns "submissionUrl"
-        every { environmentSetup.downloadCdnUrl } returns "downloadUrl"
-        every { environmentSetup.verificationCdnUrl } returns "verificationUrl"
-
-        every { environmentSetup.currentEnvironment = any() } answers {
-            currentEnvironment = arg(0)
-            Unit
-        }
-        every { environmentSetup.currentEnvironment } answers {
-            currentEnvironment
-        }
     }
 
     @AfterEach
@@ -60,44 +32,7 @@ class TestForApiFragmentViewModelTest : BaseTest() {
     }
 
     private fun createViewModel(): TestForApiFragmentViewModel = TestForApiFragmentViewModel(
-        envSetup = environmentSetup,
         context = context,
-        testSettings = testSettings
+        taskController = taskController
     )
-
-    @Test
-    fun `toggeling the env works`() = flakyTest {
-        currentEnvironment = EnvironmentSetup.Type.DEV
-        val vm = createViewModel()
-
-        val states = mutableListOf<EnvironmentState>()
-        val observerState = mockk<Observer<EnvironmentState>>()
-        every { observerState.onChanged(capture(states)) } just Runs
-        vm.environmentState.observeForever(observerState)
-
-        val events = mutableListOf<EnvironmentSetup.Type>()
-        val observerEvent = mockk<Observer<EnvironmentSetup.Type>>()
-        every { observerEvent.onChanged(capture(events)) } just Runs
-        vm.environmentChangeEvent.observeForever(observerEvent)
-
-        vm.selectEnvironmentTytpe(EnvironmentSetup.Type.DEV.rawKey)
-        vm.selectEnvironmentTytpe(EnvironmentSetup.Type.WRU_XA.rawKey)
-
-        verify(exactly = 3, timeout = 3000) { observerState.onChanged(any()) }
-        verify(exactly = 2, timeout = 3000) { observerEvent.onChanged(any()) }
-
-        states[0].apply {
-            current shouldBe EnvironmentSetup.Type.DEV
-        }
-
-        states[1].apply {
-            current shouldBe EnvironmentSetup.Type.DEV
-        }
-        events[0] shouldBe EnvironmentSetup.Type.DEV
-
-        states[2].apply {
-            current shouldBe EnvironmentSetup.Type.WRU_XA
-        }
-        events[1] shouldBe EnvironmentSetup.Type.WRU_XA
-    }
 }
diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..42ad7c156bb1ceff1fb4a3603f1849edb92f444c
--- /dev/null
+++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt
@@ -0,0 +1,105 @@
+package de.rki.coronawarnapp.test.debugoptions.ui
+
+import android.content.Context
+import androidx.lifecycle.Observer
+import de.rki.coronawarnapp.environment.EnvironmentSetup
+import de.rki.coronawarnapp.storage.TestSettings
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.test.api.ui.EnvironmentState
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.extension.ExtendWith
+import testhelpers.BaseTest
+import testhelpers.TestDispatcherProvider
+import testhelpers.extensions.CoroutinesTestExtension
+import testhelpers.extensions.InstantExecutorExtension
+import testhelpers.flakyTest
+
+@ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class)
+class DebugOptionsFragmentViewModelTest : BaseTest() {
+
+    @MockK private lateinit var environmentSetup: EnvironmentSetup
+    @MockK private lateinit var context: Context
+    @MockK private lateinit var testSettings: TestSettings
+    @MockK lateinit var taskController: TaskController
+
+    private var currentEnvironment = EnvironmentSetup.Type.DEV
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        currentEnvironment = EnvironmentSetup.Type.DEV
+
+        every { environmentSetup.defaultEnvironment } returns EnvironmentSetup.Type.DEV
+        every { environmentSetup.submissionCdnUrl } returns "submissionUrl"
+        every { environmentSetup.downloadCdnUrl } returns "downloadUrl"
+        every { environmentSetup.verificationCdnUrl } returns "verificationUrl"
+
+        every { environmentSetup.currentEnvironment = any() } answers {
+            currentEnvironment = arg(0)
+            Unit
+        }
+        every { environmentSetup.currentEnvironment } answers {
+            currentEnvironment
+        }
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createViewModel(): DebugOptionsFragmentViewModel = DebugOptionsFragmentViewModel(
+        context = context,
+        taskController = taskController,
+        envSetup = environmentSetup,
+        testSettings = testSettings,
+        dispatcherProvider = TestDispatcherProvider
+    )
+
+    @Test
+    fun `toggeling the env works`() = flakyTest {
+        currentEnvironment = EnvironmentSetup.Type.DEV
+        val vm = createViewModel()
+
+        val states = mutableListOf<EnvironmentState>()
+        val observerState = mockk<Observer<EnvironmentState>>()
+        every { observerState.onChanged(capture(states)) } just Runs
+        vm.environmentState.observeForever(observerState)
+
+        val events = mutableListOf<EnvironmentSetup.Type>()
+        val observerEvent = mockk<Observer<EnvironmentSetup.Type>>()
+        every { observerEvent.onChanged(capture(events)) } just Runs
+        vm.environmentChangeEvent.observeForever(observerEvent)
+
+        vm.selectEnvironmentTytpe(EnvironmentSetup.Type.DEV.rawKey)
+        vm.selectEnvironmentTytpe(EnvironmentSetup.Type.WRU_XA.rawKey)
+
+        verify(exactly = 3, timeout = 3000) { observerState.onChanged(any()) }
+        verify(exactly = 2, timeout = 3000) { observerEvent.onChanged(any()) }
+
+        states[0].apply {
+            current shouldBe EnvironmentSetup.Type.DEV
+        }
+
+        states[1].apply {
+            current shouldBe EnvironmentSetup.Type.DEV
+        }
+        events[0] shouldBe EnvironmentSetup.Type.DEV
+
+        states[2].apply {
+            current shouldBe EnvironmentSetup.Type.WRU_XA
+        }
+        events[1] shouldBe EnvironmentSetup.Type.WRU_XA
+    }
+}
diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModelTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModelTest.kt
index b375b726b851f75af98ee52d0f741a34fbc89eb0..e626c3e92891f7570e0a386fd23749db6e8a44cb 100644
--- a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModelTest.kt
+++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModelTest.kt
@@ -5,17 +5,20 @@ import androidx.lifecycle.SavedStateHandle
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.risk.RiskLevels
+import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction
-import de.rki.coronawarnapp.transaction.RiskLevelTransaction
 import de.rki.coronawarnapp.ui.tracing.card.TracingCardStateProvider
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
+import io.mockk.Runs
 import io.mockk.clearAllMocks
 import io.mockk.coEvery
 import io.mockk.coVerify
 import io.mockk.coVerifyOrder
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
+import io.mockk.just
 import io.mockk.mockk
 import io.mockk.mockkObject
 import kotlinx.coroutines.flow.flowOf
@@ -37,6 +40,8 @@ class TestRiskLevelCalculationFragmentCWAViewModelTest : BaseTest() {
     @MockK lateinit var exposureNotificationClient: ExposureNotificationClient
     @MockK lateinit var keyCacheRepository: KeyCacheRepository
     @MockK lateinit var tracingCardStateProvider: TracingCardStateProvider
+    @MockK lateinit var taskController: TaskController
+    @MockK lateinit var riskLevels: RiskLevels
 
     @BeforeEach
     fun setup() {
@@ -44,12 +49,11 @@ class TestRiskLevelCalculationFragmentCWAViewModelTest : BaseTest() {
 
         mockkObject(RetrieveDiagnosisKeysTransaction)
         coEvery { RetrieveDiagnosisKeysTransaction.start() } returns Unit
-        mockkObject(RiskLevelTransaction)
-        coEvery { RiskLevelTransaction.start() } returns Unit
 
         coEvery { keyCacheRepository.clear() } returns Unit
         every { enfClient.internalClient } returns exposureNotificationClient
         every { tracingCardStateProvider.state } returns flowOf(mockk())
+        every { taskController.submit(any()) } just Runs
     }
 
     @AfterEach
@@ -65,7 +69,9 @@ class TestRiskLevelCalculationFragmentCWAViewModelTest : BaseTest() {
             enfClient = enfClient,
             keyCacheRepository = keyCacheRepository,
             tracingCardStateProvider = tracingCardStateProvider,
-            dispatcherProvider = TestDispatcherProvider
+            dispatcherProvider = TestDispatcherProvider,
+            riskLevels = riskLevels,
+            taskController = taskController
         )
 
     @Test
@@ -76,7 +82,7 @@ class TestRiskLevelCalculationFragmentCWAViewModelTest : BaseTest() {
 
         coVerifyOrder {
             RetrieveDiagnosisKeysTransaction.start()
-            RiskLevelTransaction.start()
+            taskController.submit(any())
         }
     }
 
@@ -86,7 +92,7 @@ class TestRiskLevelCalculationFragmentCWAViewModelTest : BaseTest() {
 
         vm.calculateRiskLevel()
 
-        coVerify(exactly = 1) { RiskLevelTransaction.start() }
+        coVerify(exactly = 1) { taskController.submit(any()) }
         coVerify(exactly = 0) { RetrieveDiagnosisKeysTransaction.start() }
     }
 
diff --git a/Server-Protocol-Buffer/src/main/proto/internal/app_config.proto b/Server-Protocol-Buffer/src/main/proto/internal/app_config.proto
index 5811db50fb6d8dfeffd4ed1dbced36d7c66947c5..e1e5437dfc6970c892b5cb16a18490566f4ca54f 100644
--- a/Server-Protocol-Buffer/src/main/proto/internal/app_config.proto
+++ b/Server-Protocol-Buffer/src/main/proto/internal/app_config.proto
@@ -7,6 +7,8 @@ import "internal/risk_score_parameters.proto";
 import "internal/app_version_config.proto";
 import "internal/attenuation_duration.proto";
 import "internal/app_features.proto";
+import "internal/exposure_detection_parameters.proto";
+import "internal/key_download_parameters.proto";
 
 message ApplicationConfiguration {
 
@@ -23,4 +25,10 @@ message ApplicationConfiguration {
   AppFeatures appFeatures = 6;
 
   repeated string supportedCountries = 7;
+
+  KeyDownloadParametersIOS iosKeyDownloadParameters = 8;
+  KeyDownloadParametersAndroid androidKeyDownloadParameters = 9;
+
+  ExposureDetectionParametersIOS iosExposureDetectionParameters = 10;
+  ExposureDetectionParametersAndroid androidExposureDetectionParameters= 11;
 }
diff --git a/Server-Protocol-Buffer/src/main/proto/internal/exposure_detection_parameters.proto b/Server-Protocol-Buffer/src/main/proto/internal/exposure_detection_parameters.proto
new file mode 100644
index 0000000000000000000000000000000000000000..b998a852f32c9636ecded3020edd8c4122380fa2
--- /dev/null
+++ b/Server-Protocol-Buffer/src/main/proto/internal/exposure_detection_parameters.proto
@@ -0,0 +1,16 @@
+// This file is auto-generated, DO NOT make any changes here
+syntax = "proto3";
+package de.rki.coronawarnapp.server.protocols.internal;
+
+
+message ExposureDetectionParametersIOS {
+
+  int32 maxExposureDetectionsPerInterval = 1;
+}
+
+message ExposureDetectionParametersAndroid {
+
+  int32 maxExposureDetectionsPerInterval = 1;
+
+  int32 overallTimeoutInSeconds = 2;
+}
\ No newline at end of file
diff --git a/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto b/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto
new file mode 100644
index 0000000000000000000000000000000000000000..00c58f526b33cb2a6659cfe4939178d32142cf61
--- /dev/null
+++ b/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto
@@ -0,0 +1,33 @@
+// This file is auto-generated, DO NOT make any changes here
+syntax = "proto3";
+package de.rki.coronawarnapp.server.protocols.internal;
+
+
+message KeyDownloadParametersIOS {
+
+  repeated DayPackageMetadata cachedDayPackagesToUpdateOnETagMismatch = 1;
+  repeated HourPackageMetadata cachedHourPackagesToUpdateOnETagMismatch = 2;
+}
+
+message KeyDownloadParametersAndroid {
+
+  repeated DayPackageMetadata cachedDayPackagesToUpdateOnETagMismatch = 1;
+  repeated HourPackageMetadata cachedHourPackagesToUpdateOnETagMismatch = 2;
+
+  int32 downloadTimeoutInSeconds = 3;
+
+  int32 overallTimeoutInSeconds = 4;
+}
+
+message DayPackageMetadata {
+  string region = 1;
+  string date = 2;
+  string etag = 3;
+}
+
+message HourPackageMetadata {
+  string region = 1;
+  string date = 2;
+  int32 hour = 3;
+  string etag = 4;
+}
\ No newline at end of file