diff --git a/.circleci/config.yml b/.circleci/config.yml
index bd3e41ef4d3b6a360b3ba1e2c0b32bae9ca3b0ae..3d9ef399f05f2764eb0416479b3d909f44ee28d3 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -628,7 +628,6 @@ workflows:
   check_buildtype_device:
     jobs:
       - detekt
-      - firebase_screenshots
       - lint_device_release_check
       - ktlint_device_release_check
       - quick_build_device_release_no_tests
diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle
index d5d111ec6da6949571f83af296bd608644a080fa..e7fda93ad22d7261d4db870b6dd3ad44bdeaccf0 100644
--- a/Corona-Warn-App/build.gradle
+++ b/Corona-Warn-App/build.gradle
@@ -301,6 +301,7 @@ dependencies {
     implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutineVersion"
     testImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion"
     testImplementation "org.jetbrains.kotlin:kotlin-reflect:1.4.21"
+    androidTestImplementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutineVersion"
 
     // ANDROID STANDARD
     def nav_version = "2.3.3"
diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDatabase/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDatabase/1.json
new file mode 100644
index 0000000000000000000000000000000000000000..8b761004668eb2715f3ca78e6aeaf955de43f997
--- /dev/null
+++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDatabase/1.json
@@ -0,0 +1,140 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 1,
+    "identityHash": "7353e2a0b1a9b8a18e316fccdf3ee651",
+    "entities": [
+      {
+        "tableName": "AnalyticsExposureWindowEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha256Hash` TEXT NOT NULL, `calibrationConfidence` INTEGER NOT NULL, `dateMillis` INTEGER NOT NULL, `infectiousness` INTEGER NOT NULL, `reportType` INTEGER NOT NULL, `normalizedTime` REAL NOT NULL, `transmissionRiskLevel` INTEGER NOT NULL, PRIMARY KEY(`sha256Hash`))",
+        "fields": [
+          {
+            "fieldPath": "sha256Hash",
+            "columnName": "sha256Hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "calibrationConfidence",
+            "columnName": "calibrationConfidence",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dateMillis",
+            "columnName": "dateMillis",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "infectiousness",
+            "columnName": "infectiousness",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "reportType",
+            "columnName": "reportType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "normalizedTime",
+            "columnName": "normalizedTime",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transmissionRiskLevel",
+            "columnName": "transmissionRiskLevel",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "sha256Hash"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "AnalyticsScanInstanceEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `fkSha256Hash` TEXT NOT NULL, `minAttenuation` INTEGER NOT NULL, `typicalAttenuation` INTEGER NOT NULL, `secondsSinceLastScan` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": false
+          },
+          {
+            "fieldPath": "fkSha256Hash",
+            "columnName": "fkSha256Hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "minAttenuation",
+            "columnName": "minAttenuation",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "typicalAttenuation",
+            "columnName": "typicalAttenuation",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "secondsSinceLastScan",
+            "columnName": "secondsSinceLastScan",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "AnalyticsReportedExposureWindowEntity",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha256Hash` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`sha256Hash`))",
+        "fields": [
+          {
+            "fieldPath": "sha256Hash",
+            "columnName": "sha256Hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "timestamp",
+            "columnName": "timestamp",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "sha256Hash"
+          ],
+          "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, '7353e2a0b1a9b8a18e316fccdf3ee651')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.ExposureWindowContributionDao/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.ExposureWindowContributionDao/1.json
new file mode 100644
index 0000000000000000000000000000000000000000..8e46af150b400e829d51601bdd435ae1aa510e49
--- /dev/null
+++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.ExposureWindowContributionDao/1.json
@@ -0,0 +1,140 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 1,
+    "identityHash": "837a6417171913af42aac6007ed1e2b1",
+    "entities": [
+      {
+        "tableName": "exposureWindows",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha256Hash` TEXT NOT NULL, `calibrationConfidence` INTEGER NOT NULL, `dateMillis` INTEGER NOT NULL, `infectiousness` INTEGER NOT NULL, `reportType` INTEGER NOT NULL, `normalizedTime` REAL NOT NULL, `transmissionRiskLevel` INTEGER NOT NULL, PRIMARY KEY(`sha256Hash`))",
+        "fields": [
+          {
+            "fieldPath": "sha256Hash",
+            "columnName": "sha256Hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "calibrationConfidence",
+            "columnName": "calibrationConfidence",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "dateMillis",
+            "columnName": "dateMillis",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "infectiousness",
+            "columnName": "infectiousness",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "reportType",
+            "columnName": "reportType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "normalizedTime",
+            "columnName": "normalizedTime",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "transmissionRiskLevel",
+            "columnName": "transmissionRiskLevel",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "sha256Hash"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "scanInstances",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `fkSha256Hash` TEXT NOT NULL, `minAttenuation` INTEGER NOT NULL, `typicalAttenuation` INTEGER NOT NULL, `secondsSinceLastScan` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fkSha256Hash",
+            "columnName": "fkSha256Hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "minAttenuation",
+            "columnName": "minAttenuation",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "typicalAttenuation",
+            "columnName": "typicalAttenuation",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "secondsSinceLastScan",
+            "columnName": "secondsSinceLastScan",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": true
+        },
+        "indices": [],
+        "foreignKeys": []
+      },
+      {
+        "tableName": "reportedExposureWindows",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sha256Hash` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`sha256Hash`))",
+        "fields": [
+          {
+            "fieldPath": "sha256Hash",
+            "columnName": "sha256Hash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "timestamp",
+            "columnName": "timestamp",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "sha256Hash"
+          ],
+          "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, '837a6417171913af42aac6007ed1e2b1')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsDatabaseTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsDatabaseTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e6cbd89e747816cb8bc819bd8c421cf036f06695
--- /dev/null
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsDatabaseTest.kt
@@ -0,0 +1,75 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+import androidx.room.Room
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import io.kotest.matchers.shouldBe
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import testhelpers.BaseTestInstrumentation
+
+@RunWith(AndroidJUnit4::class)
+class AnalyticsExposureWindowsDatabaseTest : BaseTestInstrumentation() {
+
+    private val database: AnalyticsExposureWindowDatabase =
+        Room.inMemoryDatabaseBuilder(
+            ApplicationProvider.getApplicationContext(),
+            AnalyticsExposureWindowDatabase::class.java
+        )
+            .build()
+
+    private val dao = database.analyticsExposureWindowDao()
+
+    @After
+    fun teardown() {
+        database.clearAllTables()
+    }
+
+    @Test
+    fun testMoveToReportedAndRollback() = runBlocking {
+        // insert new
+        val exposureWindowEntity = AnalyticsExposureWindowEntity("hash", 1, 1, 1, 1, 1.0, 1)
+        val scanInstance = AnalyticsScanInstanceEntity(null, "hash", 1, 1, 1)
+        val wrapper = AnalyticsExposureWindowEntityWrapper(exposureWindowEntity, listOf(scanInstance))
+        dao.insert(listOf(wrapper))
+        val allNew = dao.getAllNew()
+        allNew.size shouldBe 1
+        allNew[0].exposureWindowEntity.sha256Hash shouldBe "hash"
+
+        // move to reported
+        dao.moveToReported(listOf(wrapper), 999999)
+        dao.getAllNew() shouldBe listOf()
+        val reported = dao.getReported("hash")
+        reported!!.sha256Hash shouldBe "hash"
+
+        // rollback
+        dao.rollback(listOf(wrapper), listOf(reported))
+        val allNew2 = dao.getAllNew()
+        allNew2.size shouldBe 1
+        allNew2[0].exposureWindowEntity.sha256Hash shouldBe "hash"
+        dao.getReported("hash") shouldBe null
+    }
+
+    @Test
+    fun testDeleteStaleReported() = runBlocking {
+        // insert new
+        val exposureWindowEntity = AnalyticsExposureWindowEntity("hash", 1, 1, 1, 1, 1.0, 1)
+        val scanInstance = AnalyticsScanInstanceEntity(null, "hash", 1, 1, 1)
+        val wrapper = AnalyticsExposureWindowEntityWrapper(exposureWindowEntity, listOf(scanInstance))
+        val exposureWindowEntity2 = AnalyticsExposureWindowEntity("hash2", 1, 1, 1, 1, 1.0, 1)
+        val scanInstance2 = AnalyticsScanInstanceEntity(null, "hash2", 1, 1, 1)
+        val wrapper2 = AnalyticsExposureWindowEntityWrapper(exposureWindowEntity2, listOf(scanInstance2))
+        dao.insert(listOf(wrapper, wrapper2))
+
+        // move to reported
+        dao.moveToReported(listOf(wrapper), 999990)
+        dao.moveToReported(listOf(wrapper2), 999999)
+
+        // delete stale
+        dao.deleteReportedOlderThan(999999)
+        dao.getReported("hash") shouldBe null
+        dao.getReported("hash2")!!.sha256Hash shouldBe "hash2"
+    }
+}
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/ContactDiaryDayFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/ContactDiaryDayFragmentTest.kt
index ae11a6cceb12d764caf8ba24dbfba16b274de8df..147a5961f1456b673ea39bad904d3f79b9a2be7d 100644
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/ContactDiaryDayFragmentTest.kt
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/ContactDiaryDayFragmentTest.kt
@@ -24,6 +24,7 @@ import io.mockk.MockKAnnotations
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
 import io.mockk.spyk
+import kotlinx.coroutines.test.TestCoroutineScope
 import org.joda.time.LocalDate
 import org.junit.After
 import org.junit.Before
@@ -128,14 +129,16 @@ class ContactDiaryDayFragmentTest : BaseUITest() {
         personListViewModel = spyk(
             ContactDiaryPersonListViewModel(
                 TestDispatcherProvider(),
+                TestCoroutineScope(),
                 selectedDay,
-                contactDiaryRepository
+                contactDiaryRepository,
             )
         )
 
         locationListViewModel = spyk(
             ContactDiaryLocationListViewModel(
                 TestDispatcherProvider(),
+                TestCoroutineScope(),
                 selectedDay,
                 contactDiaryRepository
             )
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/ContactDiaryOverviewFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/ContactDiaryOverviewFragmentTest.kt
index 382ae5d43c3ad2faf5903331bdc08f37c197fb4a..9f78f2adf4f11ef1e24d16c88e941000e2f9bae0 100644
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/ContactDiaryOverviewFragmentTest.kt
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/ContactDiaryOverviewFragmentTest.kt
@@ -4,10 +4,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
 import dagger.Module
 import dagger.android.ContributesAndroidInjector
 import de.rki.coronawarnapp.contactdiary.storage.repo.ContactDiaryRepository
+import de.rki.coronawarnapp.contactdiary.ui.exporter.ContactDiaryExporter
 import de.rki.coronawarnapp.contactdiary.ui.overview.ContactDiaryOverviewFragment
 import de.rki.coronawarnapp.contactdiary.ui.overview.ContactDiaryOverviewViewModel
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.util.TimeStamper
 import io.mockk.MockKAnnotations
 import io.mockk.impl.annotations.MockK
 import io.mockk.spyk
@@ -25,6 +27,8 @@ class ContactDiaryOverviewFragmentTest : BaseUITest() {
     @MockK lateinit var taskController: TaskController
     @MockK lateinit var contactDiaryRepository: ContactDiaryRepository
     @MockK lateinit var riskLevelStorage: RiskLevelStorage
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var exporter: ContactDiaryExporter
 
     private lateinit var viewModel: ContactDiaryOverviewViewModel
 
@@ -36,7 +40,9 @@ class ContactDiaryOverviewFragmentTest : BaseUITest() {
                 taskController = taskController,
                 dispatcherProvider = TestDispatcherProvider(),
                 contactDiaryRepository = contactDiaryRepository,
-                riskLevelStorage = riskLevelStorage
+                riskLevelStorage = riskLevelStorage,
+                timeStamper = timeStamper,
+                exporter = exporter
             )
         )
 
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/DiaryData.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/DiaryData.kt
index d46a8e8d690bca0a26585b0e89132d0eae563f26..c4a5e96b418d6347da251672d8374473cb7dbb76 100644
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/DiaryData.kt
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/contactdiary/DiaryData.kt
@@ -85,7 +85,8 @@ object DiaryData {
             onItemClick = {},
             onDurationChanged = { _, _ -> },
             onCircumstancesChanged = { _, _ -> },
-            onCircumStanceInfoClicked = {}
+            onCircumStanceInfoClicked = {},
+            onDurationDialog = { _, _ -> }
         ),
         DiaryLocationListItem(
             item = DefaultContactDiaryLocation(locationName = "Büro"),
@@ -96,7 +97,8 @@ object DiaryData {
             onItemClick = {},
             onDurationChanged = { _, _ -> },
             onCircumstancesChanged = { _, _ -> },
-            onCircumStanceInfoClicked = {}
+            onCircumStanceInfoClicked = {},
+            onDurationDialog = { _, _ -> }
         ),
         DiaryLocationListItem(
             item = DefaultContactDiaryLocation(locationName = "Supermarkt"),
@@ -104,7 +106,8 @@ object DiaryData {
             onItemClick = {},
             onDurationChanged = { _, _ -> },
             onCircumstancesChanged = { _, _ -> },
-            onCircumStanceInfoClicked = {}
+            onCircumStanceInfoClicked = {},
+            onDurationDialog = { _, _ -> }
         )
     )
 
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/MainActivityTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/MainActivityTest.kt
index 0ef69d98c02304ce2c1000ea334206153f87d7b8..63e5c8a119760fe4f0dc3efde12ac9ce35a4aca7 100644
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/MainActivityTest.kt
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/MainActivityTest.kt
@@ -14,6 +14,7 @@ import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.contactdiary.retention.ContactDiaryWorkScheduler
 import de.rki.coronawarnapp.contactdiary.storage.repo.ContactDiaryRepository
 import de.rki.coronawarnapp.contactdiary.ui.ContactDiarySettings
+import de.rki.coronawarnapp.contactdiary.ui.exporter.ContactDiaryExporter
 import de.rki.coronawarnapp.contactdiary.ui.overview.ContactDiaryOverviewFragment
 import de.rki.coronawarnapp.contactdiary.ui.overview.ContactDiaryOverviewViewModel
 import de.rki.coronawarnapp.contactdiary.ui.overview.adapter.ListItem
@@ -45,6 +46,7 @@ import de.rki.coronawarnapp.ui.main.home.items.FAQCard
 import de.rki.coronawarnapp.ui.main.home.items.HomeItem
 import de.rki.coronawarnapp.ui.statistics.Statistics
 import de.rki.coronawarnapp.util.CWADebug
+import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.device.BackgroundModeStatus
 import de.rki.coronawarnapp.util.device.PowerManagement
 import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool
@@ -102,6 +104,7 @@ class MainActivityTest : BaseUITest() {
     @MockK lateinit var taskController: TaskController
     @MockK lateinit var contactDiaryRepository: ContactDiaryRepository
     @MockK lateinit var riskLevelStorage: RiskLevelStorage
+    @MockK lateinit var exporter: ContactDiaryExporter
 
     // ViewModels
     private lateinit var mainActivityViewModel: MainActivityViewModel
@@ -368,7 +371,9 @@ class MainActivityTest : BaseUITest() {
             taskController = taskController,
             dispatcherProvider = TestDispatcherProvider(),
             contactDiaryRepository = contactDiaryRepository,
-            riskLevelStorage = riskLevelStorage
+            riskLevelStorage = riskLevelStorage,
+            timeStamper = TimeStamper(),
+            exporter = exporter
         )
     )
 
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/contactdiary/ui/ContactDiaryCommentInfoTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/contactdiary/ui/ContactDiaryCommentInfoTestFragment.kt
deleted file mode 100644
index 39a057c7646d9d23ee459f866004d5bb91a517f6..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/contactdiary/ui/ContactDiaryCommentInfoTestFragment.kt
+++ /dev/null
@@ -1,31 +0,0 @@
-package de.rki.coronawarnapp.test.contactdiary.ui
-
-import android.os.Bundle
-import android.view.View
-import androidx.fragment.app.Fragment
-import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.databinding.ContactDiaryCommentInfoFragmentBinding
-import de.rki.coronawarnapp.test.menu.ui.TestMenuItem
-import de.rki.coronawarnapp.util.ui.popBackStack
-import de.rki.coronawarnapp.util.ui.viewBindingLazy
-
-class ContactDiaryCommentInfoTestFragment : Fragment(R.layout.contact_diary_comment_info_fragment) {
-
-    private val binding: ContactDiaryCommentInfoFragmentBinding by viewBindingLazy()
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-
-        binding.toolbar.setNavigationOnClickListener {
-            popBackStack()
-        }
-    }
-
-    companion object {
-        val MENU_ITEM = TestMenuItem(
-            title = "Contact Diary Comment Info",
-            description = "Contact diary comment info screen",
-            targetId = R.id.test_contact_diary_comment_fragment
-        )
-    }
-}
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 7fdfb6b6f7bf074f727bd22a550a72c5b12adb80..6d09d4e6bc5d38de5e6556b177aa116a3a37b415 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
@@ -5,7 +5,6 @@ import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 import de.rki.coronawarnapp.miscinfo.MiscInfoFragment
 import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment
-import de.rki.coronawarnapp.test.contactdiary.ui.ContactDiaryCommentInfoTestFragment
 import de.rki.coronawarnapp.test.contactdiary.ui.ContactDiaryTestFragment
 import de.rki.coronawarnapp.test.crash.ui.SettingsCrashReportFragment
 import de.rki.coronawarnapp.test.datadonation.ui.DataDonationTestFragment
@@ -35,8 +34,7 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() {
             ContactDiaryTestFragment.MENU_ITEM,
             PlaygroundFragment.MENU_ITEM,
             DataDonationTestFragment.MENU_ITEM,
-            DeltaonboardingFragment.MENU_ITEM,
-            ContactDiaryCommentInfoTestFragment.MENU_ITEM
+            DeltaonboardingFragment.MENU_ITEM
         ).let { MutableLiveData(it) }
     }
     val showTestScreenEvent = SingleLiveEvent<TestMenuItem>()
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 5a10dea8abfb6f61fb5adcdded0d4b41c082e6b3..d4b7265dbf3cd6f7b563669a33c8f4319319a8b4 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
@@ -43,9 +43,6 @@
         <action
             android:id="@+id/action_test_menu_fragment_to_dataDonationFragment"
             app:destination="@id/test_datadonation_fragment" />
-        <action
-            android:id="@+id/action_test_menu_fragment_to_test_contact_diary_person_comment_fragment"
-            app:destination="@id/test_contact_diary_comment_fragment" />
         <action
             android:id="@+id/action_test_menu_fragment_to_deltaonboardingFragment"
             app:destination="@id/test_deltaonboarding_fragment" />
@@ -127,11 +124,5 @@
         android:name="de.rki.coronawarnapp.test.deltaonboarding.ui.DeltaonboardingFragment"
         android:label="DeltaonboardingFragment"
         tools:layout="@layout/fragment_test_deltaonboarding" />
-    <fragment
-        android:id="@+id/test_contact_diary_comment_fragment"
-        android:name="de.rki.coronawarnapp.test.contactdiary.ui.ContactDiaryCommentInfoTestFragment"
-        android:label="CommentInfoTestFragment"
-        tools:layout="@layout/contact_diary_comment_info_fragment" />
-
 
 </navigation>
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/common/DiaryCircumstancesTextView.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/common/DiaryCircumstancesTextView.kt
index 012c1a42dbf09ae0a60976576986b0005ff07bb8..bbca79b9214cf07cb2b58b4e68386dacddd6215e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/common/DiaryCircumstancesTextView.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/common/DiaryCircumstancesTextView.kt
@@ -19,6 +19,7 @@ class DiaryCircumstancesTextView @JvmOverloads constructor(
 
     private val input: EditText
     private val infoButton: ImageView
+    private var lastSavedText: String? = null
 
     private var afterTextChangedListener: ((String) -> Unit)? = null
 
@@ -36,9 +37,13 @@ class DiaryCircumstancesTextView @JvmOverloads constructor(
             }
             imeOptions = EditorInfo.IME_ACTION_DONE
             setRawInputType(InputType.TYPE_CLASS_TEXT)
+
             // When the user entered something and puts the app into the background
-            viewTreeObserver.addOnWindowFocusChangeListener {
-                notifyTextChanged(text.toString())
+            viewTreeObserver.addOnWindowFocusChangeListener { windowFocus ->
+                if (hasFocus() && !windowFocus) {
+                    Timber.v("User has left app, input had focus, triggering notifyTextChanged")
+                    notifyTextChanged(text.toString())
+                }
             }
         }
         infoButton = findViewById(R.id.info_button)
@@ -50,8 +55,15 @@ class DiaryCircumstancesTextView @JvmOverloads constructor(
     }
 
     private fun notifyTextChanged(text: String) {
+        if (lastSavedText == text) {
+            Timber.v("New text equals last text, skipping notify.")
+            return
+        }
         // Prevent Copy&Paste inserting new lines.
-        afterTextChangedListener?.invoke(text.trim().replace("\n", ""))
+        afterTextChangedListener?.let {
+            it.invoke(text.trim().replace("\n", ""))
+            lastSavedText = text
+        }
     }
 
     fun setInfoButtonClickListener(listener: () -> Unit) {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/ContactDiaryLocationListFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/ContactDiaryLocationListFragment.kt
index 3321505cd75328547b00da24991918abc0977ee7..8d55e12458fa2a188abbd940fb667f23d3d87f7b 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/ContactDiaryLocationListFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/ContactDiaryLocationListFragment.kt
@@ -6,17 +6,26 @@ import androidx.core.view.isGone
 import androidx.fragment.app.Fragment
 import androidx.navigation.fragment.navArgs
 import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.contactdiary.ui.day.ContactDiaryDayFragmentDirections
+import de.rki.coronawarnapp.contactdiary.ui.durationpicker.ContactDiaryDurationPickerFragment
 import de.rki.coronawarnapp.contactdiary.util.MarginRecyclerViewDecoration
 import de.rki.coronawarnapp.databinding.ContactDiaryLocationListFragmentBinding
+import de.rki.coronawarnapp.ui.doNavigate
 import de.rki.coronawarnapp.util.di.AutoInject
 import de.rki.coronawarnapp.util.lists.diffutil.update
+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 org.joda.time.Duration
 import javax.inject.Inject
 
-class ContactDiaryLocationListFragment : Fragment(R.layout.contact_diary_location_list_fragment), AutoInject {
+class ContactDiaryLocationListFragment :
+    Fragment(R.layout.contact_diary_location_list_fragment),
+    AutoInject,
+    ContactDiaryDurationPickerFragment.OnChangeListener {
+
     private val binding: ContactDiaryLocationListFragmentBinding by viewBindingLazy()
 
     private val navArgs by navArgs<ContactDiaryLocationListFragmentArgs>()
@@ -48,5 +57,26 @@ class ContactDiaryLocationListFragment : Fragment(R.layout.contact_diary_locatio
             locationListAdapter.update(it)
             binding.contactDiaryLocationListNoItemsGroup.isGone = it.isNotEmpty()
         }
+
+        viewModel.openDialog.observe2(this) {
+            val args = Bundle()
+            args.putString(ContactDiaryDurationPickerFragment.DURATION_ARGUMENT_KEY, it)
+
+            val durationPicker = ContactDiaryDurationPickerFragment()
+            durationPicker.arguments = args
+            durationPicker.setTargetFragment(this@ContactDiaryLocationListFragment, 0)
+            durationPicker.show(parentFragmentManager, "ContactDiaryDurationPickerFragment")
+        }
+
+        viewModel.openCommentInfo.observe2(this) {
+            doNavigate(
+                ContactDiaryDayFragmentDirections
+                    .actionContactDiaryDayFragmentToContactDiaryCommentInfoFragment()
+            )
+        }
+    }
+
+    override fun onChange(duration: Duration) {
+        viewModel.onDurationSelected(duration)
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/ContactDiaryLocationListViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/ContactDiaryLocationListViewModel.kt
index d9155c01dbe5bc9d3c8f597dc5fb101017dc2a41..76950a4124614d5c14863ba6f63aa49d33c7e12c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/ContactDiaryLocationListViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/ContactDiaryLocationListViewModel.kt
@@ -9,18 +9,23 @@ import de.rki.coronawarnapp.contactdiary.model.toEditableVariant
 import de.rki.coronawarnapp.contactdiary.storage.repo.ContactDiaryRepository
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.util.coroutine.AppScope
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.trimToLength
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
 import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
 import org.joda.time.Duration
 import org.joda.time.LocalDate
 
 class ContactDiaryLocationListViewModel @AssistedInject constructor(
-    dispatcherProvider: DispatcherProvider,
+    val dispatcherProvider: DispatcherProvider,
+    @AppScope val appScope: CoroutineScope,
     @Assisted selectedDay: String,
     private val contactDiaryRepository: ContactDiaryRepository
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
@@ -28,6 +33,10 @@ class ContactDiaryLocationListViewModel @AssistedInject constructor(
         ex.report(ExceptionCategory.INTERNAL, TAG)
     }
 
+    val openCommentInfo = SingleLiveEvent<Unit>()
+    val openDialog = SingleLiveEvent<String>()
+    private var currentLocation: DiaryLocationListItem? = null
+
     private val localDate = LocalDate.parse(selectedDay)
 
     private val dayElement = contactDiaryRepository.locationVisitsForDate(localDate)
@@ -49,13 +58,16 @@ class ContactDiaryLocationListViewModel @AssistedInject constructor(
                     onCircumstancesChanged(item, circumstances)
                 },
                 onCircumStanceInfoClicked = {
-                    // TODO
+                    openCommentInfo.postValue(Unit)
+                },
+                onDurationDialog = { item, durationString ->
+                    onDurationDialog(item, durationString)
                 }
             )
         }
     }.asLiveData()
 
-    private fun onLocationSelectionChanged(item: DiaryLocationListItem) = launch(coroutineExceptionHandler) {
+    private fun onLocationSelectionChanged(item: DiaryLocationListItem) = launchOnAppScope {
         if (!item.selected) {
             contactDiaryRepository.addLocationVisit(
                 DefaultContactDiaryLocationVisit(
@@ -71,12 +83,24 @@ class ContactDiaryLocationListViewModel @AssistedInject constructor(
         }
     }
 
+    private fun onDurationDialog(
+        listItem: DiaryLocationListItem,
+        durationString: String
+    ) {
+        currentLocation = listItem
+        openDialog.postValue(durationString)
+    }
+
+    fun onDurationSelected(duration: Duration) {
+        currentLocation?.let { onDurationChanged(it, duration) }
+    }
+
     private fun onDurationChanged(
         item: DiaryLocationListItem,
         duration: Duration?
     ) {
         val visit = item.visit?.toEditableVariant() ?: return
-        launch {
+        launchOnAppScope {
             contactDiaryRepository.updateLocationVisit(visit.copy(duration = duration))
         }
     }
@@ -87,11 +111,17 @@ class ContactDiaryLocationListViewModel @AssistedInject constructor(
     ) {
         val visit = item.visit?.toEditableVariant() ?: return
         val sanitized = circumstances.trim().trimToLength(250)
-        launch {
+        launchOnAppScope {
             contactDiaryRepository.updateLocationVisit(visit.copy(circumstances = sanitized))
         }
     }
 
+    // Viewmodel may be cancelled before the data is saved
+    private fun launchOnAppScope(block: suspend CoroutineScope.() -> Unit) =
+        appScope.launch(coroutineExceptionHandler) {
+            block()
+        }
+
     @AssistedFactory
     interface Factory : CWAViewModelFactory<ContactDiaryLocationListViewModel> {
         fun create(selectedDay: String): ContactDiaryLocationListViewModel
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/DiaryLocationListItem.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/DiaryLocationListItem.kt
index df57fbbe68b4263ecdb1a7079c0f634d452dc277..5f9bf02618d417c870727053345ecd5e800c10ac 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/DiaryLocationListItem.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/DiaryLocationListItem.kt
@@ -16,7 +16,8 @@ data class DiaryLocationListItem(
     override val onItemClick: (SelectableDiaryItem<ContactDiaryLocation>) -> Unit,
     val onDurationChanged: (DiaryLocationListItem, Duration?) -> Unit,
     val onCircumstancesChanged: (DiaryLocationListItem, String) -> Unit,
-    val onCircumStanceInfoClicked: () -> Unit
+    val onCircumStanceInfoClicked: () -> Unit,
+    val onDurationDialog: (DiaryLocationListItem, String) -> Unit
 ) : SelectableDiaryItem<ContactDiaryLocation>(), HasPayloadDiffer {
     override val selected: Boolean
         get() = visit != null
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/DiaryLocationViewHolder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/DiaryLocationViewHolder.kt
index 720d330f2e5255fb4cda6f29b48eda9f2e00bb99..04b9002702564da8d4e563cf8e35509226cb22e0 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/DiaryLocationViewHolder.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/location/DiaryLocationViewHolder.kt
@@ -3,6 +3,7 @@ package de.rki.coronawarnapp.contactdiary.ui.day.tabs.location
 import android.view.ViewGroup
 import android.view.accessibility.AccessibilityEvent
 import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.contactdiary.ui.durationpicker.toContactDiaryFormat
 import de.rki.coronawarnapp.contactdiary.util.setClickLabel
 import de.rki.coronawarnapp.databinding.ContactDiaryLocationListItemBinding
 import de.rki.coronawarnapp.ui.lists.BaseAdapter
@@ -40,5 +41,23 @@ class DiaryLocationViewHolder(
             circumstances.setInputTextChangedListener { item.onCircumstancesChanged(item, it) }
             setInfoButtonClickListener { item.onCircumStanceInfoClicked() }
         }
+
+        durationInput.apply {
+            val duration = item.visit?.duration
+            text = duration?.toContactDiaryFormat()
+            if (duration == null || duration.millis == 0L) {
+                text = context.getString(R.string.duration_dialog_default_value)
+                setBackgroundResource(R.drawable.contact_diary_duration_background_default)
+                setTextAppearance(R.style.bodyNeutral)
+            } else {
+                setBackgroundResource(R.drawable.contact_diary_duration_background_selected)
+                setTextAppearance(R.style.body1)
+            }
+        }
+
+        durationInput.setOnClickListener {
+            circumstances.clearFocus()
+            item.onDurationDialog(item, durationInput.text.toString())
+        }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/person/ContactDiaryPersonListFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/person/ContactDiaryPersonListFragment.kt
index 0d6e9c0525bdb01e4e1908adc223936ceeec79ad..24c5b2a482142c3e8570c35d228713c1c1514e06 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/person/ContactDiaryPersonListFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/person/ContactDiaryPersonListFragment.kt
@@ -6,10 +6,12 @@ import androidx.core.view.isGone
 import androidx.fragment.app.Fragment
 import androidx.navigation.fragment.navArgs
 import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.contactdiary.ui.day.ContactDiaryDayFragmentDirections
 import de.rki.coronawarnapp.contactdiary.util.MarginRecyclerViewDecoration
 import de.rki.coronawarnapp.databinding.ContactDiaryPersonListFragmentBinding
 import de.rki.coronawarnapp.util.di.AutoInject
 import de.rki.coronawarnapp.util.lists.diffutil.update
+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
@@ -44,5 +46,12 @@ class ContactDiaryPersonListFragment : Fragment(R.layout.contact_diary_person_li
             personListAdapter.update(it)
             binding.contactDiaryPersonListNoItemsGroup.isGone = it.isNotEmpty()
         }
+
+        viewModel.openCommentInfo.observe2(this) {
+            doNavigate(
+                ContactDiaryDayFragmentDirections
+                    .actionContactDiaryDayFragmentToContactDiaryCommentInfoFragment()
+            )
+        }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/person/ContactDiaryPersonListViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/person/ContactDiaryPersonListViewModel.kt
index 5df0f9adddb61faa799f9d07a0d7081a962e3f43..a0add62e4ef1b7334a00817de8a6f1397651cea3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/person/ContactDiaryPersonListViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/day/tabs/person/ContactDiaryPersonListViewModel.kt
@@ -11,18 +11,23 @@ import de.rki.coronawarnapp.contactdiary.model.toEditableVariant
 import de.rki.coronawarnapp.contactdiary.storage.repo.ContactDiaryRepository
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.util.coroutine.AppScope
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.flow.combine
 import de.rki.coronawarnapp.util.trimToLength
+import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
 import kotlinx.coroutines.CoroutineExceptionHandler
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.launch
 import org.joda.time.LocalDate
 import timber.log.Timber
 
 class ContactDiaryPersonListViewModel @AssistedInject constructor(
-    dispatcherProvider: DispatcherProvider,
+    val dispatcherProvider: DispatcherProvider,
+    @AppScope val appScope: CoroutineScope,
     @Assisted selectedDay: String,
     private val contactDiaryRepository: ContactDiaryRepository
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
@@ -30,6 +35,8 @@ class ContactDiaryPersonListViewModel @AssistedInject constructor(
         ex.report(ExceptionCategory.INTERNAL, TAG)
     }
 
+    val openCommentInfo = SingleLiveEvent<Unit>()
+
     private val localDate = LocalDate.parse(selectedDay)
 
     private val dayEncounters = contactDiaryRepository.personEncountersForDate(localDate)
@@ -60,7 +67,7 @@ class ContactDiaryPersonListViewModel @AssistedInject constructor(
                     onCircumstancesChanged(item, circumstances)
                 },
                 onCircumstanceInfoClicked = {
-                    // TODO
+                    openCommentInfo.postValue(Unit)
                 }
             )
         }
@@ -68,7 +75,7 @@ class ContactDiaryPersonListViewModel @AssistedInject constructor(
 
     private fun onPersonSelectionChanged(
         item: DiaryPersonListItem
-    ) = launch(coroutineExceptionHandler) {
+    ) = launchOnAppScope {
         if (!item.selected) {
             contactDiaryRepository.addPersonEncounter(
                 DefaultContactDiaryPersonEncounter(
@@ -89,18 +96,24 @@ class ContactDiaryPersonListViewModel @AssistedInject constructor(
     ) {
         Timber.d("onDurationChanged(item=%s, duration=%s)", item, duration)
         val encounter = item.personEncounter?.toEditableVariant() ?: return
-        launch {
+        launchOnAppScope {
             contactDiaryRepository.updatePersonEncounter(encounter.copy(durationClassification = duration))
         }
     }
 
+    // Viewmodel may be cancelled before the data is saved
+    private fun launchOnAppScope(block: suspend CoroutineScope.() -> Unit) =
+        appScope.launch(coroutineExceptionHandler) {
+            block()
+        }
+
     private fun onWithmaskChanged(
         item: DiaryPersonListItem,
         withMask: Boolean?
     ) {
         Timber.d("onWithmaskChanged(item=%s, withMask=%s)", item, withMask)
         val encounter = item.personEncounter?.toEditableVariant() ?: return
-        launch {
+        launchOnAppScope {
             contactDiaryRepository.updatePersonEncounter(encounter.copy(withMask = withMask))
         }
     }
@@ -111,7 +124,7 @@ class ContactDiaryPersonListViewModel @AssistedInject constructor(
     ) {
         Timber.d("onWasOutsideChanged(item=%s, onWasOutside=%s)", item, wasOutside)
         val encounter = item.personEncounter?.toEditableVariant() ?: return
-        launch {
+        launchOnAppScope {
             contactDiaryRepository.updatePersonEncounter(encounter.copy(wasOutside = wasOutside))
         }
     }
@@ -122,7 +135,7 @@ class ContactDiaryPersonListViewModel @AssistedInject constructor(
     ) {
         Timber.d("onCircumstancesChanged(item=%s, circumstances=%s)", item, circumstances)
         val encounter = item.personEncounter?.toEditableVariant() ?: return
-        launch {
+        launchOnAppScope {
             val sanitized = circumstances.trim().trimToLength(250)
             contactDiaryRepository.updatePersonEncounter(encounter.copy(circumstances = sanitized))
         }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/ContactDiaryDurationPickerFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/ContactDiaryDurationPickerFragment.kt
index 6a39ab7edd0af915f3786eea3a32c7630941fc0c..a33bc0e11e588448dbf12ee41458b1839c7a7db7 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/ContactDiaryDurationPickerFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/ContactDiaryDurationPickerFragment.kt
@@ -42,9 +42,11 @@ class ContactDiaryDurationPickerFragment : DialogFragment() {
         }
 
         with(binding.value) {
-            val duration = requireArguments().getString(DURATION_ARGUMENT_KEY)!!.split(":").toTypedArray()
-            hours.value = hoursArray.indexOf(duration[0])
-            minutes.value = minutesArray.indexOf(duration[1])
+            var duration = requireArguments().getString(DURATION_ARGUMENT_KEY)!!.split(":").toTypedArray()
+            if (duration.size < 2) duration = arrayOf("00", "00")
+
+            hours.value = if (hoursArray.size > duration[0].toInt()) hoursArray.indexOf(duration[0]) else 0
+            minutes.value = if (minutesArray.size > duration[1].toInt()) minutesArray.indexOf(duration[1]) else 0
 
             cancelButton.setOnClickListener { dismiss() }
             okButton.setOnClickListener {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/DurationExtension.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/DurationExtension.kt
index 270c455e0cda681f10ebcb4e2d0736acac986481..dad157251d0fcaf2966a42aff1ecc2c5a6c55c3a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/DurationExtension.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/DurationExtension.kt
@@ -16,3 +16,15 @@ fun Duration.toContactDiaryFormat(): String {
     }
     return "$hours:$minutes"
 }
+
+// returns readable durations with optional prefix and suffix such as "Dauer 01:30 h"
+fun Duration.toReadableDuration(prefix: String? = null, suffix: String? = null): String {
+    val durationInMinutes = standardMinutes
+    val durationString = String.format("%02d:%02d", durationInMinutes / 60, (durationInMinutes % 60))
+
+    return listOfNotNull(
+        prefix,
+        durationString,
+        suffix
+    ).joinToString(separator = " ")
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/exporter/ContactDiaryExporter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/exporter/ContactDiaryExporter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..56cf164dfaaa4eec768466c8a7df5eff3d14e31a
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/exporter/ContactDiaryExporter.kt
@@ -0,0 +1,141 @@
+package de.rki.coronawarnapp.contactdiary.ui.exporter
+
+import android.content.Context
+import dagger.Reusable
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.contactdiary.model.ContactDiaryLocationVisit
+import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter
+import de.rki.coronawarnapp.contactdiary.ui.durationpicker.toReadableDuration
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDate
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.withContext
+import org.joda.time.LocalDate
+import java.util.Locale
+import javax.inject.Inject
+
+@Reusable
+class ContactDiaryExporter @Inject constructor(
+    @AppContext private val context: Context,
+    private val timeStamper: TimeStamper,
+    private val dispatcherProvider: DispatcherProvider
+) {
+
+    private val prefixPhone = context.getString(R.string.contact_diary_export_prefix_phone)
+    private val prefixEMail = context.getString(R.string.contact_diary_export_prefix_email)
+
+    private val textDurationLessThan15Min = context.getString(R.string.contact_diary_export_durations_less_than_15min)
+    private val textDurationLongerThan15Min =
+        context.getString(R.string.contact_diary_export_durations_longer_than_15min)
+
+    private val textWithMask = context.getString(R.string.contact_diary_export_wearing_mask)
+    private val textNoMask = context.getString(R.string.contact_diary_export_wearing_no_mask)
+
+    private val textWasOutdoors = context.getString(R.string.contact_diary_export_outdoor)
+    private val textWasIndoor = context.getString(R.string.contact_diary_export_indoor)
+
+    private val durationPrefix = context.getString(R.string.contact_diary_export_location_duration_prefix)
+    private val durationSuffix = context.getString(R.string.contact_diary_export_location_duration_suffix)
+
+    suspend fun createExport(
+        personEncounters: List<ContactDiaryPersonEncounter>,
+        locationVisits: List<ContactDiaryLocationVisit>,
+        numberOfLastDaysToExport: Int
+    ): String = withContext(dispatcherProvider.Default) {
+
+        val datesToExport = generateDatesToExport(numberOfLastDaysToExport)
+
+        StringBuilder()
+            .appendIntro(datesToExport)
+            .appendPersonsAndLocations(personEncounters, locationVisits, datesToExport)
+            .toString()
+    }
+
+    private fun generateDatesToExport(numberOfLastDaysToExport: Int) =
+        (0 until numberOfLastDaysToExport).map { timeStamper.nowUTC.toLocalDate().minusDays(it) }
+
+    private fun StringBuilder.appendIntro(datesToExport: List<LocalDate>) = apply {
+        appendLine(
+            context.getString(
+                R.string.contact_diary_export_intro_one,
+                datesToExport.last().toFormattedString(),
+                datesToExport.first().toFormattedString()
+            )
+        )
+        appendLine(context.getString(R.string.contact_diary_export_intro_two))
+    }
+
+    private fun StringBuilder.appendPersonsAndLocations(
+        personEncounters: List<ContactDiaryPersonEncounter>,
+        locationVisits: List<ContactDiaryLocationVisit>,
+        datesToExport: List<LocalDate>
+    ) = apply {
+
+        if (personEncounters.isNotEmpty() || locationVisits.isNotEmpty()) {
+            appendLine()
+        } else {
+            return this
+        }
+
+        val groupedPersonEncounters = personEncounters.groupBy { it.date }
+        val groupedLocationVisits = locationVisits.groupBy { it.date }
+
+        for (date in datesToExport) {
+
+            // According to tech spec persons first and then locations
+            groupedPersonEncounters[date]
+                ?.sortedBy { getStringToSortBy(it.contactDiaryPerson.fullName) }
+                ?.map { it.getExportInfo(it.date) }
+                ?.forEach { appendLine(it) }
+
+            groupedLocationVisits[date]
+                ?.sortedBy { getStringToSortBy(it.contactDiaryLocation.locationName) }
+                ?.map { it.getExportInfo(it.date) }
+                ?.forEach { appendLine(it) }
+        }
+
+        return this
+    }
+
+    private fun getStringToSortBy(name: String) = name.toLowerCase(Locale.ROOT)
+
+    private fun ContactDiaryPersonEncounter.getExportInfo(date: LocalDate) = listOfNotNull(
+        date.toFormattedStringWithName(contactDiaryPerson.fullName),
+        contactDiaryPerson.phoneNumber?.let { getPhoneWithPrefix(it) },
+        contactDiaryPerson.emailAddress?.let { getEMailWithPrefix(it) },
+        durationClassification?.let { getDurationClassificationString(it) },
+        withMask?.let { if (it) textWithMask else textNoMask },
+        wasOutside?.let { if (it) textWasOutdoors else textWasIndoor },
+        circumstances
+    ).joinToString(separator = "; ")
+
+    private fun ContactDiaryLocationVisit.getExportInfo(date: LocalDate): String {
+        return listOfNotNull(
+            date.toFormattedStringWithName(contactDiaryLocation.locationName),
+            contactDiaryLocation.phoneNumber?.let { getPhoneWithPrefix(it) },
+            contactDiaryLocation.emailAddress?.let { getEMailWithPrefix(it) },
+            duration?.toReadableDuration(durationPrefix, durationSuffix),
+            circumstances
+        ).joinToString(separator = "; ")
+    }
+
+    private fun LocalDate.toFormattedStringWithName(name: String) = "${toFormattedString()} $name"
+
+    private fun getPhoneWithPrefix(phone: String) = if (phone.isNotBlank()) {
+        "$prefixPhone $phone"
+    } else null
+
+    private fun getEMailWithPrefix(eMail: String) = if (eMail.isNotBlank()) {
+        "$prefixEMail $eMail"
+    } else null
+
+    private fun getDurationClassificationString(duration: ContactDiaryPersonEncounter.DurationClassification) =
+        when (duration) {
+            ContactDiaryPersonEncounter.DurationClassification.LESS_THAN_15_MINUTES -> textDurationLessThan15Min
+            ContactDiaryPersonEncounter.DurationClassification.MORE_THAN_15_MINUTES -> textDurationLongerThan15Min
+        }
+
+    // According to tech spec german locale only
+    private fun LocalDate.toFormattedString(): String = toString("dd.MM.yyyy", Locale.GERMAN)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/location/ContactDiaryAddLocationFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/location/ContactDiaryAddLocationFragment.kt
index 078300ce3c65481721f754a75d063ac56cddaa27..b7540115e47f5e7109b60d9a9369739b37112298 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/location/ContactDiaryAddLocationFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/location/ContactDiaryAddLocationFragment.kt
@@ -12,6 +12,7 @@ import de.rki.coronawarnapp.contactdiary.util.hideKeyboard
 import de.rki.coronawarnapp.databinding.ContactDiaryAddLocationFragmentBinding
 import de.rki.coronawarnapp.util.DialogHelper
 import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.setTextOnTextInput
 import de.rki.coronawarnapp.util.ui.observe2
 import de.rki.coronawarnapp.util.ui.popBackStack
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
@@ -40,52 +41,52 @@ class ContactDiaryAddLocationFragment : Fragment(R.layout.contact_diary_add_loca
         val location = navArgs.selectedLocation
         if (location != null) {
             binding.apply {
-                contactDiaryAddLocationNameInputEditText.setText(location.locationName)
-                contactDiaryAddLocationPhoneInputEditText.setText(location.phoneNumber)
-                contactDiaryAddLocationEmailInputEditText.setText(location.emailAddress)
-                contactDiaryAddLocationDeleteButton.visibility = View.VISIBLE
-                contactDiaryAddLocationDeleteButton.setOnClickListener {
+                locationNameInputEdit.setText(location.locationName)
+                locationPhoneInput.setTextOnTextInput(location.phoneNumber, endIconVisible = false)
+                locationEmailInput.setTextOnTextInput(location.emailAddress, endIconVisible = false)
+                locationDeleteButton.visibility = View.VISIBLE
+                locationDeleteButton.setOnClickListener {
                     DialogHelper.showDialog(deleteLocationConfirmationDialog)
                 }
-                contactDiaryAddLocationSaveButton.setOnClickListener {
+                locationSaveButton.setOnClickListener {
                     it.hideKeyboard()
                     viewModel.updateLocation(
                         location,
-                        phoneNumber = binding.contactDiaryAddLocationPhoneInputEditText.text.toString().trim(),
-                        emailAddress = binding.contactDiaryAddLocationEmailInputEditText.text.toString().trim()
+                        phoneNumber = binding.locationPhoneInput.text.toString().trim(),
+                        emailAddress = binding.locationEmailInput.text.toString().trim()
                     )
                 }
             }
             viewModel.locationChanged(location.locationName)
         } else {
             binding.apply {
-                contactDiaryAddLocationDeleteButton.visibility = View.GONE
-                contactDiaryAddLocationSaveButton.setOnClickListener {
+                locationDeleteButton.visibility = View.GONE
+                locationSaveButton.setOnClickListener {
                     it.hideKeyboard()
                     viewModel.addLocation(
-                        phoneNumber = binding.contactDiaryAddLocationPhoneInputEditText.text.toString().trim(),
-                        emailAddress = binding.contactDiaryAddLocationEmailInputEditText.text.toString().trim()
+                        phoneNumber = binding.locationPhoneInput.text.toString().trim(),
+                        emailAddress = binding.locationEmailInput.text.toString().trim()
                     )
                 }
             }
         }
 
         binding.apply {
-            contactDiaryAddLocationNameInputEditText.focusAndShowKeyboard()
+            locationNameInputEdit.focusAndShowKeyboard()
 
-            contactDiaryAddLocationCloseButton.setOnClickListener {
+            locationCloseButton.setOnClickListener {
                 it.hideKeyboard()
                 viewModel.closePressed()
             }
-            contactDiaryAddLocationNameInputEditText.doAfterTextChanged {
+            locationNameInputEdit.doAfterTextChanged {
                 viewModel.locationChanged(it.toString())
             }
 
-            contactDiaryAddLocationEmailInputEditText.setOnEditorActionListener { _, actionId, _ ->
+            locationEmailInput.setOnEditorActionListener { _, actionId, _ ->
                 return@setOnEditorActionListener when (actionId) {
                     EditorInfo.IME_ACTION_DONE -> {
                         if (viewModel.isValid.value == true) {
-                            binding.contactDiaryAddLocationSaveButton.performClick()
+                            binding.locationSaveButton.performClick()
                         }
                         false
                     }
@@ -99,7 +100,7 @@ class ContactDiaryAddLocationFragment : Fragment(R.layout.contact_diary_add_loca
         }
 
         viewModel.isValid.observe2(this) {
-            binding.contactDiaryAddLocationSaveButton.isEnabled = it
+            binding.locationSaveButton.isEnabled = it
         }
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewFragment.kt
index 166a253409f3b3d4cee436c0b4e469f0abf034f2..f305c29de6d8b2fc9fdc90397f6ac616b4d3d31b 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewFragment.kt
@@ -101,7 +101,7 @@ class ContactDiaryOverviewFragment : Fragment(R.layout.contact_diary_overview_fr
                     true
                 }
                 R.id.menu_contact_diary_export_entries -> {
-                    vm.onExportPress(context)
+                    vm.onExportPress()
                     true
                 }
                 R.id.menu_contact_diary_edit_persons -> {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewViewModel.kt
index bf01a2f7b9daa8131a46256a34de11fd8ba342a4..c90a2d9856a443a69502c578efb145df438f5123 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewViewModel.kt
@@ -1,6 +1,5 @@
 package de.rki.coronawarnapp.contactdiary.ui.overview
 
-import android.content.Context
 import androidx.annotation.DrawableRes
 import androidx.annotation.StringRes
 import androidx.lifecycle.asLiveData
@@ -13,6 +12,7 @@ import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter.Durat
 import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter.DurationClassification.MORE_THAN_15_MINUTES
 import de.rki.coronawarnapp.contactdiary.retention.ContactDiaryCleanTask
 import de.rki.coronawarnapp.contactdiary.storage.repo.ContactDiaryRepository
+import de.rki.coronawarnapp.contactdiary.ui.exporter.ContactDiaryExporter
 import de.rki.coronawarnapp.contactdiary.ui.overview.adapter.ListItem
 import de.rki.coronawarnapp.risk.result.AggregatedRiskPerDateResult
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
@@ -20,6 +20,8 @@ import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParamete
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.task.common.DefaultTaskRequest
 import de.rki.coronawarnapp.ui.SingleLiveEvent
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDate
+import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
@@ -28,19 +30,20 @@ import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flowOf
 import org.joda.time.LocalDate
 import timber.log.Timber
-import java.util.Locale
 
 class ContactDiaryOverviewViewModel @AssistedInject constructor(
     taskController: TaskController,
     dispatcherProvider: DispatcherProvider,
     contactDiaryRepository: ContactDiaryRepository,
-    riskLevelStorage: RiskLevelStorage
+    riskLevelStorage: RiskLevelStorage,
+    timeStamper: TimeStamper,
+    private val exporter: ContactDiaryExporter
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
 
     val routeToScreen: SingleLiveEvent<ContactDiaryOverviewNavigationEvents> = SingleLiveEvent()
     val exportLocationsAndPersons: SingleLiveEvent<String> = SingleLiveEvent()
 
-    private val dates = (0 until DAY_COUNT).map { LocalDate.now().minusDays(it) }
+    private val dates = (0 until DAY_COUNT).map { timeStamper.nowUTC.toLocalDate().minusDays(it) }
 
     private val locationVisitsFlow = contactDiaryRepository.locationVisits
     private val personEncountersFlow = contactDiaryRepository.personEncounters
@@ -176,48 +179,20 @@ class ContactDiaryOverviewViewModel @AssistedInject constructor(
             }
         }
 
-    fun onExportPress(ctx: Context) {
+    fun onExportPress() {
         Timber.d("Exporting person and location entries")
         launch {
-            val locationVisits = locationVisitsFlow
-                .first()
-                .groupBy({ it.date }, { it.contactDiaryLocation.locationName })
-
-            val personEncounters = personEncountersFlow
-                .first()
-                .groupBy({ it.date }, { it.contactDiaryPerson.fullName })
-
-            val sb = StringBuilder()
-                .appendLine(
-                    ctx.getString(
-                        R.string.contact_diary_export_intro_one,
-                        dates.last().toFormattedString(),
-                        dates.first().toFormattedString()
-                    )
-                )
-                .appendLine(ctx.getString(R.string.contact_diary_export_intro_two))
-                .appendLine()
-
-            for (date in dates) {
-                val dateString = date.toFormattedString()
 
-                // According to tech spec persons first and then locations
-                personEncounters[date]?.addToStringBuilder(sb, dateString)
-                locationVisits[date]?.addToStringBuilder(sb, dateString)
-            }
+            val export = exporter.createExport(
+                personEncountersFlow.first(),
+                locationVisitsFlow.first(),
+                DAY_COUNT
+            )
 
-            exportLocationsAndPersons.postValue(sb.toString())
+            exportLocationsAndPersons.postValue(export)
         }
     }
 
-    private fun List<String>.addToStringBuilder(sb: StringBuilder, dateString: String) = sortedBy {
-        it.toLowerCase(Locale.ROOT)
-    }
-        .forEach { sb.appendLine("$dateString $it") }
-
-    // According to tech spec german locale only
-    private fun LocalDate.toFormattedString(): String = toString("dd.MM.yyyy", Locale.GERMAN)
-
     @AssistedFactory
     interface Factory : SimpleCWAViewModelFactory<ContactDiaryOverviewViewModel>
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/adapter/ContactDiaryOverviewNestedAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/adapter/ContactDiaryOverviewNestedAdapter.kt
index 30de452e3d3008028f22dc4d9e1f7335bb19f6e4..0185b24a95bed3906b7f1764a0129f2fb0478c97 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/adapter/ContactDiaryOverviewNestedAdapter.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/overview/adapter/ContactDiaryOverviewNestedAdapter.kt
@@ -3,6 +3,7 @@ package de.rki.coronawarnapp.contactdiary.ui.overview.adapter
 import android.view.View
 import android.view.ViewGroup
 import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.contactdiary.ui.durationpicker.toReadableDuration
 import de.rki.coronawarnapp.contactdiary.util.clearAndAddAll
 import de.rki.coronawarnapp.databinding.ContactDiaryOverviewNestedListItemBinding
 import de.rki.coronawarnapp.ui.lists.BaseAdapter
@@ -48,9 +49,14 @@ class ContactDiaryOverviewNestedAdapter : BaseAdapter<ContactDiaryOverviewNested
 
         private fun getAttributes(duration: Duration?, resources: List<Int>?, circumstances: String?): String =
             mutableListOf<String>().apply {
-                duration?.run { add(toStandardHours().toString()) }
+                duration?.run {
+                    if (duration != Duration.ZERO) {
+                        val durationSuffix = context.getString(R.string.contact_diary_overview_location_duration_suffix)
+                        add(toReadableDuration(suffix = durationSuffix))
+                    }
+                }
                 resources?.run { forEach { add(context.getString(it)) } }
                 circumstances?.run { add(this) }
-            }.joinToString()
+            }.filter { it.isNotEmpty() }.joinToString()
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/person/ContactDiaryAddPersonFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/person/ContactDiaryAddPersonFragment.kt
index 0a897ddac8ad4a258e06da3eaa3bedae8d93a840..ae8716f9fd970d59bf157a4dce9b2813f602e256 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/person/ContactDiaryAddPersonFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/contactdiary/ui/person/ContactDiaryAddPersonFragment.kt
@@ -12,6 +12,7 @@ import de.rki.coronawarnapp.contactdiary.util.hideKeyboard
 import de.rki.coronawarnapp.databinding.ContactDiaryAddPersonFragmentBinding
 import de.rki.coronawarnapp.util.DialogHelper
 import de.rki.coronawarnapp.util.di.AutoInject
+import de.rki.coronawarnapp.util.setTextOnTextInput
 import de.rki.coronawarnapp.util.ui.observe2
 import de.rki.coronawarnapp.util.ui.popBackStack
 import de.rki.coronawarnapp.util.ui.viewBindingLazy
@@ -42,50 +43,50 @@ class ContactDiaryAddPersonFragment :
         val person = navArgs.selectedPerson
         if (person != null) {
             binding.apply {
-                contactDiaryPersonNameEditText.setText(person.fullName)
-                contactDiaryPersonPhoneNumberEditText.setText(person.phoneNumber)
-                contactDiaryPersonEmailEditText.setText(person.emailAddress)
-                contactDiaryPersonDeleteButton.visibility = View.VISIBLE
-                contactDiaryPersonDeleteButton.setOnClickListener {
+                personNameInput.setText(person.fullName)
+                personPhoneNumberInput.setTextOnTextInput(person.phoneNumber, endIconVisible = false)
+                personEmailInput.setTextOnTextInput(person.emailAddress, endIconVisible = false)
+                personDeleteButton.visibility = View.VISIBLE
+                personDeleteButton.setOnClickListener {
                     DialogHelper.showDialog(deletePersonConfirmationDialog)
                 }
-                contactDiaryPersonSaveButton.setOnClickListener {
+                personSaveButton.setOnClickListener {
                     it.hideKeyboard()
                     viewModel.updatePerson(
                         person,
-                        phoneNumber = binding.contactDiaryPersonPhoneNumberEditText.text.toString().trim(),
-                        emailAddress = binding.contactDiaryPersonEmailEditText.text.toString().trim()
+                        phoneNumber = binding.personPhoneNumberInput.text.toString().trim(),
+                        emailAddress = binding.personEmailInput.text.toString().trim()
                     )
                 }
             }
             viewModel.nameChanged(person.fullName)
         } else {
-            binding.contactDiaryPersonDeleteButton.visibility = View.GONE
-            binding.contactDiaryPersonSaveButton.setOnClickListener {
+            binding.personDeleteButton.visibility = View.GONE
+            binding.personSaveButton.setOnClickListener {
                 it.hideKeyboard()
                 viewModel.addPerson(
-                    phoneNumber = binding.contactDiaryPersonPhoneNumberEditText.text.toString().trim(),
-                    emailAddress = binding.contactDiaryPersonEmailEditText.text.toString().trim()
+                    phoneNumber = binding.personPhoneNumberInput.text.toString().trim(),
+                    emailAddress = binding.personEmailInput.text.toString().trim()
                 )
             }
         }
 
         binding.apply {
-            contactDiaryPersonNameEditText.focusAndShowKeyboard()
+            personNameInput.focusAndShowKeyboard()
 
-            contactDiaryPersonCloseButton.setOnClickListener {
+            personCloseButton.setOnClickListener {
                 it.hideKeyboard()
                 viewModel.closePressed()
             }
-            contactDiaryPersonNameEditText.doAfterTextChanged {
+            personNameInput.doAfterTextChanged {
                 viewModel.nameChanged(it.toString())
             }
 
-            contactDiaryPersonEmailEditText.setOnEditorActionListener { _, actionId, _ ->
+            personEmailInput.setOnEditorActionListener { _, actionId, _ ->
                 return@setOnEditorActionListener when (actionId) {
                     IME_ACTION_DONE -> {
                         if (viewModel.isNameValid.value == true) {
-                            binding.contactDiaryPersonSaveButton.performClick()
+                            binding.personSaveButton.performClick()
                         }
                         false
                     }
@@ -99,7 +100,7 @@ class ContactDiaryAddPersonFragment :
         }
 
         viewModel.isNameValid.observe2(this) { isValid ->
-            binding.contactDiaryPersonSaveButton.isEnabled = isValid
+            binding.personSaveButton.isEnabled = isValid
         }
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt
index 254d9b3d981d7e1e6fe5fa93b1063c064afccd45..52c57ad78da01d091532ca8f6e0c890e83f7d205 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt
@@ -8,6 +8,7 @@ import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule
 import de.rki.coronawarnapp.datadonation.analytics.modules.clientmetadata.ClientMetadataDonor
 import de.rki.coronawarnapp.datadonation.analytics.modules.exposureriskmetadata.ExposureRiskMetadataDonor
 import de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest.TestResultDonor
+import de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowDonor
 import de.rki.coronawarnapp.datadonation.analytics.modules.usermetadata.UserMetadataDonor
 import de.rki.coronawarnapp.datadonation.analytics.server.DataDonationAnalyticsApiV1
 import de.rki.coronawarnapp.datadonation.analytics.storage.DefaultLastAnalyticsSubmissionLogger
@@ -40,16 +41,14 @@ class AnalyticsModule {
             .create(DataDonationAnalyticsApiV1::class.java)
     }
 
-//    Add these back later when they actually collect data
-//
-//    @IntoSet
-//    @Provides
-//    fun newExposureWindows(module: NewExposureWindowsDonor): DonorModule = module
-//
+    @IntoSet
+    @Provides
+    fun newExposureWindows(module: AnalyticsExposureWindowDonor): DonorModule = module
+
 //    @IntoSet
 //    @Provides
 //    fun keySubmission(module: KeySubmissionStateDonor): DonorModule = module
-//
+
     @IntoSet
     @Provides
     fun registeredTest(module: TestResultDonor): DonorModule = module
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollector.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollector.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ff958e997cb636fa8ec1e7fd1839c67ca93b40c8
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollector.kt
@@ -0,0 +1,51 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import com.google.android.gms.nearby.exposurenotification.ScanInstance
+import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings
+import de.rki.coronawarnapp.risk.result.RiskResult
+import de.rki.coronawarnapp.util.debug.measureTime
+import timber.log.Timber
+import javax.inject.Inject
+
+class AnalyticsExposureWindowCollector @Inject constructor(
+    private val analyticsExposureWindowRepository: AnalyticsExposureWindowRepository,
+    private val analyticsSettings: AnalyticsSettings
+) {
+    suspend fun reportRiskResultsPerWindow(riskResultsPerWindow: Map<ExposureWindow, RiskResult>) {
+        if (analyticsSettings.analyticsEnabled.value) {
+            collectAnalyticsData(riskResultsPerWindow)
+        }
+    }
+
+    private suspend fun collectAnalyticsData(riskResultsPerWindow: Map<ExposureWindow, RiskResult>) {
+        measureTime(onMeasured = { Timber.d("Time per db insert of exposure window is $it") }) {
+            riskResultsPerWindow.forEach {
+                val analyticsExposureWindow = createAnalyticsExposureWindow(
+                    it.key,
+                    it.value
+                )
+                analyticsExposureWindowRepository.addNew(analyticsExposureWindow)
+            }
+        }
+    }
+}
+
+private fun createAnalyticsExposureWindow(
+    window: ExposureWindow,
+    result: RiskResult
+) = AnalyticsExposureWindow(
+    calibrationConfidence = window.calibrationConfidence,
+    dateMillis = window.dateMillisSinceEpoch,
+    infectiousness = window.infectiousness,
+    reportType = window.reportType,
+    analyticsScanInstances = window.scanInstances.map { it.toAnalyticsScanInstance() },
+    normalizedTime = result.normalizedTime,
+    transmissionRiskLevel = result.transmissionRiskLevel
+)
+
+private fun ScanInstance.toAnalyticsScanInstance() = AnalyticsScanInstance(
+    minAttenuation = minAttenuationDb,
+    typicalAttenuation = typicalAttenuationDb,
+    secondsSinceLastScan = secondsSinceLastScan
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDatabase.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e9ed853129b1d54b67264d228ae1c5759cfcd0d2
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDatabase.kt
@@ -0,0 +1,154 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+import android.content.Context
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Delete
+import androidx.room.Embedded
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.Relation
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.Transaction
+import de.rki.coronawarnapp.util.di.AppContext
+import javax.inject.Inject
+
+@Database(
+    entities = [
+        AnalyticsExposureWindowEntity::class,
+        AnalyticsScanInstanceEntity::class,
+        AnalyticsReportedExposureWindowEntity::class
+    ],
+    version = 1
+)
+abstract class AnalyticsExposureWindowDatabase : RoomDatabase() {
+    abstract fun analyticsExposureWindowDao(): AnalyticsExposureWindowDao
+
+    class Factory @Inject constructor(@AppContext private val context: Context) {
+        fun create(): AnalyticsExposureWindowDatabase = Room
+            .databaseBuilder(
+                context,
+                AnalyticsExposureWindowDatabase::class.java,
+                "AnalyticsExposureWindow-db"
+            )
+            .fallbackToDestructiveMigration()
+            .build()
+    }
+}
+
+@Dao
+interface AnalyticsExposureWindowDao {
+    @Transaction
+    @Query("SELECT * FROM AnalyticsExposureWindowEntity")
+    suspend fun getAllNew(): List<AnalyticsExposureWindowEntityWrapper>
+
+    @Query("SELECT * FROM AnalyticsExposureWindowEntity WHERE sha256Hash LIKE :sha256Hash")
+    suspend fun getNew(sha256Hash: String): AnalyticsExposureWindowEntity?
+
+    @Query("SELECT * FROM AnalyticsReportedExposureWindowEntity WHERE sha256Hash LIKE :sha256Hash")
+    suspend fun getReported(sha256Hash: String): AnalyticsReportedExposureWindowEntity?
+
+    @Query("SELECT * FROM AnalyticsReportedExposureWindowEntity")
+    suspend fun getAllReported(): List<AnalyticsReportedExposureWindowEntity>
+
+    @Delete
+    suspend fun deleteExposureWindows(entities: List<AnalyticsExposureWindowEntity>)
+
+    @Delete
+    suspend fun deleteScanInstances(entities: List<AnalyticsScanInstanceEntity>)
+
+    @Delete
+    suspend fun deleteReported(entities: List<AnalyticsReportedExposureWindowEntity>)
+
+    @Insert(onConflict = OnConflictStrategy.IGNORE)
+    suspend fun insertExposureWindows(entities: List<AnalyticsExposureWindowEntity>): List<Long>
+
+    @Insert(onConflict = OnConflictStrategy.IGNORE)
+    suspend fun insertScanInstances(entity: List<AnalyticsScanInstanceEntity>): List<Long>
+
+    @Insert(onConflict = OnConflictStrategy.IGNORE)
+    suspend fun insertReported(entities: List<AnalyticsReportedExposureWindowEntity>): List<Long>
+
+    @Transaction
+    suspend fun insert(wrappers: List<AnalyticsExposureWindowEntityWrapper>) {
+        insertExposureWindows(wrappers.map { it.exposureWindowEntity })
+        insertScanInstances(wrappers.flatMap { it.scanInstanceEntities })
+    }
+
+    @Transaction
+    suspend fun moveToReported(
+        entities: List<AnalyticsExposureWindowEntityWrapper>,
+        timestamp: Long
+    ): List<AnalyticsReportedExposureWindowEntity> {
+        val reported = entities.map {
+            AnalyticsReportedExposureWindowEntity(
+                it.exposureWindowEntity.sha256Hash,
+                timestamp
+            )
+        }
+        deleteExposureWindows(entities.map { it.exposureWindowEntity })
+        deleteScanInstances(entities.flatMap { it.scanInstanceEntities })
+        insertReported(reported)
+        return reported
+    }
+
+    @Transaction
+    suspend fun rollback(
+        wrappers: List<AnalyticsExposureWindowEntityWrapper>,
+        reported: List<AnalyticsReportedExposureWindowEntity>
+    ) {
+        deleteReported(reported)
+        insertExposureWindows(wrappers.map { it.exposureWindowEntity })
+        insertScanInstances(wrappers.flatMap { it.scanInstanceEntities })
+    }
+
+    @Query("DELETE FROM AnalyticsReportedExposureWindowEntity WHERE timestamp < :timestamp")
+    suspend fun deleteReportedOlderThan(timestamp: Long)
+}
+
+class AnalyticsExposureWindowEntityWrapper(
+    @Embedded val exposureWindowEntity: AnalyticsExposureWindowEntity,
+    @Relation(parentColumn = PARENT_COLUMN, entityColumn = CHILD_COLUMN)
+    val scanInstanceEntities: List<AnalyticsScanInstanceEntity>
+)
+
+@Entity
+data class AnalyticsExposureWindowEntity(
+    @PrimaryKey(autoGenerate = false) val sha256Hash: String,
+    val calibrationConfidence: Int,
+    val dateMillis: Long,
+    val infectiousness: Int,
+    val reportType: Int,
+    val normalizedTime: Double,
+    val transmissionRiskLevel: Int
+)
+
+@Entity
+data class AnalyticsScanInstanceEntity(
+    @PrimaryKey(autoGenerate = true) val id: Long? = null,
+    @ForeignKey(
+        entity = AnalyticsExposureWindowEntity::class,
+        parentColumns = [PARENT_COLUMN],
+        childColumns = [CHILD_COLUMN],
+        onDelete = ForeignKey.CASCADE,
+        deferred = true
+    )
+    val fkSha256Hash: String,
+    val minAttenuation: Int,
+    val typicalAttenuation: Int,
+    val secondsSinceLastScan: Int
+)
+
+@Entity
+data class AnalyticsReportedExposureWindowEntity(
+    @PrimaryKey(autoGenerate = false) val sha256Hash: String,
+    val timestamp: Long
+)
+
+private const val PARENT_COLUMN = "sha256Hash"
+private const val CHILD_COLUMN = "fkSha256Hash"
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..20483943d92062ecf5fafdf68654d18f69fcb955
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonor.kt
@@ -0,0 +1,97 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+import androidx.annotation.VisibleForTesting
+import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule
+import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.random.Random
+
+@Singleton
+class AnalyticsExposureWindowDonor @Inject constructor(
+    private val analyticsExposureWindowRepository: AnalyticsExposureWindowRepository
+) : DonorModule {
+
+    override suspend fun beginDonation(request: DonorModule.Request): DonorModule.Contribution {
+        // clean up
+        analyticsExposureWindowRepository.deleteStaleData()
+
+        val probability = request.currentConfig.analytics.probabilityToSubmitNewExposureWindows
+        if (skipSubmission(probability)) {
+            Timber.w("Submission skipped.")
+            return AnalyticsExposureWindowNoContribution
+        }
+
+        val newWrappers = analyticsExposureWindowRepository.getAllNew()
+        val reported = analyticsExposureWindowRepository.moveToReported(newWrappers)
+        return Contribution(
+            data = newWrappers.asPpaData(),
+            onDonationFailed = { onDonationFailed(newWrappers, reported) }
+        )
+    }
+
+    override suspend fun deleteData() {
+        analyticsExposureWindowRepository.deleteAllData()
+    }
+
+    @VisibleForTesting
+    internal fun skipSubmission(probability: Double): Boolean {
+        // load balancing
+        val random = Random.nextDouble()
+        Timber.w("Random number is $random. probabilityToSubmitNewExposureWindows is $probability.")
+        return random > probability
+    }
+
+    @VisibleForTesting
+    internal suspend fun onDonationFailed(
+        newWrappers: List<AnalyticsExposureWindowEntityWrapper>,
+        reported: List<AnalyticsReportedExposureWindowEntity>
+    ) {
+        analyticsExposureWindowRepository.rollback(newWrappers, reported)
+    }
+
+    data class Contribution(
+        val data: List<PpaData.PPANewExposureWindow>,
+        val onDonationFailed: suspend () -> Unit
+    ) : DonorModule.Contribution {
+        override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) {
+            protobufContainer.addAllNewExposureWindows(data)
+        }
+
+        override suspend fun finishDonation(successful: Boolean) {
+            if (!successful) onDonationFailed()
+        }
+    }
+}
+
+@VisibleForTesting
+internal fun List<AnalyticsExposureWindowEntityWrapper>.asPpaData() = map {
+    val scanInstances = it.scanInstanceEntities.map { scanInstance ->
+        PpaData.PPAExposureWindowScanInstance.newBuilder()
+            .setMinAttenuation(scanInstance.minAttenuation)
+            .setTypicalAttenuation(scanInstance.typicalAttenuation)
+            .setSecondsSinceLastScan(scanInstance.secondsSinceLastScan)
+            .build()
+    }
+
+    val exposureWindow = PpaData.PPAExposureWindow.newBuilder()
+        .setDate(it.exposureWindowEntity.dateMillis)
+        .setCalibrationConfidence(it.exposureWindowEntity.calibrationConfidence)
+        .setInfectiousnessValue(it.exposureWindowEntity.infectiousness)
+        .setReportTypeValue(it.exposureWindowEntity.reportType)
+        .addAllScanInstances(scanInstances)
+        .build()
+
+    PpaData.PPANewExposureWindow.newBuilder()
+        .setExposureWindow(exposureWindow)
+        .setNormalizedTime(it.exposureWindowEntity.normalizedTime)
+        .setTransmissionRiskLevel(it.exposureWindowEntity.transmissionRiskLevel)
+        .build()
+}
+
+@VisibleForTesting
+object AnalyticsExposureWindowNoContribution : DonorModule.Contribution {
+    override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) = Unit
+    override suspend fun finishDonation(successful: Boolean) = Unit
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowModel.kt
new file mode 100644
index 0000000000000000000000000000000000000000..bf518144de1571050cd1fba3ad7aeb28c44180cd
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowModel.kt
@@ -0,0 +1,17 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+data class AnalyticsExposureWindow(
+    val calibrationConfidence: Int,
+    val dateMillis: Long,
+    val infectiousness: Int,
+    val reportType: Int,
+    val analyticsScanInstances: List<AnalyticsScanInstance>,
+    val normalizedTime: Double,
+    val transmissionRiskLevel: Int
+)
+
+data class AnalyticsScanInstance(
+    val minAttenuation: Int,
+    val typicalAttenuation: Int,
+    val secondsSinceLastScan: Int
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowRepository.kt
new file mode 100644
index 0000000000000000000000000000000000000000..7b763695aaf8fad25144cd4c544cf0bf49534bf4
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowRepository.kt
@@ -0,0 +1,85 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+import androidx.annotation.VisibleForTesting
+import de.rki.coronawarnapp.util.HashExtensions.toSHA256
+import de.rki.coronawarnapp.util.TimeStamper
+import org.joda.time.Days
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class AnalyticsExposureWindowRepository @Inject constructor(
+    private val databaseFactory: AnalyticsExposureWindowDatabase.Factory,
+    private val timeStamper: TimeStamper
+) {
+    private val database by lazy {
+        databaseFactory.create()
+    }
+
+    private val dao by lazy {
+        database.analyticsExposureWindowDao()
+    }
+
+    suspend fun getAllNew(): List<AnalyticsExposureWindowEntityWrapper> {
+        return dao.getAllNew()
+    }
+
+    suspend fun addNew(analyticsExposureWindow: AnalyticsExposureWindow) {
+        val hash = analyticsExposureWindow.sha256Hash()
+        if (dao.getReported(hash) == null && dao.getNew(hash) == null) {
+            val wrapper = analyticsExposureWindow.toWrapper(hash)
+            dao.insert(listOf(wrapper))
+        }
+    }
+
+    suspend fun moveToReported(
+        wrapperEntities: List<AnalyticsExposureWindowEntityWrapper>
+    ): List<AnalyticsReportedExposureWindowEntity> {
+        return dao.moveToReported(wrapperEntities, timeStamper.nowUTC.millis)
+    }
+
+    suspend fun rollback(
+        wrappers: List<AnalyticsExposureWindowEntityWrapper>,
+        reported: List<AnalyticsReportedExposureWindowEntity>
+    ) {
+        dao.rollback(wrappers, reported)
+    }
+
+    suspend fun deleteStaleData() {
+        val timestamp = timeStamper.nowUTC.minus(Days.days(15).toStandardDuration()).millis
+        dao.deleteReportedOlderThan(timestamp)
+    }
+
+    suspend fun deleteAllData() {
+        val new = dao.getAllNew()
+        dao.deleteExposureWindows(new.map { it.exposureWindowEntity })
+        dao.deleteScanInstances(new.flatMap { it.scanInstanceEntities })
+        dao.deleteReported(dao.getAllReported())
+    }
+}
+
+@VisibleForTesting
+internal fun AnalyticsExposureWindow.sha256Hash() = toString().toSHA256()
+
+@VisibleForTesting
+internal fun AnalyticsExposureWindow.toWrapper(key: String) =
+    AnalyticsExposureWindowEntityWrapper(
+        exposureWindowEntity = AnalyticsExposureWindowEntity(
+            sha256Hash = key,
+            calibrationConfidence = calibrationConfidence,
+            dateMillis = dateMillis,
+            infectiousness = infectiousness,
+            reportType = reportType,
+            normalizedTime = normalizedTime,
+            transmissionRiskLevel = transmissionRiskLevel
+        ),
+        scanInstanceEntities = analyticsScanInstances.map { it.toEntity(key) }
+    )
+
+private fun AnalyticsScanInstance.toEntity(foreignKey: String) =
+    AnalyticsScanInstanceEntity(
+        fkSha256Hash = foreignKey,
+        minAttenuation = minAttenuation,
+        typicalAttenuation = typicalAttenuation,
+        secondsSinceLastScan = secondsSinceLastScan
+    )
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/NewExposureWindowsDonor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/NewExposureWindowsDonor.kt
deleted file mode 100644
index 993e333c1b42800b3438b25daffd9d132c5a4c5c..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/NewExposureWindowsDonor.kt
+++ /dev/null
@@ -1,36 +0,0 @@
-package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
-
-import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule
-import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class NewExposureWindowsDonor @Inject constructor() : DonorModule {
-
-    override suspend fun beginDonation(request: DonorModule.Request): DonorModule.Contribution {
-        return CollectedData(
-            protobuf = Any(),
-            onContributionFinished = { success ->
-                // TODO
-            }
-        )
-    }
-
-    override suspend fun deleteData() {
-        // TODO
-    }
-
-    data class CollectedData(
-        val protobuf: Any,
-        val onContributionFinished: suspend (Boolean) -> Unit
-    ) : DonorModule.Contribution {
-        override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) {
-            // TODO "Add this specific protobuf to the top level protobuf container"
-        }
-
-        override suspend fun finishDonation(successful: Boolean) {
-            onContributionFinished(successful)
-        }
-    }
-}
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
index 99ce95839712ad6ee1a6834b249651c24a896226..734d7ff3de2b076aa8307f5c99c4e379730d17b2 100644
--- 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
@@ -1,7 +1,6 @@
 package de.rki.coronawarnapp.risk
 
 import android.text.TextUtils
-import androidx.annotation.VisibleForTesting
 import com.google.android.gms.nearby.exposurenotification.ExposureWindow
 import com.google.android.gms.nearby.exposurenotification.Infectiousness
 import com.google.android.gms.nearby.exposurenotification.ReportType
@@ -19,18 +18,6 @@ import kotlin.math.max
 @Singleton
 class DefaultRiskLevels @Inject constructor() : RiskLevels {
 
-    override fun determineRisk(
-        appConfig: ExposureWindowRiskCalculationConfig,
-        exposureWindows: List<ExposureWindow>
-    ): AggregatedRiskResult {
-        val riskResultsPerWindow =
-            exposureWindows.mapNotNull { window ->
-                calculateRisk(appConfig, window)?.let { window to it }
-            }.toMap()
-
-        return aggregateResults(appConfig, riskResultsPerWindow)
-    }
-
     private fun ExposureWindow.dropDueToMinutesAtAttenuation(
         attenuationFilters: List<RiskCalculationParametersOuterClass.MinutesAtAttenuationFilter>
     ) =
@@ -105,8 +92,7 @@ class DefaultRiskLevels @Inject constructor() : RiskLevels {
             .map { it.riskLevel }
             .firstOrNull()
 
-    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
-    internal fun calculateRisk(
+    override fun calculateRisk(
         appConfig: ExposureWindowRiskCalculationConfig,
         exposureWindow: ExposureWindow
     ): RiskResult? {
@@ -162,12 +148,11 @@ class DefaultRiskLevels @Inject constructor() : RiskLevels {
         )
     }
 
-    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
-    internal fun aggregateResults(
+    override fun aggregateResults(
         appConfig: ExposureWindowRiskCalculationConfig,
-        exposureWindowsAndResult: Map<ExposureWindow, RiskResult>
+        exposureWindowResultMap: Map<ExposureWindow, RiskResult>
     ): AggregatedRiskResult {
-        val uniqueDatesMillisSinceEpoch = exposureWindowsAndResult.keys
+        val uniqueDatesMillisSinceEpoch = exposureWindowResultMap.keys
             .map { it.dateMillisSinceEpoch }
             .toSet()
 
@@ -176,7 +161,7 @@ class DefaultRiskLevels @Inject constructor() : RiskLevels {
             { TextUtils.join(System.lineSeparator(), uniqueDatesMillisSinceEpoch) }
         )
         val exposureHistory = uniqueDatesMillisSinceEpoch.map {
-            aggregateRiskPerDate(appConfig, it, exposureWindowsAndResult)
+            aggregateRiskPerDate(appConfig, it, exposureWindowResultMap)
         }
 
         Timber.d("exposureHistory size: ${exposureHistory.size}")
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
index 7b411e3a8ec163bddad5df5c053ad96a4591c648..058d658c79fd84cf764a8176046b2baf158f8a8b 100644
--- 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
@@ -1,9 +1,11 @@
 package de.rki.coronawarnapp.risk
 
 import android.content.Context
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.appconfig.ConfigData
 import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig
+import de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowCollector
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
@@ -11,6 +13,7 @@ import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
 import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
 import de.rki.coronawarnapp.risk.RiskLevelResult.FailureReason
+import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.task.Task
@@ -31,7 +34,7 @@ import timber.log.Timber
 import javax.inject.Inject
 import javax.inject.Provider
 
-@Suppress("ReturnCount")
+@Suppress("ReturnCount", "LongParameterList")
 class RiskLevelTask @Inject constructor(
     private val riskLevels: RiskLevels,
     @AppContext private val context: Context,
@@ -41,7 +44,8 @@ class RiskLevelTask @Inject constructor(
     private val riskLevelSettings: RiskLevelSettings,
     private val appConfigProvider: AppConfigProvider,
     private val riskLevelStorage: RiskLevelStorage,
-    private val keyCacheRepository: KeyCacheRepository
+    private val keyCacheRepository: KeyCacheRepository,
+    private val analyticsExposureWindowCollector: AnalyticsExposureWindowCollector
 ) : Task<DefaultProgress, RiskLevelTaskResult> {
 
     private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>()
@@ -153,7 +157,7 @@ class RiskLevelTask @Inject constructor(
         Timber.tag(TAG).d("Calculating risklevel")
         val exposureWindows = enfClient.exposureWindows()
 
-        return riskLevels.determineRisk(configData, exposureWindows).let {
+        return determineRisk(configData, exposureWindows).let {
             Timber.tag(TAG).d("Risklevel calculated: %s", it)
             if (it.isIncreasedRisk()) {
                 Timber.tag(TAG).i("Risk is increased!")
@@ -169,6 +173,20 @@ class RiskLevelTask @Inject constructor(
         }
     }
 
+    private suspend fun determineRisk(
+        appConfig: ExposureWindowRiskCalculationConfig,
+        exposureWindows: List<ExposureWindow>
+    ): AggregatedRiskResult {
+        val riskResultsPerWindow =
+            exposureWindows.mapNotNull { window ->
+                riskLevels.calculateRisk(appConfig, window)?.let { window to it }
+            }.toMap()
+
+        analyticsExposureWindowCollector.reportRiskResultsPerWindow(riskResultsPerWindow)
+
+        return riskLevels.aggregateResults(appConfig, riskResultsPerWindow)
+    }
+
     private suspend fun backgroundJobsEnabled() =
         backgroundModeStatus.isAutoModeEnabled.first().also {
             if (it) {
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
index a3ee1addcbcb891f02a737e1e913732362049617..30089d352db9a570d12661d5474a0eca9ce82047 100644
--- 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
@@ -3,11 +3,17 @@ package de.rki.coronawarnapp.risk
 import com.google.android.gms.nearby.exposurenotification.ExposureWindow
 import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig
 import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
+import de.rki.coronawarnapp.risk.result.RiskResult
 
 interface RiskLevels {
 
-    fun determineRisk(
+    fun calculateRisk(
         appConfig: ExposureWindowRiskCalculationConfig,
-        exposureWindows: List<ExposureWindow>
+        exposureWindow: ExposureWindow
+    ): RiskResult?
+
+    fun aggregateResults(
+        appConfig: ExposureWindowRiskCalculationConfig,
+        exposureWindowResultMap: Map<ExposureWindow, RiskResult>
     ): AggregatedRiskResult
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TextInputEditTextExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TextInputEditTextExtensions.kt
new file mode 100644
index 0000000000000000000000000000000000000000..828d07c13a7bbbf9feef739a4436374d37f58a7b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TextInputEditTextExtensions.kt
@@ -0,0 +1,12 @@
+package de.rki.coronawarnapp.util
+
+import com.google.android.material.textfield.TextInputEditText
+import com.google.android.material.textfield.TextInputLayout
+
+fun TextInputEditText.setTextOnTextInput(
+    text: String?,
+    endIconVisible: Boolean = true
+) {
+    this.setText(text)
+    (parent?.parent as? TextInputLayout)?.isEndIconVisible = endIconVisible
+}
diff --git a/Corona-Warn-App/src/main/res/layout/contact_diary_add_location_fragment.xml b/Corona-Warn-App/src/main/res/layout/contact_diary_add_location_fragment.xml
index 6b605a2815d151fa890c93c30566a5d92a2633e9..1e1ec8dbe6c01241da337b636491fd0a4209fc6a 100644
--- a/Corona-Warn-App/src/main/res/layout/contact_diary_add_location_fragment.xml
+++ b/Corona-Warn-App/src/main/res/layout/contact_diary_add_location_fragment.xml
@@ -9,7 +9,7 @@
         android:layout_height="wrap_content">
 
         <ImageView
-            android:id="@+id/contact_diary_add_location_close_button"
+            android:id="@+id/location_close_button"
             style="@style/buttonIcon"
             android:layout_width="@dimen/button_icon"
             android:layout_height="@dimen/button_icon"
@@ -22,19 +22,19 @@
             app:layout_constraintTop_toTopOf="parent" />
 
         <TextView
-            android:id="@+id/contact_diary_add_location_title"
+            android:id="@+id/location_title"
             style="@style/headline6"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_marginStart="@dimen/spacing_normal"
             android:text="@string/contact_diary_add_location_title"
-            app:layout_constraintBottom_toBottomOf="@id/contact_diary_add_location_close_button"
-            app:layout_constraintEnd_toStartOf="@id/contact_diary_add_location_delete_button"
-            app:layout_constraintStart_toEndOf="@id/contact_diary_add_location_close_button"
-            app:layout_constraintTop_toTopOf="@id/contact_diary_add_location_close_button" />
+            app:layout_constraintBottom_toBottomOf="@id/location_close_button"
+            app:layout_constraintEnd_toStartOf="@id/location_delete_button"
+            app:layout_constraintStart_toEndOf="@id/location_close_button"
+            app:layout_constraintTop_toTopOf="@id/location_close_button" />
 
         <ImageView
-            android:id="@+id/contact_diary_add_location_delete_button"
+            android:id="@+id/location_delete_button"
             style="@style/buttonIcon"
             android:layout_width="@dimen/button_icon"
             android:layout_height="@dimen/button_icon"
@@ -47,19 +47,20 @@
             app:layout_constraintTop_toTopOf="parent" />
 
         <com.google.android.material.textfield.TextInputLayout
-            android:id="@+id/contact_diary_add_location_name_input_layout"
+            android:id="@+id/location_name_input_layout"
             style="@style/TextInputLayoutTheme"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_marginHorizontal="@dimen/spacing_normal"
             android:layout_marginTop="@dimen/spacing_small"
             android:hint="@string/contact_diary_add_location_text_input_hint"
+            app:endIconMode="clear_text"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/contact_diary_add_location_close_button">
+            app:layout_constraintTop_toBottomOf="@id/location_close_button">
 
             <com.google.android.material.textfield.TextInputEditText
-                android:id="@+id/contact_diary_add_location_name_input_edit_text"
+                android:id="@+id/location_name_input_edit"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:imeOptions="actionNext"
@@ -68,19 +69,20 @@
         </com.google.android.material.textfield.TextInputLayout>
 
         <com.google.android.material.textfield.TextInputLayout
-            android:id="@+id/contact_diary_add_location_phone_input_layout"
+            android:id="@+id/location_phone_input_layout"
             style="@style/TextInputLayoutTheme"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_marginHorizontal="@dimen/spacing_normal"
             android:layout_marginTop="@dimen/spacing_tiny"
             android:hint="@string/contact_diary_add_text_input_phone_hint"
+            app:endIconMode="clear_text"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/contact_diary_add_location_name_input_layout">
+            app:layout_constraintTop_toBottomOf="@id/location_name_input_layout">
 
             <com.google.android.material.textfield.TextInputEditText
-                android:id="@+id/contact_diary_add_location_phone_input_edit_text"
+                android:id="@+id/location_phone_input"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:imeOptions="actionNext"
@@ -89,19 +91,20 @@
         </com.google.android.material.textfield.TextInputLayout>
 
         <com.google.android.material.textfield.TextInputLayout
-            android:id="@+id/contact_diary_add_location_email_input_layout"
+            android:id="@+id/location_email_input_layout"
             style="@style/TextInputLayoutTheme"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_marginHorizontal="@dimen/spacing_normal"
             android:layout_marginTop="@dimen/spacing_tiny"
             android:hint="@string/contact_diary_add_text_input_email_hint"
+            app:endIconMode="clear_text"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/contact_diary_add_location_phone_input_layout">
+            app:layout_constraintTop_toBottomOf="@id/location_phone_input_layout">
 
             <com.google.android.material.textfield.TextInputEditText
-                android:id="@+id/contact_diary_add_location_email_input_edit_text"
+                android:id="@+id/location_email_input"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:imeOptions="actionDone"
@@ -110,7 +113,7 @@
         </com.google.android.material.textfield.TextInputLayout>
 
         <Button
-            android:id="@+id/contact_diary_add_location_save_button"
+            android:id="@+id/location_save_button"
             style="@style/buttonPrimary"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
@@ -122,7 +125,7 @@
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintHorizontal_bias="0.166"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@+id/contact_diary_add_location_email_input_layout"
+            app:layout_constraintTop_toBottomOf="@+id/location_email_input_layout"
             app:layout_constraintVertical_bias="1.0" />
     </androidx.constraintlayout.widget.ConstraintLayout>
 </ScrollView>
diff --git a/Corona-Warn-App/src/main/res/layout/contact_diary_add_person_fragment.xml b/Corona-Warn-App/src/main/res/layout/contact_diary_add_person_fragment.xml
index 90420e38aacae58b2a691c99c11305ba18e98e40..2163a98fb554e92ad433c7b14c37fcca9376e0bd 100644
--- a/Corona-Warn-App/src/main/res/layout/contact_diary_add_person_fragment.xml
+++ b/Corona-Warn-App/src/main/res/layout/contact_diary_add_person_fragment.xml
@@ -9,7 +9,7 @@
         android:layout_height="wrap_content">
 
         <ImageView
-            android:id="@+id/contact_diary_person_close_button"
+            android:id="@+id/person_close_button"
             style="@style/buttonIcon"
             android:layout_width="@dimen/button_icon"
             android:layout_height="@dimen/button_icon"
@@ -28,12 +28,12 @@
             android:layout_height="wrap_content"
             android:layout_marginStart="@dimen/spacing_normal"
             android:text="@string/contact_diary_add_person_title"
-            app:layout_constraintBottom_toBottomOf="@+id/contact_diary_person_close_button"
-            app:layout_constraintStart_toEndOf="@+id/contact_diary_person_close_button"
-            app:layout_constraintTop_toTopOf="@+id/contact_diary_person_close_button" />
+            app:layout_constraintBottom_toBottomOf="@+id/person_close_button"
+            app:layout_constraintStart_toEndOf="@+id/person_close_button"
+            app:layout_constraintTop_toTopOf="@+id/person_close_button" />
 
         <ImageView
-            android:id="@+id/contact_diary_person_delete_button"
+            android:id="@+id/person_delete_button"
             style="@style/buttonIcon"
             android:layout_width="@dimen/button_icon"
             android:layout_height="@dimen/button_icon"
@@ -46,41 +46,42 @@
             app:layout_constraintTop_toTopOf="parent" />
 
         <com.google.android.material.textfield.TextInputLayout
-            android:id="@+id/contact_diary_person_name_input_layout"
+            android:id="@+id/person_name_input_layout"
             style="@style/TextInputLayoutTheme"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_marginHorizontal="@dimen/spacing_normal"
             android:layout_marginTop="@dimen/spacing_small"
             android:hint="@string/contact_diary_add_person_text_input_name_hint"
+            app:endIconMode="clear_text"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@+id/contact_diary_person_close_button">
+            app:layout_constraintTop_toBottomOf="@+id/person_close_button">
 
             <com.google.android.material.textfield.TextInputEditText
-                android:id="@+id/contact_diary_person_name_edit_text"
+                android:id="@+id/person_name_input"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:imeOptions="actionNext"
-                android:inputType="textCapWords"
-                app:backgroundTint="@color/colorContactDiaryListItem" />
+                android:inputType="textCapWords" />
 
         </com.google.android.material.textfield.TextInputLayout>
 
         <com.google.android.material.textfield.TextInputLayout
-            android:id="@+id/contact_diary_person_phone_input_layout"
+            android:id="@+id/person_phone_input_layout"
             style="@style/TextInputLayoutTheme"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
             android:layout_marginHorizontal="@dimen/spacing_normal"
             android:layout_marginTop="@dimen/spacing_tiny"
             android:hint="@string/contact_diary_add_text_input_phone_hint"
+            app:endIconMode="clear_text"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@+id/contact_diary_person_name_input_layout">
+            app:layout_constraintTop_toBottomOf="@+id/person_name_input_layout">
 
             <com.google.android.material.textfield.TextInputEditText
-                android:id="@+id/contact_diary_person_phone_number_edit_text"
+                android:id="@+id/person_phone_number_input"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:imeOptions="actionNext"
@@ -89,7 +90,7 @@
         </com.google.android.material.textfield.TextInputLayout>
 
         <com.google.android.material.textfield.TextInputLayout
-            android:id="@+id/contact_diary_person_email_input_layout"
+            android:id="@+id/person_email_input_layout"
             style="@style/TextInputLayoutTheme"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
@@ -97,12 +98,13 @@
             android:layout_marginTop="@dimen/spacing_tiny"
             android:layout_marginBottom="@dimen/spacing_small"
             android:hint="@string/contact_diary_add_text_input_email_hint"
+            app:endIconMode="clear_text"
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@+id/contact_diary_person_phone_input_layout">
+            app:layout_constraintTop_toBottomOf="@+id/person_phone_input_layout">
 
             <com.google.android.material.textfield.TextInputEditText
-                android:id="@+id/contact_diary_person_email_edit_text"
+                android:id="@+id/person_email_input"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:imeOptions="actionDone"
@@ -111,7 +113,7 @@
         </com.google.android.material.textfield.TextInputLayout>
 
         <Button
-            android:id="@+id/contact_diary_person_save_button"
+            android:id="@+id/person_save_button"
             style="@style/buttonPrimary"
             android:layout_width="0dp"
             android:layout_height="wrap_content"
@@ -123,7 +125,7 @@
             app:layout_constraintEnd_toEndOf="parent"
             app:layout_constraintHorizontal_bias="0.333"
             app:layout_constraintStart_toStartOf="parent"
-            app:layout_constraintTop_toBottomOf="@id/contact_diary_person_email_input_layout"
+            app:layout_constraintTop_toBottomOf="@id/person_email_input_layout"
             app:layout_constraintVertical_bias="1.0" />
     </androidx.constraintlayout.widget.ConstraintLayout>
 </ScrollView>
diff --git a/Corona-Warn-App/src/main/res/layout/contact_diary_duration_picker_dialog_fragment.xml b/Corona-Warn-App/src/main/res/layout/contact_diary_duration_picker_dialog_fragment.xml
index 4a5b66cef9fd8e37cb7d063f7782ff6e8a6ae9d8..b222b3df117080fe443a2c65fe306bc5ca791ba2 100644
--- a/Corona-Warn-App/src/main/res/layout/contact_diary_duration_picker_dialog_fragment.xml
+++ b/Corona-Warn-App/src/main/res/layout/contact_diary_duration_picker_dialog_fragment.xml
@@ -61,11 +61,12 @@
 
     <Button
         android:id="@+id/cancel_button"
-        style="@style/buttonLight"
+        style="@style/Widget.AppCompat.Button.ButtonBar.AlertDialog"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
         android:layout_marginTop="29dp"
         android:layout_marginBottom="@dimen/spacing_small"
+        android:letterSpacing="0.08"
         android:text="@string/duration_dialog_cancel_button"
         android:textColor="@color/colorTextTint"
         app:layout_constraintBottom_toBottomOf="parent"
@@ -74,12 +75,13 @@
 
     <Button
         android:id="@+id/ok_button"
-        style="@style/buttonLight"
+        style="@style/Widget.AppCompat.Button.ButtonBar.AlertDialog"
         android:layout_width="0dp"
         android:layout_height="wrap_content"
         android:layout_marginTop="29dp"
-        android:layout_marginEnd="14dp"
+        android:layout_marginEnd="26dp"
         android:layout_marginBottom="@dimen/spacing_small"
+        android:letterSpacing="0.08"
         android:text="@string/duration_dialog_ok_button"
         android:textColor="@color/colorTextTint"
         app:layout_constraintBottom_toBottomOf="parent"
diff --git a/Corona-Warn-App/src/main/res/layout/contact_diary_location_list_item.xml b/Corona-Warn-App/src/main/res/layout/contact_diary_location_list_item.xml
index 26c08436cf8452e4495b48674ee599bddecbd333..3de93f29fcd5a4f7fecad7347cefc6ae7dbc4cbb 100644
--- a/Corona-Warn-App/src/main/res/layout/contact_diary_location_list_item.xml
+++ b/Corona-Warn-App/src/main/res/layout/contact_diary_location_list_item.xml
@@ -26,11 +26,16 @@
 
         <TextView
             android:id="@+id/duration_input"
-            android:layout_width="wrap_content"
+            style="@style/bodyNeutral"
+            android:layout_width="70dp"
             android:layout_height="wrap_content"
             android:layout_gravity="center_vertical|end"
-            android:padding="8dp"
-            android:text="00:00" />
+            android:paddingTop="11dp"
+            android:paddingBottom="11dp"
+            android:paddingLeft="13dp"
+            android:paddingRight="13dp"
+            android:text="@string/duration_dialog_default_value"
+            android:background="@drawable/contact_diary_duration_background_default"/>
     </FrameLayout>
 
     <de.rki.coronawarnapp.contactdiary.ui.day.tabs.common.DiaryCircumstancesTextView
diff --git a/Corona-Warn-App/src/main/res/navigation/contact_diary_nav_graph.xml b/Corona-Warn-App/src/main/res/navigation/contact_diary_nav_graph.xml
index c51fa6233504c24597917c39694102d63bf3b5c6..48be50fd3f68da5ac7cf2bf68c357accfe4c5b38 100644
--- a/Corona-Warn-App/src/main/res/navigation/contact_diary_nav_graph.xml
+++ b/Corona-Warn-App/src/main/res/navigation/contact_diary_nav_graph.xml
@@ -20,6 +20,9 @@
             android:id="@+id/action_contactDiaryDayFragment_to_contactDiaryAddLocationFragment"
             app:destination="@id/contactDiaryAddLocationFragment" />
         <deepLink app:uri="coronawarnapp://contact-journal/day/{selectedDay}" />
+        <action
+            android:id="@+id/action_contactDiaryDayFragment_to_contactDiaryCommentInfoFragment"
+            app:destination="@id/contactDiaryCommentInfoFragment" />
     </fragment>
     <fragment
         android:id="@+id/contactDiaryPersonListFragment"
@@ -29,9 +32,6 @@
         <argument
             android:name="selectedDay"
             app:argType="string" />
-        <action
-            android:id="@+id/action_contactDiaryPersonListFragment_to_contactDiaryPersonCommentInfoFragment"
-            app:destination="@id/contactDiaryCommentInfoFragment" />
     </fragment>
     <fragment
         android:id="@+id/contactDiaryPlaceListFragment"
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 f05ceb616eafe5dea2ba8cb70c7a7557e5b10e56..fbdda0b02b459d441f2450af2e29f76ea66c72ca 100644
--- a/Corona-Warn-App/src/main/res/values-bg/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-bg/strings.xml
@@ -1877,4 +1877,4 @@
     <string name="analytics_userinput_district_title">"В кой окръг се намирате"</string>
     <!-- XTXT: Analytics voluntary user input, district: UNSPECIFIED -->
     <string name="analytics_userinput_district_unspecified">"Без отговор"</string>
-</resources>
\ No newline at end of file
+</resources>
diff --git a/Corona-Warn-App/src/main/res/values-de/contact_diary_strings.xml b/Corona-Warn-App/src/main/res/values-de/contact_diary_strings.xml
index 72abf54c41ffaef59f4815031746ad4b7ac6217e..623060853d1f1d1bfc7f6ededf40bad77bbcbb59 100644
--- a/Corona-Warn-App/src/main/res/values-de/contact_diary_strings.xml
+++ b/Corona-Warn-App/src/main/res/values-de/contact_diary_strings.xml
@@ -151,9 +151,9 @@
     <string name="accessibility_day_add_location">"Ort hinzufügen"</string>
 
     <!-- XBUT: Option - person encounter - below 15 Min -->
-    <string name="contact_diary_person_encounter_duration_below_15_min">unter 15 Min</string>
+    <string name="contact_diary_person_encounter_duration_below_15_min">unter 15 Min.</string>
     <!-- XBUT: Option - person encounter - above 15 Min -->
-    <string name="contact_diary_person_encounter_duration_above_15_min">über 15 Min</string>
+    <string name="contact_diary_person_encounter_duration_above_15_min">über 15 Min.</string>
     <!-- XBUT: Option - person encounter - with mask -->
     <string name="contact_diary_person_encounter_mask_with">mit Maske</string>
     <!-- XBUT: Option - person encounter - without mask -->
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 8d848ddb9485be9b63290b219540ca5ba3fb5679..4fb850acfa2e2da6abab63c214967d5588c5facf 100644
--- a/Corona-Warn-App/src/main/res/values-de/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-de/strings.xml
@@ -538,13 +538,13 @@
     <!-- XTXT: onboarding privacy preserving analytics (ppa) - regional evaluation text -->
     <string name="onboarding_ppa_regional_evaluation_text">"Wenn Sie zusätzlich Ihr Bundesland und Ihre Region angeben, können wir regionale Auswertungen durchführen."</string>
     <!-- XTXT: onboarding privacy preserving analytics (ppa) - age title -->
-    <string name="onboarding_ppa_age_title">"Ihr Alter (optional)"</string>
+    <string name="onboarding_ppa_age_title">"Ihre Altersgruppe (optional)"</string>
     <!-- XTXT: onboarding privacy preserving analytics (ppa) - consent title -->
     <string name="onboarding_ppa_more_info_title">"Ausführliche Informationen zu dieser Datenverarbeitung und den Datenschutzrisiken in den USA und anderen Drittländern"</string>
     <!-- XBUT: onboarding privacy preserving analytics (ppa) - donate button -->
-    <string name="onboarding_ppa_consent_donate_button">"Daten spenden"</string>
+    <string name="onboarding_ppa_consent_donate_button">"Einverstanden"</string>
     <!-- XBUT: onboarding privacy preserving analytics (ppa) - dont donate button -->
-    <string name="onboarding_ppa_consent_not_donate_button">"Nicht spenden"</string>
+    <string name="onboarding_ppa_consent_not_donate_button">"Nicht Einverstanden"</string>
 
     <!-- XHED: onboarding privacy preserving analytics (ppa) - more info - headline -->
     <string name="onboarding_ppa_more_info_headline">"Ausführliche Informationen zur Datenspende"</string>
@@ -1834,7 +1834,7 @@
     <!-- XTXT: Analytics voluntary user input, age group: AGE_GROUP_UNSPECIFIED -->
     <string name="analytics_userinput_agegroup_unspecified">keine Angabe</string>
     <!-- XTXT: Analytics voluntary user input, age group: AGE_GROUP_0_TO_29 -->
-    <string name="analytics_userinput_agegroup_0_to_29">0-29 Jahre</string>
+    <string name="analytics_userinput_agegroup_0_to_29">bis 29 Jahre</string>
     <!-- XTXT: Analytics voluntary user input, age group: AGE_GROUP_30_TO_59 -->
     <string name="analytics_userinput_agegroup_30_to_59">30-59 Jahre</string>
     <!-- XTXT: Analytics voluntary user input, age group: AGE_GROUP_FROM_60 -->
diff --git a/Corona-Warn-App/src/main/res/values/contact_diary_strings.xml b/Corona-Warn-App/src/main/res/values/contact_diary_strings.xml
index 1db1884f3cd01ac69d8cc85dd4609bfcda3d59b3..b7276c4c7e70bbcdae399d81b0bc44473134dfad 100644
--- a/Corona-Warn-App/src/main/res/values/contact_diary_strings.xml
+++ b/Corona-Warn-App/src/main/res/values/contact_diary_strings.xml
@@ -118,10 +118,32 @@
 
     <!-- XHED: Title for the contact journal export email subject -->
     <string name="contact_diary_export_subject" translatable="false">"Mein Kontakt-Tagebuch"</string>
-    <!-- XTXT: Intro text  for the contact journal email export part one-->
+    <!-- XTXT: Intro text for the contact journal email export part one-->
     <string name="contact_diary_export_intro_one" translatable="false">"Kontakte der letzten 15 Tage (%1$s - %2$s)"</string>
-    <!-- XTXT: Intro text  for the contact journal email export part two-->
+    <!-- XTXT: Intro text for the contact journal email export part two-->
     <string name="contact_diary_export_intro_two" translatable="false">"Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG."</string>
+    <!-- XTXT: Phone number prefix in the contact journal export-->
+    <string name="contact_diary_export_prefix_phone" translatable="false">"Tel."</string>
+    <!-- XTXT: EMail prefix in the contact journal export-->
+    <string name="contact_diary_export_prefix_email" translatable="false">"E-Mail"</string>
+    <!-- XTXT: Additional information about duration that was longer than 15 minutes in the contact journal export-->
+    <string name="contact_diary_export_durations_longer_than_15min" translatable="false">"Kontaktdauer &gt; 15 Minuten"</string>
+    <!-- XTXT: Additional information about duration that was less than 15 minutes in the contact journal export-->
+    <string name="contact_diary_export_durations_less_than_15min" translatable="false">"Kontaktdauer &lt; 15 Minuten"</string>
+    <!-- XTXT: Additional information about wearing a mask in the contact journal export-->
+    <string name="contact_diary_export_wearing_mask" translatable="false">"mit Maske"</string>
+    <!-- XTXT: Additional information about wearing no mask in the contact journal export-->
+    <string name="contact_diary_export_wearing_no_mask" translatable="false">"ohne Maske"</string>
+    <!-- XTXT: Additional information about being indoors in the contact journal export-->
+    <string name="contact_diary_export_indoor" translatable="false">"im Gebäude"</string>
+    <!-- XTXT: Additional information about being outdoor in the contact journal export-->
+    <string name="contact_diary_export_outdoor" translatable="false">"im Freien"</string>
+    <!-- XTXT: Location duration prefix in the contact journal export-->
+    <string name="contact_diary_export_location_duration_prefix" translatable="false">"Dauer"</string>
+    <!-- XTXT: Location duration postfix in the contact journal export-->
+    <string name="contact_diary_export_location_duration_suffix" translatable="false">"h"</string>
+    <!-- XTXT: Location duration prefix in the contact journal overview-->
+    <string name="contact_diary_overview_location_duration_suffix" translatable="false">"Std."</string>
 
     <!-- XHED: Title for the contact diary comment info screen -->
     <string name="contact_diary_comment_info_screen_title">"Notiz"</string>
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 1b7680961b5f22d45112d8a2fe248a307fb69fad..1ad11eafa6fd0d46eb4aefe7d2c89c6bfdce6384 100644
--- a/Corona-Warn-App/src/main/res/values/legal_strings.xml
+++ b/Corona-Warn-App/src/main/res/values/legal_strings.xml
@@ -12,7 +12,7 @@
     <!-- XHED: onboarding(tracing) - headline for consent information -->
     <string name="onboarding_tracing_headline_consent" translatable="false">"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 “Enable 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\nYour Android smartphone will then continuously generate random IDs and send 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 up-to-date lists every day of the random IDs of all users who have shared their test result (more precisely: their own random IDs) via their official coronavirus app in order to warn other users. These lists are then compared with the random IDs of other users which have been recorded by your Android 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 Android 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 Android 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\nYou can withdraw your consent to the exposure logging feature at any time by either disabling the feature using the toggle switch in the app or deleting 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 the “COVID-19 Exposure Notifications” functionality in your Android smartphone’s settings. Please note that the app will not delete your random IDs or those received from other smartphones which have been recorded by this functionality on your Android smartphone. 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\nYour Android smartphone will then continuously generate random IDs and send 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 up-to-date lists every day of the random IDs of all users who have shared their test result (more precisely: their own random IDs) via their official coronavirus app in order to warn other users. These lists are then compared with the random IDs of other users which have been recorded by your Android 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 Android 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 Android 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\nYou can withdraw your consent to the exposure logging feature at any time by either disabling the feature using the toggle switch in the app or deleting 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 the “COVID-19 Exposure Notifications” functionality in your Android smartphone’s settings. Please note that the app will not delete your random IDs or those received from other smartphones which have been recorded by this functionality on your Android smartphone. 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>
     <!-- XHED: Page subheadline for consent sub section your consent  -->
    <string name="submission_consent_your_consent_subsection_headline" translatable="false">"Your Consent"</string>
     <!-- YTXT: Body for consent sub section your consent subtext -->
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/DurationExtensionKtTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/DurationExtensionKtTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..4bfa961ca21dcfff47bb170e56e8c2441ee99680
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/durationpicker/DurationExtensionKtTest.kt
@@ -0,0 +1,57 @@
+package de.rki.coronawarnapp.contactdiary.ui.durationpicker
+
+import io.kotest.matchers.shouldBe
+import org.joda.time.Duration
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
+
+internal class DurationExtensionKtTest {
+
+    @ParameterizedTest
+    @MethodSource("provideArguments")
+    fun `toReadableDuration() should return correct String`(testItem: TestItem) {
+        with(testItem) {
+            duration.toReadableDuration(prefix, suffix) shouldBe expectedReadableDuration
+        }
+    }
+
+    companion object {
+
+        @Suppress("unused")
+        @JvmStatic
+        fun provideArguments() = listOf(
+            TestItem(
+                prefix = null,
+                suffix = null,
+                duration = Duration.standardMinutes(30),
+                expectedReadableDuration = "00:30"
+            ),
+            TestItem(
+                prefix = "Dauer",
+                suffix = null,
+                duration = Duration.standardMinutes(45),
+                expectedReadableDuration = "Dauer 00:45"
+            ),
+            TestItem(
+                prefix = null,
+                suffix = "Std.",
+                duration = Duration.standardMinutes(60),
+                expectedReadableDuration = "01:00 Std."
+            ),
+            TestItem(
+                prefix = "Dauer",
+                suffix = "h",
+                duration = Duration.standardMinutes(75),
+                expectedReadableDuration = "Dauer 01:15 h"
+            ),
+        ).map { Arguments.of(it) }
+    }
+
+    data class TestItem(
+        val prefix: String?,
+        val duration: Duration,
+        val suffix: String?,
+        val expectedReadableDuration: String
+    )
+}
\ No newline at end of file
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/exporter/ContactDiaryExporterTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/exporter/ContactDiaryExporterTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3e31ce1cb90e0653d861a483ea0ea3306db47a8c
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/exporter/ContactDiaryExporterTest.kt
@@ -0,0 +1,166 @@
+package de.rki.coronawarnapp.contactdiary.ui.exporter
+
+import android.content.Context
+import de.rki.coronawarnapp.contactdiary.model.ContactDiaryLocationVisit
+import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter
+import de.rki.coronawarnapp.contactdiary.util.ContactDiaryData
+import de.rki.coronawarnapp.contactdiary.util.mockStringsForContactDiaryExporterTests
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.matchers.shouldBe
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.junit5.MockKExtension
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.TestInstance
+import org.junit.jupiter.api.extension.ExtendWith
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.Arguments
+import org.junit.jupiter.params.provider.MethodSource
+import testhelpers.TestDispatcherProvider
+import testhelpers.extensions.CoroutinesTestExtension
+
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@ExtendWith(MockKExtension::class, CoroutinesTestExtension::class)
+internal class ContactDiaryExporterTest {
+
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var context: Context
+
+    private val numberOfLastDaysToExport = 15
+
+    @BeforeEach
+    fun setUp() {
+
+        // In these test, now = January, 15
+        every { timeStamper.nowUTC } returns Instant.parse("2021-01-15T00:00:00.000Z")
+
+        mockStringsForContactDiaryExporterTests(context)
+    }
+
+    @AfterEach
+    fun tearDown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance() = ContactDiaryExporter(
+        context,
+        timeStamper,
+        TestDispatcherProvider()
+    )
+
+    @ParameterizedTest
+    @MethodSource("provideArguments")
+    fun `createExport() should produce correct export`(
+        testItem: ExporterTestItem
+    ) = runBlockingTest {
+
+        createInstance().createExport(
+            testItem.personEncounters,
+            testItem.locationVisits,
+            numberOfLastDaysToExport
+        ) shouldBe testItem.expectedExport
+    }
+
+    companion object {
+
+        @Suppress("unused")
+        @JvmStatic
+        fun provideArguments() = listOf(
+            ExporterTestItem(
+                personEncounters = emptyList(),
+                locationVisits = emptyList(),
+                expectedExport =
+                    """
+                    Kontakte der letzten 15 Tage (01.01.2021 - 15.01.2021)
+                    Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG.
+                    
+                    """.trimIndent()
+            ),
+            ExporterTestItem(
+                personEncounters = ContactDiaryData.TWO_PERSONS_NO_ADDITIONAL_DATA,
+                locationVisits = ContactDiaryData.TWO_LOCATIONS_NO_ADDITIONAL_DATA,
+                expectedExport =
+                    """
+                    Kontakte der letzten 15 Tage (01.01.2021 - 15.01.2021)
+                    Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG.
+
+                    02.01.2021 Constantin Frenzel
+                    02.01.2021 Barber
+                    01.01.2021 Andrea Steinhauer
+                    01.01.2021 Bakery
+                
+                    """.trimIndent()
+            ),
+            ExporterTestItem(
+                personEncounters = ContactDiaryData.TWO_PERSONS_WITH_PHONE_NUMBERS,
+                locationVisits = ContactDiaryData.TWO_LOCATIONS_WITH_EMAIL,
+                expectedExport =
+                    """
+                    Kontakte der letzten 15 Tage (01.01.2021 - 15.01.2021)
+                    Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG.
+
+                    02.01.2021 Constantin Frenzel; Tel. +49 987 654321
+                    02.01.2021 Barber; eMail barber@icutyourhair.com
+                    01.01.2021 Andrea Steinhauer; Tel. +49 123 456789
+                    01.01.2021 Bakery; eMail baker@ibakeyourbread.com
+                
+                    """.trimIndent()
+            ),
+            ExporterTestItem(
+                personEncounters = ContactDiaryData.TWO_PERSONS_WITH_PHONE_NUMBERS_AND_EMAIL,
+                locationVisits = ContactDiaryData.TWO_LOCATIONS_WITH_PHONE_NUMBERS_AND_EMAIL,
+                expectedExport =
+                    """
+                    Kontakte der letzten 15 Tage (01.01.2021 - 15.01.2021)
+                    Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG.
+
+                    02.01.2021 Constantin Frenzel; Tel. +49 987 654321; eMail constantin.frenzel@example.com
+                    02.01.2021 Barber; Tel. +99 888 777777; eMail barber@icutyourhair.com
+                    01.01.2021 Andrea Steinhauer; Tel. +49 123 456789; eMail andrea.steinhauer@example.com
+                    01.01.2021 Bakery; Tel. +11 222 333333; eMail baker@ibakeyourbread.com
+                
+                    """.trimIndent()
+            ),
+            ExporterTestItem(
+                personEncounters = ContactDiaryData.TWO_PERSONS_WITH_ATTRIBUTES,
+                locationVisits = ContactDiaryData.TWO_LOCATIONS_WITH_DURATION,
+                expectedExport =
+                    """
+                    Kontakte der letzten 15 Tage (01.01.2021 - 15.01.2021)
+                    Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG.
+
+                    02.01.2021 Constantin Frenzel; Kontaktdauer > 15 Minuten; ohne Maske; im Gebäude
+                    02.01.2021 Barber; Dauer 01:45 h
+                    01.01.2021 Andrea Steinhauer; Kontaktdauer < 15 Minuten; mit Maske; im Freien
+                    01.01.2021 Bakery; Dauer 00:15 h
+                
+                    """.trimIndent()
+            ),
+            ExporterTestItem(
+                personEncounters = ContactDiaryData.TWO_PERSONS_WITH_CIRCUMSTANCES,
+                locationVisits = ContactDiaryData.TWO_LOCATIONS_WITH_CIRCUMSTANCES,
+                expectedExport =
+                    """    
+                    Kontakte der letzten 15 Tage (01.01.2021 - 15.01.2021)
+                    Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG.
+
+                    02.01.2021 Constantin Frenzel; saßen nah beieinander
+                    02.01.2021 Barber; Nobody was wearing a mask, but needed a haircut real bad
+                    01.01.2021 Andrea Steinhauer; Sicherheitsmaßnahmen eingehalten
+                    01.01.2021 Bakery; Very crowdy, but delicious bread
+                
+                    """.trimIndent()
+            )
+        ).map { testItem -> Arguments.of(testItem) }
+    }
+
+    data class ExporterTestItem(
+        val personEncounters: List<ContactDiaryPersonEncounter>,
+        val locationVisits: List<ContactDiaryLocationVisit>,
+        val expectedExport: String
+    )
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewViewModelTest.kt
index 9de044a53bccdd9bd70e39e2dd9d60c66b6f2c62..a751d47446c7bd0fc5101c48c542e06ec0a65e6f 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/ui/overview/ContactDiaryOverviewViewModelTest.kt
@@ -1,16 +1,21 @@
 package de.rki.coronawarnapp.contactdiary.ui.overview
 
+import android.content.Context
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryLocation
 import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryLocationVisit
 import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryPerson
 import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryPersonEncounter
 import de.rki.coronawarnapp.contactdiary.storage.repo.ContactDiaryRepository
+import de.rki.coronawarnapp.contactdiary.ui.exporter.ContactDiaryExporter
 import de.rki.coronawarnapp.contactdiary.ui.overview.adapter.ListItem
+import de.rki.coronawarnapp.contactdiary.util.ContactDiaryData
+import de.rki.coronawarnapp.contactdiary.util.mockStringsForContactDiaryExporterTests
 import de.rki.coronawarnapp.risk.result.AggregatedRiskPerDateResult
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass
 import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.util.TimeStamper
 import io.kotest.matchers.should
 import io.kotest.matchers.shouldBe
 import io.kotest.matchers.types.beInstanceOf
@@ -22,6 +27,7 @@ import io.mockk.runs
 import io.mockk.verify
 import kotlinx.coroutines.flow.flowOf
 import org.joda.time.DateTimeZone
+import org.joda.time.Instant
 import org.joda.time.LocalDate
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
@@ -29,6 +35,7 @@ import org.junit.jupiter.api.extension.ExtendWith
 import testhelpers.TestDispatcherProvider
 import testhelpers.extensions.InstantExecutorExtension
 import testhelpers.extensions.getOrAwaitValue
+import testhelpers.extensions.observeForTesting
 
 @ExtendWith(InstantExecutorExtension::class)
 open class ContactDiaryOverviewViewModelTest {
@@ -36,6 +43,9 @@ open class ContactDiaryOverviewViewModelTest {
     @MockK lateinit var taskController: TaskController
     @MockK lateinit var contactDiaryRepository: ContactDiaryRepository
     @MockK lateinit var riskLevelStorage: RiskLevelStorage
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var context: Context
+
     private val testDispatcherProvider = TestDispatcherProvider()
     private val date = LocalDate.now()
     private val dateMillis = date.toDateTimeAtStartOfDay(DateTimeZone.UTC).millis
@@ -48,6 +58,9 @@ open class ContactDiaryOverviewViewModelTest {
         every { contactDiaryRepository.locationVisits } returns flowOf(emptyList())
         every { contactDiaryRepository.personEncounters } returns flowOf(emptyList())
         every { riskLevelStorage.aggregatedRiskPerDateResults } returns flowOf(emptyList())
+
+        mockStringsForContactDiaryExporterTests(context)
+        every { timeStamper.nowUTC } returns Instant.now()
     }
 
     private val person = DefaultContactDiaryPerson(123, "Romeo")
@@ -80,7 +93,13 @@ open class ContactDiaryOverviewViewModelTest {
         taskController = taskController,
         dispatcherProvider = testDispatcherProvider,
         contactDiaryRepository = contactDiaryRepository,
-        riskLevelStorage = riskLevelStorage
+        riskLevelStorage = riskLevelStorage,
+        timeStamper,
+        ContactDiaryExporter(
+            context,
+            timeStamper,
+            testDispatcherProvider
+        )
     )
 
     @Test
@@ -253,6 +272,32 @@ open class ContactDiaryOverviewViewModelTest {
         }
     }
 
+    @Test
+    fun `onExportPress() should post export`() {
+        // In this test, now = January, 15
+        every { timeStamper.nowUTC } returns Instant.parse("2021-01-15T00:00:00.000Z")
+
+        every { contactDiaryRepository.personEncounters } returns flowOf(ContactDiaryData.TWO_PERSONS_WITH_PHONE_NUMBERS_AND_EMAIL)
+        every { contactDiaryRepository.locationVisits } answers { flowOf(ContactDiaryData.TWO_LOCATIONS_WITH_DURATION) }
+
+        val vm = createInstance()
+
+        vm.onExportPress()
+
+        vm.exportLocationsAndPersons.observeForTesting {
+            vm.exportLocationsAndPersons.value shouldBe """
+                Kontakte der letzten 15 Tage (01.01.2021 - 15.01.2021)
+                Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG.
+
+                02.01.2021 Constantin Frenzel; Tel. +49 987 654321; eMail constantin.frenzel@example.com
+                02.01.2021 Barber; Dauer 01:45 h
+                01.01.2021 Andrea Steinhauer; Tel. +49 123 456789; eMail andrea.steinhauer@example.com
+                01.01.2021 Bakery; Dauer 00:15 h
+                
+            """.trimIndent()
+        }
+    }
+
     private fun List<ListItem.Data>.validate(hasPerson: Boolean, hasLocation: Boolean) {
         var count = 0
         if (hasPerson) count++
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryData.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryData.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5ab315b67e50065414550294d66dcb3ade206a4a
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryData.kt
@@ -0,0 +1,188 @@
+package de.rki.coronawarnapp.contactdiary.util
+
+import de.rki.coronawarnapp.contactdiary.model.ContactDiaryPersonEncounter
+import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryLocation
+import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryLocationVisit
+import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryPerson
+import de.rki.coronawarnapp.contactdiary.model.DefaultContactDiaryPersonEncounter
+import org.joda.time.Duration
+import org.joda.time.LocalDate
+
+object ContactDiaryData {
+
+    val TWO_PERSONS_NO_ADDITIONAL_DATA = listOf(
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Andrea Steinhauer"
+            )
+        ),
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Constantin Frenzel"
+            )
+        )
+    )
+
+    val TWO_PERSONS_WITH_PHONE_NUMBERS = listOf(
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Andrea Steinhauer",
+                phoneNumber = "+49 123 456789"
+            )
+        ),
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Constantin Frenzel",
+                phoneNumber = "+49 987 654321"
+            )
+        )
+    )
+
+    val TWO_PERSONS_WITH_PHONE_NUMBERS_AND_EMAIL = listOf(
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Andrea Steinhauer",
+                phoneNumber = "+49 123 456789",
+                emailAddress = "andrea.steinhauer@example.com"
+            )
+        ),
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Constantin Frenzel",
+                phoneNumber = "+49 987 654321",
+                emailAddress = "constantin.frenzel@example.com"
+            )
+        )
+    )
+
+    val TWO_PERSONS_WITH_ATTRIBUTES = listOf(
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Andrea Steinhauer"
+            ),
+            withMask = true,
+            wasOutside = true,
+            durationClassification = ContactDiaryPersonEncounter.DurationClassification.LESS_THAN_15_MINUTES
+
+        ),
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Constantin Frenzel"
+            ),
+            withMask = false,
+            wasOutside = false,
+            durationClassification = ContactDiaryPersonEncounter.DurationClassification.MORE_THAN_15_MINUTES
+        )
+    )
+
+    val TWO_PERSONS_WITH_CIRCUMSTANCES = listOf(
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Andrea Steinhauer"
+            ),
+            circumstances = "Sicherheitsmaßnahmen eingehalten"
+        ),
+        DefaultContactDiaryPersonEncounter(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryPerson = DefaultContactDiaryPerson(
+                fullName = "Constantin Frenzel"
+            ),
+            circumstances = "saßen nah beieinander"
+        )
+    )
+
+    val TWO_LOCATIONS_NO_ADDITIONAL_DATA = listOf(
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Bakery"
+            )
+        ),
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Barber"
+            )
+        )
+    )
+
+    val TWO_LOCATIONS_WITH_EMAIL = listOf(
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Bakery",
+                emailAddress = "baker@ibakeyourbread.com"
+            )
+        ),
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Barber",
+                emailAddress = "barber@icutyourhair.com"
+            )
+        )
+    )
+
+    val TWO_LOCATIONS_WITH_PHONE_NUMBERS_AND_EMAIL = listOf(
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Bakery",
+                phoneNumber = "+11 222 333333",
+                emailAddress = "baker@ibakeyourbread.com"
+            )
+        ),
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Barber",
+                phoneNumber = "+99 888 777777",
+                emailAddress = "barber@icutyourhair.com"
+            )
+        )
+    )
+
+    val TWO_LOCATIONS_WITH_DURATION = listOf(
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Bakery"
+            ),
+            duration = Duration.standardMinutes(15)
+        ),
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Barber"
+            ),
+            // 105 minutes = 1h45min
+            duration = Duration.standardMinutes(105)
+        )
+    )
+
+    val TWO_LOCATIONS_WITH_CIRCUMSTANCES = listOf(
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-01"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Bakery"
+            ),
+            circumstances = "Very crowdy, but delicious bread"
+        ),
+        DefaultContactDiaryLocationVisit(
+            date = LocalDate.parse("2021-01-02"),
+            contactDiaryLocation = DefaultContactDiaryLocation(
+                locationName = "Barber"
+            ),
+            circumstances = "Nobody was wearing a mask, but needed a haircut real bad"
+        )
+    )
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryExportStringsMock.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryExportStringsMock.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2690e74e2879e2b15c06a6f994f56894504406e4
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/contactdiary/util/ContactDiaryExportStringsMock.kt
@@ -0,0 +1,39 @@
+package de.rki.coronawarnapp.contactdiary.util
+
+import android.content.Context
+import de.rki.coronawarnapp.R
+import io.mockk.every
+import io.mockk.slot
+
+fun mockStringsForContactDiaryExporterTests(context: Context) {
+
+    val fromSlot = slot<String>()
+    val toSlot = slot<String>()
+
+    every {
+        context.getString(
+            R.string.contact_diary_export_intro_one,
+            capture(fromSlot),
+            capture(toSlot)
+        )
+    } answers { "Kontakte der letzten 15 Tage (${fromSlot.captured} - ${toSlot.captured})" }
+
+    every {
+        context.getString(R.string.contact_diary_export_intro_two)
+    } answers { "Die nachfolgende Liste dient dem zuständigen Gesundheitsamt zur Kontaktnachverfolgung gem. § 25 IfSG." }
+
+    every { context.getString(R.string.contact_diary_export_prefix_phone) } returns "Tel."
+    every { context.getString(R.string.contact_diary_export_prefix_email) } returns "eMail"
+
+    every { context.getString(R.string.contact_diary_export_durations_less_than_15min) } returns "Kontaktdauer < 15 Minuten"
+    every { context.getString(R.string.contact_diary_export_durations_longer_than_15min) } returns "Kontaktdauer > 15 Minuten"
+
+    every { context.getString(R.string.contact_diary_export_wearing_mask) } returns "mit Maske"
+    every { context.getString(R.string.contact_diary_export_wearing_no_mask) } returns "ohne Maske"
+
+    every { context.getString(R.string.contact_diary_export_outdoor) } returns "im Freien"
+    every { context.getString(R.string.contact_diary_export_indoor) } returns "im Gebäude"
+
+    every { context.getString(R.string.contact_diary_export_location_duration_prefix) } returns "Dauer"
+    every { context.getString(R.string.contact_diary_export_location_duration_suffix) } returns "h"
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollectorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollectorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..6c4eb073ffcc052181647254cbbd7f5d6c260c34
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowCollectorTest.kt
@@ -0,0 +1,70 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import com.google.android.gms.nearby.exposurenotification.ScanInstance
+import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings
+import de.rki.coronawarnapp.risk.result.RiskResult
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class AnalyticsExposureWindowCollectorTest : BaseTest() {
+
+    @MockK lateinit var analyticsExposureWindowRepository: AnalyticsExposureWindowRepository
+    @MockK lateinit var analyticsSettings: AnalyticsSettings
+    @MockK lateinit var exposureWindow: ExposureWindow
+    @MockK lateinit var riskResult: RiskResult
+    @MockK lateinit var scanInstance: ScanInstance
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { scanInstance.minAttenuationDb } returns 1
+        every { scanInstance.secondsSinceLastScan } returns 1
+        every { scanInstance.typicalAttenuationDb } returns 1
+        every { exposureWindow.calibrationConfidence } returns 1
+        every { exposureWindow.dateMillisSinceEpoch } returns 1
+        every { exposureWindow.infectiousness } returns 1
+        every { exposureWindow.reportType } returns 1
+        every { exposureWindow.scanInstances } returns listOf(scanInstance)
+        every { riskResult.normalizedTime } returns 1.0
+        every { riskResult.transmissionRiskLevel } returns 1
+        coEvery { analyticsExposureWindowRepository.addNew(any()) } just Runs
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    @Test
+    fun `data is stored when analytics enabled`() {
+        every { analyticsSettings.analyticsEnabled.value } returns true
+        runBlockingTest {
+            newInstance().reportRiskResultsPerWindow(mapOf(exposureWindow to riskResult))
+            coVerify(exactly = 1) { analyticsExposureWindowRepository.addNew(any()) }
+        }
+    }
+
+    @Test
+    fun `data is not stored when analytics disabled`() {
+        every { analyticsSettings.analyticsEnabled.value } returns false
+        runBlockingTest {
+            newInstance().reportRiskResultsPerWindow(mapOf(exposureWindow to riskResult))
+            coVerify(exactly = 0) { analyticsExposureWindowRepository.addNew(any()) }
+        }
+    }
+
+    private fun newInstance() =
+        AnalyticsExposureWindowCollector(analyticsExposureWindowRepository, analyticsSettings)
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..307fade38797106d34a72ae99ce097727abd1b60
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowDonorTest.kt
@@ -0,0 +1,129 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+import de.rki.coronawarnapp.appconfig.ConfigData
+import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockkObject
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import kotlin.random.Random
+
+class AnalyticsExposureWindowDonorTest : BaseTest() {
+
+    @MockK lateinit var analyticsExposureWindowRepository: AnalyticsExposureWindowRepository
+    @MockK lateinit var configData: ConfigData
+    private val request = object : DonorModule.Request {
+        override val currentConfig: ConfigData
+            get() = configData
+    }
+    private val window = AnalyticsExposureWindowEntity(
+        "hash",
+        1,
+        1L,
+        1,
+        1,
+        1.0,
+        1
+    )
+    private val scanInstance = AnalyticsScanInstanceEntity(1, "hash", 1, 1, 1)
+    private val wrapper = AnalyticsExposureWindowEntityWrapper(
+        window,
+        listOf(scanInstance)
+    )
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        mockkObject(Random)
+        coEvery { analyticsExposureWindowRepository.deleteStaleData() } just Runs
+        coEvery { analyticsExposureWindowRepository.rollback(any(), any()) } just Runs
+        every { Random.nextDouble() } returns .5
+    }
+
+    @Test
+    fun `skip submission when random number greater than probability`() {
+        val donor = newInstance()
+        runBlockingTest {
+            donor.skipSubmission(.3) shouldBe true
+        }
+    }
+
+    @Test
+    fun `execute submission when random number less or equal than probability`() {
+        val donor = newInstance()
+        runBlockingTest {
+            donor.skipSubmission(.5) shouldBe false
+        }
+    }
+
+    @Test
+    fun `skipped submission returns empty contribution`() {
+        val donor = newInstance()
+        coEvery { configData.analytics.probabilityToSubmitNewExposureWindows } returns .4
+        runBlockingTest {
+            donor.beginDonation(request) shouldBe AnalyticsExposureWindowNoContribution
+        }
+    }
+
+    @Test
+    fun `regular submission returns stored data`() {
+        val donor = newInstance()
+        val wrappers = listOf(wrapper)
+        val reported = listOf(
+            AnalyticsReportedExposureWindowEntity(
+                "hash",
+                1L
+            )
+        )
+        coEvery { configData.analytics.probabilityToSubmitNewExposureWindows } returns .8
+        coEvery { analyticsExposureWindowRepository.getAllNew() } returns wrappers
+        coEvery { analyticsExposureWindowRepository.moveToReported(wrappers) } returns reported
+        runBlockingTest {
+            (donor.beginDonation(request) as AnalyticsExposureWindowDonor.Contribution).data shouldBe wrappers.asPpaData()
+        }
+    }
+
+    @Test
+    fun `failure triggers rollback`() {
+        val donor = newInstance()
+        val wrappers = listOf(wrapper)
+        val reported = listOf(
+            AnalyticsReportedExposureWindowEntity(
+                "hash",
+                1L
+            )
+        )
+        coEvery { configData.analytics.probabilityToSubmitNewExposureWindows } returns .8
+        coEvery { analyticsExposureWindowRepository.getAllNew() } returns wrappers
+        coEvery { analyticsExposureWindowRepository.moveToReported(wrappers) } returns reported
+        runBlockingTest {
+            val contribution = donor.beginDonation(request)
+            contribution.finishDonation(false)
+            coVerify { analyticsExposureWindowRepository.rollback(wrappers, reported) }
+        }
+    }
+
+    @Test
+    fun `stale data clean up`() {
+        val donor = newInstance()
+        coEvery { configData.analytics.probabilityToSubmitNewExposureWindows } returns .4
+        runBlockingTest {
+            donor.beginDonation(request)
+            coVerify { analyticsExposureWindowRepository.deleteStaleData() }
+        }
+    }
+
+    private fun newInstance() =
+        AnalyticsExposureWindowDonor(
+            analyticsExposureWindowRepository
+        )
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsRepositoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsRepositoryTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..9c06099cdc2417ae478938d260a9e4d1398505d5
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/exposurewindows/AnalyticsExposureWindowsRepositoryTest.kt
@@ -0,0 +1,121 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows
+
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.shouldNotBe
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Days
+import org.joda.time.Instant
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class AnalyticsExposureWindowsRepositoryTest : BaseTest() {
+
+    @MockK lateinit var databaseFactory: AnalyticsExposureWindowDatabase.Factory
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var analyticsExposureWindowDao: AnalyticsExposureWindowDao
+    @MockK lateinit var analyticsExposureWindowDatabase: AnalyticsExposureWindowDatabase
+    @MockK lateinit var analyticsReportedExposureWindowEntity: AnalyticsReportedExposureWindowEntity
+    @MockK lateinit var analyticsExposureWindowEntity: AnalyticsExposureWindowEntity
+
+    private val analyticsScanInstance = AnalyticsScanInstance(
+        1,
+        1,
+        1,
+    )
+    private val analyticsExposureWindow = AnalyticsExposureWindow(
+        1,
+        1L,
+        1,
+        1,
+        listOf(analyticsScanInstance),
+        1.0,
+        1
+    )
+    private val now = Instant.now()
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { timeStamper.nowUTC } returns now
+        coEvery { analyticsExposureWindowDao.deleteReportedOlderThan(any()) } just Runs
+    }
+
+    @Test
+    fun `stale data clean up`() {
+        addDatabase()
+        runBlockingTest {
+            newInstance().deleteStaleData()
+            coVerify {
+                analyticsExposureWindowDao.deleteReportedOlderThan(
+                    now.minus(Days.days(15).toStandardDuration()).millis
+                )
+            }
+        }
+    }
+
+    @Test
+    fun `insert if hash not reported or new`() {
+        coEvery { analyticsExposureWindowDao.getReported(any()) } returns null
+        coEvery { analyticsExposureWindowDao.getNew(any()) } returns null
+        coEvery { analyticsExposureWindowDao.insert(any()) } just Runs
+        addDatabase()
+        runBlockingTest {
+            newInstance().addNew(analyticsExposureWindow)
+            coVerify { analyticsExposureWindowDao.insert(any()) }
+        }
+    }
+
+    @Test
+    fun `no insert if hash reported`() {
+        coEvery { analyticsExposureWindowDao.getReported(any()) } returns analyticsReportedExposureWindowEntity
+        coEvery { analyticsExposureWindowDao.getNew(any()) } returns analyticsExposureWindowEntity
+        addDatabase()
+        runBlockingTest {
+            newInstance().addNew(analyticsExposureWindow)
+            coVerify(exactly = 0) { analyticsExposureWindowDao.insert(any()) }
+        }
+    }
+
+    @Test
+    fun `no insert if hash in new`() {
+        coEvery { analyticsExposureWindowDao.getReported(any()) } returns analyticsReportedExposureWindowEntity
+        coEvery { analyticsExposureWindowDao.getNew(any()) } returns analyticsExposureWindowEntity
+        addDatabase()
+        runBlockingTest {
+            newInstance().addNew(analyticsExposureWindow)
+            coVerify(exactly = 0) { analyticsExposureWindowDao.insert(any()) }
+        }
+    }
+
+    @Test
+    fun `hash value equal for two instances with same data`() {
+        val copy = analyticsExposureWindow.copy()
+        copy.sha256Hash() shouldBe analyticsExposureWindow.sha256Hash()
+    }
+
+    @Test
+    fun `hash value not equal for two instances with different data`() {
+        val copy = analyticsExposureWindow.copy(dateMillis = 9999)
+        copy.sha256Hash() shouldNotBe analyticsExposureWindow.sha256Hash()
+    }
+
+    private fun addDatabase() {
+        every { analyticsExposureWindowDatabase.analyticsExposureWindowDao() } returns analyticsExposureWindowDao
+        every { databaseFactory.create() } returns analyticsExposureWindowDatabase
+    }
+
+    private fun newInstance() =
+        AnalyticsExposureWindowRepository(
+            databaseFactory,
+            timeStamper
+        )
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/survey/SurveysTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/survey/SurveysTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..5b6ea68dab4c85b702949a8b27f0e58cbe821af4
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/survey/SurveysTest.kt
@@ -0,0 +1,93 @@
+package de.rki.coronawarnapp.datadonation.survey
+
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.datadonation.OTPAuthorizationResult
+import de.rki.coronawarnapp.datadonation.safetynet.DeviceAttestation
+import de.rki.coronawarnapp.datadonation.storage.OTPRepository
+import de.rki.coronawarnapp.datadonation.survey.Surveys.ConsentResult.AlreadyGiven
+import de.rki.coronawarnapp.datadonation.survey.Surveys.ConsentResult.Needed
+import de.rki.coronawarnapp.datadonation.survey.Surveys.Type.HIGH_RISK_ENCOUNTER
+import de.rki.coronawarnapp.datadonation.survey.server.SurveyServer
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.matchers.should
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.types.beInstanceOf
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.TestDispatcherProvider
+import java.util.UUID
+
+internal class SurveysTest : BaseTest() {
+
+    @MockK lateinit var deviceAttestation: DeviceAttestation
+    @MockK lateinit var appConfigProvider: AppConfigProvider
+    @MockK lateinit var surveyServer: SurveyServer
+    @MockK lateinit var oneTimePasswordRepo: OTPRepository
+    @MockK lateinit var urlProvider: SurveyUrlProvider
+    @MockK lateinit var timeStamper: TimeStamper
+
+    @BeforeEach
+    fun setUp() {
+        MockKAnnotations.init(this)
+        every { timeStamper.nowUTC } returns Instant.parse("2020-01-01T00:00:00.000Z")
+    }
+
+    private fun createInstance() = Surveys(
+        deviceAttestation,
+        appConfigProvider,
+        surveyServer,
+        oneTimePasswordRepo,
+        TestDispatcherProvider(),
+        urlProvider,
+        timeStamper
+    )
+
+    @Test
+    fun `isConsentNeeded() should return Needed when no otp was yet authorized`() = runBlockingTest {
+        every { oneTimePasswordRepo.otpAuthorizationResult } returns null
+        createInstance().isConsentNeeded(HIGH_RISK_ENCOUNTER) shouldBe Needed
+    }
+
+    @Test
+    fun `isConsentNeeded() should return Needed when authentication of stored otp failed `() = runBlockingTest {
+        every { oneTimePasswordRepo.otpAuthorizationResult } returns OTPAuthorizationResult(
+            UUID.randomUUID(),
+            authorized = false,
+            redeemedAt = timeStamper.nowUTC,
+            invalidated = false
+        )
+        createInstance().isConsentNeeded(HIGH_RISK_ENCOUNTER) shouldBe Needed
+    }
+
+    @Test
+    fun `isConsentNeeded() should return Needed when an authorized otp was invalidated due to a risk change from high to low risk`() =
+        runBlockingTest {
+            every { oneTimePasswordRepo.otpAuthorizationResult } returns OTPAuthorizationResult(
+                UUID.randomUUID(),
+                authorized = true,
+                redeemedAt = timeStamper.nowUTC,
+                invalidated = true
+            )
+            createInstance().isConsentNeeded(HIGH_RISK_ENCOUNTER) shouldBe Needed
+        }
+
+    @Test
+    fun `isConsentNeeded() should return AlreadyGiven when an authorized otp is stored and not invalidated`() =
+        runBlockingTest {
+            every { oneTimePasswordRepo.otpAuthorizationResult } returns OTPAuthorizationResult(
+                UUID.randomUUID(),
+                authorized = true,
+                redeemedAt = timeStamper.nowUTC,
+                invalidated = false
+            )
+            coEvery { urlProvider.provideUrl(any(), any()) } returns ""
+            createInstance().isConsentNeeded(HIGH_RISK_ENCOUNTER) should beInstanceOf<AlreadyGiven>()
+        }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTaskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTaskTest.kt
index 90681c6f37c0ab123d868c01a49116bfbc1890a1..62a8305271e50cc3dd45c4c8014fd28180ec137f 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTaskTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelTaskTest.kt
@@ -6,6 +6,7 @@ import android.net.Network
 import android.net.NetworkCapabilities
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.appconfig.ConfigData
+import de.rki.coronawarnapp.datadonation.analytics.modules.exposurewindows.AnalyticsExposureWindowCollector
 import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey
 import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
@@ -49,6 +50,7 @@ class RiskLevelTaskTest : BaseTest() {
     @MockK lateinit var appConfigProvider: AppConfigProvider
     @MockK lateinit var riskLevelStorage: RiskLevelStorage
     @MockK lateinit var keyCacheRepository: KeyCacheRepository
+    @MockK lateinit var analyticsExposureWindowCollector: AnalyticsExposureWindowCollector
 
     private val arguments: Task.Arguments = object : Task.Arguments {}
 
@@ -92,7 +94,8 @@ class RiskLevelTaskTest : BaseTest() {
         riskLevelSettings = riskLevelSettings,
         appConfigProvider = appConfigProvider,
         riskLevelStorage = riskLevelStorage,
-        keyCacheRepository = keyCacheRepository
+        keyCacheRepository = keyCacheRepository,
+        analyticsExposureWindowCollector = analyticsExposureWindowCollector
     )
 
     @Test
@@ -234,8 +237,10 @@ class RiskLevelTaskTest : BaseTest() {
 
         coEvery { keyCacheRepository.getAllCachedKeys() } returns listOf(cachedKey)
         coEvery { enfClient.exposureWindows() } returns listOf()
-        every { riskLevels.determineRisk(any(), listOf()) } returns aggregatedRiskResult
+        every { riskLevels.calculateRisk(any(), any()) } returns null
+        every { riskLevels.aggregateResults(any(), any()) } returns aggregatedRiskResult
         every { timeStamper.nowUTC } returns now
+        coEvery { analyticsExposureWindowCollector.reportRiskResultsPerWindow(any()) } just Runs
 
         createTask().run(arguments) shouldBe RiskLevelTaskResult(
             calculatedAt = now,
diff --git a/gradle.properties b/gradle.properties
index d23c1203a167d27ffe28055b15a2373e01567166..9b0279b18b450e889f22c4827e04be18cfda289b 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -20,4 +20,4 @@ org.gradle.dependency.verification.console=verbose
 VERSION_MAJOR=1
 VERSION_MINOR=15
 VERSION_PATCH=0
-VERSION_BUILD=1
+VERSION_BUILD=2