From 63050acabc0e7c1d6a157e4c04de1094bf2a3c6f Mon Sep 17 00:00:00 2001
From: Chilja Gossow <49635654+chiljamgossow@users.noreply.github.com>
Date: Wed, 7 Apr 2021 15:34:27 +0200
Subject: [PATCH] Warning package download & scheduling
 (EXPOSUREAPP-5695,EXPOSUREAPP-5696) (#2707)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* download and repo

* download warning packages

* download warning packages

* TraceTimeWarning Download, Draft2

* TraceTimeWarning Download, Draft3

* Offer only new warning packages via API.

* Remove duplicate test menu entry.

* TraceTimeWarning Download, Draft5

* Adapt CheckInWarningMatcher to mark processed packages.

* Fix failing unit tests.

* Unit Test Skeletons

* Back BackgroundScheduler non-static and injected.

* Refactored and combined diagnosis and tracing periodic workers.

* Fix unit test.

* LINTs

* Adapt marking packages as processed to upstream CheckInWarningMatcher changes.

* Copy .await() from worker library to project due to being scope restricted.

* unit tests

* klint

* klint

* add TODO for matching comparison

* refactor combination of results

* rename

* add test for combine

* revert unnecessary changes

* change initial result to failed

* using low risk as default for lastCalculated to maintain the old behavior when no results are available

* klint

* Resolve merge regressions.

* Fix fake check-in generation.

* Handle worker refactoring gracefully, catch ClassNotFoundException.

* Refactoring packages.

* Refactor CheckInWarningMatcher.kt and PresenceTracingWarningTask.kt
Move repository/database update calls to task, let the matcher only be responsible for matching.

* Adjust test fragment to allow retriggering the download + matching task.

* Fix refactoring regression.

* Fix flaky time label test.

* Fix package import.

* Fix another timezone based flaky test.

* Reduce nesting level to make the linter happy.

* Update ROOM schema files.

* Update ROOM schema files.

* Refactoring.

* Small naming fixes

* Shorten worker IDs.

* Collect BerndStylePointsâ„¢

* Set tableName explicitly.

* * Adjust id comparison within CheckInWarningMatcher.kt
* Add tests for traceLocationIdHash calculation.
* Calculate traceLocationIdHash on-demand, don't store it.

* Improve trace warning task test menu output.

* Remove spammy log output.

* Remove unused function

* Add additional TraceLocation test cases that match mock server.

* Improve check-in matching log messages.

* Fix duplicate overlaps and check for overlap distinctness.

* Add test for config timeout value.

* Remove unused test.

* Use time measuring function.

* Simplify error case handling for revoked metadata packages.

* Turn createMatchingLaunchers into runMatchingLaunchers

* Use TimeUnit.MINUTES

* Remove extra deletion call.
If there are no CheckIns, the SyncTool will have deleted all matches already.

* Use flatMap instead of flatten.

* Use more specific names (toCheckInWarningOverlap/toTraceTimeIntervalMatchEntity)

* Revert "Remove extra deletion call. If there are no CheckIns, the SyncTool will have deleted all matches already."

This reverts commit 3b0acf3d

* Fix merge conflict regressions.

Co-authored-by: Matthias Urhahn <matthias.urhahn@sap.com>
Co-authored-by: harambasicluka <64483219+harambasicluka@users.noreply.github.com>
Co-authored-by: Alexander Alferov <a.alferov@sap.com>
---
 .reuse/dep5                                   |   4 +
 .../1.json                                    |  64 ---
 .../1.json                                    |  12 +-
 .../1.json                                    |   0
 .../1.json                                    |  76 ++++
 .../storage/CheckInDatabaseData.kt            |   2 -
 ...DiagnosisKeyRetrievalPeriodicWorkerTest.kt | 150 -------
 .../risk/storage/DefaultRiskLevelStorage.kt   |   2 +-
 .../risk/storage/DefaultRiskLevelStorage.kt   |   2 +-
 .../ui/EventRegistrationTestFragment.kt       |  21 +-
 .../EventRegistrationTestFragmentViewModel.kt |  84 ++--
 .../test/menu/ui/TestMenuFragmentViewModel.kt |   1 -
 .../fragment_test_eventregistration.xml       |  14 +-
 .../coronawarnapp/CoronaWarnApplication.kt    |   3 +-
 .../appconfig/KeyDownloadConfig.kt            |   4 +
 .../mapping/KeyDownloadParametersMapper.kt    |  26 +-
 .../overview/ContactDiaryOverviewViewModel.kt |   2 +-
 .../DiagnosisKeyRetrievalWorkBuilder.kt       |  34 ++
 .../execution/DiagnosisKeyRetrievalWorker.kt} |  48 +--
 .../EventRegistrationModule.kt                |  15 +-
 .../eventregistration/checkins/CheckIn.kt     |  15 +-
 .../TraceTimeIntervalWarningPackage.kt        |  16 -
 .../TraceTimeIntervalWarningRepository.kt     |  59 ---
 .../checkins/qrcode/TraceLocation.kt          |  15 +-
 .../entity/TraceLocationCheckInEntity.kt      |   2 -
 .../coronawarnapp/playbook/BackgroundNoise.kt |   5 +-
 .../checkins/checkout/CheckOutNotification.kt |   4 +-
 ...ons.kt => PresenceTracingNotifications.kt} |   6 +-
 .../risk/CheckInWarningMatcher.kt             | 156 -------
 .../risk/PresenceTracingRiskRepository.kt     | 224 ----------
 .../presencetracing/risk/PtRiskLevelResult.kt |   2 +
 .../risk/TraceLocationCheckInRisk.kt          |   3 +-
 .../risk/calculation/CheckInWarningMatcher.kt | 143 +++++++
 .../PresenceTracingConversions.kt             |   7 +-
 .../PresenceTracingRiskCalculator.kt          |   2 +-
 .../PresenceTracingRiskMapper.kt              |   2 +-
 .../PresenceTracingRiskModel.kt               |   4 +-
 .../execution/PresenceTracingWarningTask.kt   | 158 +++++++
 .../PresenceTracingWarningWorkBuilder.kt      |  37 ++
 .../execution/PresenceTracingWarningWorker.kt |  52 +++
 .../PresenceTracingRiskDatabase.kt            |   8 +-
 .../storage/PresenceTracingRiskRepository.kt  | 283 +++++++++++++
 .../warning/PresenceTracingWarningModule.kt   |  43 ++
 .../download/TraceWarningPackageDownloader.kt | 164 ++++++++
 .../download/TraceWarningPackageSyncTool.kt   | 178 ++++++++
 .../TraceWarningPackageValidationException.kt |   9 +
 .../download/server/TraceWarningApiV1.kt      |  31 ++
 .../server/TraceWarningPackageDownload.kt     |  16 +
 .../download/server/TraceWarningServer.kt     |  54 +++
 .../warning/storage/TraceWarningPackage.kt    |  14 +
 .../storage/TraceWarningPackageContainer.kt   |  23 ++
 .../storage/TraceWarningPackageDatabase.kt    |  62 +++
 .../storage/TraceWarningPackageMetadata.kt    |  58 +++
 .../warning/storage/TraceWarningRepository.kt | 182 +++++++++
 .../warning/storage/WarningPackageId.kt       |   3 +
 .../coronawarnapp/risk/CombinedEwPtRisk.kt    |  64 +++
 .../risk/RiskLevelChangeDetector.kt           |   7 +-
 .../risk/RiskLevelResultExtensions.kt         |  37 +-
 .../risk/execution/RiskWorkScheduler.kt       | 109 +++++
 .../risk/storage/BaseRiskLevelStorage.kt      | 157 ++++---
 .../risk/storage/RiskLevelStorage.kt          |  59 +--
 .../submission/SubmissionRepository.kt        |   5 +-
 .../submission/task/SubmissionTask.kt         |   7 +-
 .../tracing/states/TracingStateProvider.kt    |  11 +-
 .../TracingDetailsFragmentViewModel.kt        |   3 +-
 .../ui/details/TracingDetailsItemProvider.kt  |   3 +-
 .../items/riskdetails/DetailsLowRiskBox.kt    |   1 +
 .../SettingsTracingFragmentViewModel.kt       |   7 +-
 .../EventRegistrationUIModule.kt              |   8 +-
 .../confirm/ConfirmCheckInViewModel.kt        |   1 -
 .../details/QrCodeDetailViewModel.kt          |   6 +-
 .../rki/coronawarnapp/ui/main/MainActivity.kt |   8 +-
 .../ui/settings/SettingsResetViewModel.kt     |   5 +-
 .../util/TimeAndDateExtensions.kt             |   7 +
 .../rki/coronawarnapp/util/WatchdogService.kt |  24 +-
 .../util/coroutine/ListenableFuture.kt        |  57 +++
 .../util/formatter/FormatterStatistics.kt     |  15 +-
 .../util/worker/CWAWorkerFactory.kt           |  18 +-
 .../coronawarnapp/util/worker/WorkerBinder.kt |  22 +-
 .../worker/BackgroundConstants.kt             |  20 -
 .../worker/BackgroundNoisePeriodicWorker.kt   |   8 +-
 .../worker/BackgroundWorkBuilder.kt           | 193 ++++-----
 .../worker/BackgroundWorkScheduler.kt         | 133 ++----
 .../worker/BackgroundWorkSchedulerBase.kt     |  11 -
 .../DiagnosisKeyRetrievalPeriodicWorker.kt    |  60 ---
 ...gnosisTestResultRetrievalPeriodicWorker.kt |   6 +-
 .../src/main/res/drawable/ic_print.xml        |   6 +-
 .../src/main/res/drawable/ic_share.xml        |   6 +-
 .../ContactDiaryOverviewViewModelTest.kt      |   2 +-
 .../checkins/CheckInRepositoryTest.kt         |   5 -
 .../CheckInTransmissionRiskLevelTest.kt       |   1 -
 .../checkins/CheckInsTransformerTest.kt       |   3 -
 .../checkins/split/CheckInSplitterTest.kt     |   1 -
 .../events/TraceLocationIdTest.kt             | 204 +++++++++-
 .../storage/retention/CheckInCleanerTest.kt   |   1 -
 .../checkins/checkout/CheckOutHandlerTest.kt  |   1 -
 .../checkout/auto/AutoCheckOutTest.kt         |   1 -
 .../common/TraceLocationNotificationsTest.kt  |   2 +-
 .../risk/CheckInWarningMatcherTest.kt         | 255 ------------
 .../calculation/CheckInWarningMatcherTest.kt  | 255 ++++++++++++
 .../risk/{ => calculation}/FindMatchesTest.kt |  12 +-
 .../risk/{ => calculation}/OverlapTest.kt     |  94 +++--
 .../PresenceTracingRiskCalculatorTest.kt      |   2 +-
 .../PresenceTracingRiskMapperTest.kt          |   2 +-
 .../PresenceTracingWarningTaskTest.kt         | 223 ++++++++++
 .../PresenceTracingWarningWorkerTest.kt       |  95 +++++
 .../TraceWarningPackageDownloaderTest.kt      |  12 +
 .../risk/EwRiskLevelResultExtensionsTest.kt   |  13 +-
 .../risk/RiskLevelChangeDetectorTest.kt       | 194 +++++++--
 .../risk/storage/BaseRiskLevelStorageTest.kt  | 385 +++++++++++++++++-
 .../risk/storage/CombineRiskTest.kt           | 110 ++++-
 .../risk/storage/RiskStorageTestData.kt       |  10 +-
 .../storage/SubmissionRepositoryTest.kt       |   9 +-
 .../submission/task/SubmissionTaskTest.kt     |  14 +-
 .../details/TracingDetailsItemProviderTest.kt |  66 +--
 .../poster/QrCodePosterViewModelTest.kt       |   2 +-
 .../util/TimeAndDateExtensionsTest.kt         |  13 +
 .../FormatterStatisticsHelperTest.kt          |  10 +-
 .../util/worker/CWAWorkerFactoryTest.kt       |  26 +-
 .../util/worker/WorkerBinderTest.kt           |   4 +
 .../worker/BackgroundWorkBuilderTest.kt       |  16 -
 ...isTestResultRetrievalPeriodicWorkerTest.kt |  21 +-
 .../storage/DefaultRiskLevelStorageTest.kt    |   4 +-
 .../storage/DefaultRiskLevelStorageTest.kt    |   4 +-
 124 files changed, 3934 insertions(+), 1826 deletions(-)
 delete mode 100644 Corona-Warn-App/schemas/de.rki.coronawarnapp.eventregistration.checkins.riskcalculation.PresenceTracingDatabase/1.json
 rename Corona-Warn-App/schemas/{de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskDatabase => de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskDatabase}/1.json (100%)
 create mode 100644 Corona-Warn-App/schemas/de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningDatabase/1.json
 delete mode 100644 Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorkerTest.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/execution/DiagnosisKeyRetrievalWorkBuilder.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{worker/DiagnosisKeyRetrievalOneTimeWorker.kt => diagnosiskeys/execution/DiagnosisKeyRetrievalWorker.kt} (50%)
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/download/TraceTimeIntervalWarningPackage.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/download/TraceTimeIntervalWarningRepository.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/common/{TraceLocationNotifications.kt => PresenceTracingNotifications.kt} (96%)
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/CheckInWarningMatcher.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskRepository.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/{ => presencetracing}/risk/TraceLocationCheckInRisk.kt (62%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/CheckInWarningMatcher.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/{ => calculation}/PresenceTracingConversions.kt (73%)
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/{ => calculation}/PresenceTracingRiskCalculator.kt (97%)
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/{ => calculation}/PresenceTracingRiskMapper.kt (97%)
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/{ => calculation}/PresenceTracingRiskModel.kt (90%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningTask.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorkBuilder.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorker.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/{ => storage}/PresenceTracingRiskDatabase.kt (85%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/storage/PresenceTracingRiskRepository.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/PresenceTracingWarningModule.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageDownloader.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageSyncTool.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageValidationException.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningApiV1.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningPackageDownload.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningServer.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackage.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageContainer.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageDatabase.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageMetadata.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningRepository.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/WarningPackageId.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/CombinedEwPtRisk.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/execution/RiskWorkScheduler.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/ListenableFuture.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkSchedulerBase.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
 delete mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/CheckInWarningMatcherTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/CheckInWarningMatcherTest.kt
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/{ => calculation}/FindMatchesTest.kt (79%)
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/{ => calculation}/OverlapTest.kt (72%)
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/{ => calculation}/PresenceTracingRiskCalculatorTest.kt (98%)
 rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/{ => calculation}/PresenceTracingRiskMapperTest.kt (98%)
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningTaskTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorkerTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageDownloaderTest.kt
 delete mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt

diff --git a/.reuse/dep5 b/.reuse/dep5
index 95abf9721..b1088ff6d 100644
--- a/.reuse/dep5
+++ b/.reuse/dep5
@@ -40,6 +40,10 @@ Files: Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/viewmodel/ViewMod
 Copyright: 2018 The Android Open Source Project
 License: Apache-2.0
 
+Files: Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/ListenableFuture.kt
+Copyright: 2018 The Android Open Source Project
+License: Apache-2.0
+
 Files: gradlew gradlew.bat
 Copyright: Copyright 2015 the original author or authors.
 License: Apache-2.0
diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.eventregistration.checkins.riskcalculation.PresenceTracingDatabase/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.eventregistration.checkins.riskcalculation.PresenceTracingDatabase/1.json
deleted file mode 100644
index 1109cd70c..000000000
--- a/Corona-Warn-App/schemas/de.rki.coronawarnapp.eventregistration.checkins.riskcalculation.PresenceTracingDatabase/1.json
+++ /dev/null
@@ -1,64 +0,0 @@
-{
-  "formatVersion": 1,
-  "database": {
-    "version": 1,
-    "identityHash": "751c249cb9c836add4b0cb663ed13954",
-    "entities": [
-      {
-        "tableName": "TraceTimeIntervalMatchEntity",
-        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `checkInId` INTEGER NOT NULL, `traceWarningPackageId` INTEGER NOT NULL, `transmissionRiskLevel` INTEGER NOT NULL, `startTimeMillis` INTEGER NOT NULL, `endTimeMillis` INTEGER NOT NULL)",
-        "fields": [
-          {
-            "fieldPath": "id",
-            "columnName": "id",
-            "affinity": "INTEGER",
-            "notNull": false
-          },
-          {
-            "fieldPath": "checkInId",
-            "columnName": "checkInId",
-            "affinity": "INTEGER",
-            "notNull": true
-          },
-          {
-            "fieldPath": "traceWarningPackageId",
-            "columnName": "traceWarningPackageId",
-            "affinity": "INTEGER",
-            "notNull": true
-          },
-          {
-            "fieldPath": "transmissionRiskLevel",
-            "columnName": "transmissionRiskLevel",
-            "affinity": "INTEGER",
-            "notNull": true
-          },
-          {
-            "fieldPath": "startTimeMillis",
-            "columnName": "startTimeMillis",
-            "affinity": "INTEGER",
-            "notNull": true
-          },
-          {
-            "fieldPath": "endTimeMillis",
-            "columnName": "endTimeMillis",
-            "affinity": "INTEGER",
-            "notNull": true
-          }
-        ],
-        "primaryKey": {
-          "columnNames": [
-            "id"
-          ],
-          "autoGenerate": true
-        },
-        "indices": [],
-        "foreignKeys": []
-      }
-    ],
-    "views": [],
-    "setupQueries": [
-      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
-      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '751c249cb9c836add4b0cb663ed13954')"
-    ]
-  }
-}
\ No newline at end of file
diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.eventregistration.storage.TraceLocationDatabase/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.eventregistration.storage.TraceLocationDatabase/1.json
index 9e78f2a34..befd22943 100644
--- a/Corona-Warn-App/schemas/de.rki.coronawarnapp.eventregistration.storage.TraceLocationDatabase/1.json
+++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.eventregistration.storage.TraceLocationDatabase/1.json
@@ -2,11 +2,11 @@
   "formatVersion": 1,
   "database": {
     "version": 1,
-    "identityHash": "1dc5f8a56361d50b8bb18050bce59d20",
+    "identityHash": "5117ed4caaa7ecd70051902d844cc665",
     "entities": [
       {
         "tableName": "checkin",
-        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `traceLocationIdBase64` TEXT NOT NULL, `traceLocationIdHashBase64` TEXT NOT NULL, `version` INTEGER NOT NULL, `type` INTEGER NOT NULL, `description` TEXT NOT NULL, `address` TEXT NOT NULL, `traceLocationStart` TEXT, `traceLocationEnd` TEXT, `defaultCheckInLengthInMinutes` INTEGER, `cryptographicSeedBase64` TEXT NOT NULL, `cnPublicKey` TEXT NOT NULL, `checkInStart` TEXT NOT NULL, `checkInEnd` TEXT NOT NULL, `completed` INTEGER NOT NULL, `createJournalEntry` INTEGER NOT NULL)",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `traceLocationIdBase64` TEXT NOT NULL, `version` INTEGER NOT NULL, `type` INTEGER NOT NULL, `description` TEXT NOT NULL, `address` TEXT NOT NULL, `traceLocationStart` TEXT, `traceLocationEnd` TEXT, `defaultCheckInLengthInMinutes` INTEGER, `cryptographicSeedBase64` TEXT NOT NULL, `cnPublicKey` TEXT NOT NULL, `checkInStart` TEXT NOT NULL, `checkInEnd` TEXT NOT NULL, `completed` INTEGER NOT NULL, `createJournalEntry` INTEGER NOT NULL)",
         "fields": [
           {
             "fieldPath": "id",
@@ -20,12 +20,6 @@
             "affinity": "TEXT",
             "notNull": true
           },
-          {
-            "fieldPath": "traceLocationIdHashBase64",
-            "columnName": "traceLocationIdHashBase64",
-            "affinity": "TEXT",
-            "notNull": true
-          },
           {
             "fieldPath": "version",
             "columnName": "version",
@@ -192,7 +186,7 @@
     "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, '1dc5f8a56361d50b8bb18050bce59d20')"
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '5117ed4caaa7ecd70051902d844cc665')"
     ]
   }
 }
\ No newline at end of file
diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskDatabase/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskDatabase/1.json
similarity index 100%
rename from Corona-Warn-App/schemas/de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskDatabase/1.json
rename to Corona-Warn-App/schemas/de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskDatabase/1.json
diff --git a/Corona-Warn-App/schemas/de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningDatabase/1.json b/Corona-Warn-App/schemas/de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningDatabase/1.json
new file mode 100644
index 000000000..71c7f0b1d
--- /dev/null
+++ b/Corona-Warn-App/schemas/de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningDatabase/1.json
@@ -0,0 +1,76 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 1,
+    "identityHash": "1775e362b403962906c6d384338b2708",
+    "entities": [
+      {
+        "tableName": "TraceWarningPackageMetadata",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `createdAt` TEXT NOT NULL, `location` TEXT NOT NULL, `hourInterval` INTEGER NOT NULL, `eTag` TEXT, `downloaded` INTEGER NOT NULL, `emptyPkg` INTEGER NOT NULL, `processed` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "packageId",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "location",
+            "columnName": "location",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "hourInterval",
+            "columnName": "hourInterval",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "eTag",
+            "columnName": "eTag",
+            "affinity": "TEXT",
+            "notNull": false
+          },
+          {
+            "fieldPath": "isDownloaded",
+            "columnName": "downloaded",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isEmptyPkg",
+            "columnName": "emptyPkg",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isProcessed",
+            "columnName": "processed",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "columnNames": [
+            "id"
+          ],
+          "autoGenerate": false
+        },
+        "indices": [],
+        "foreignKeys": []
+      }
+    ],
+    "views": [],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '1775e362b403962906c6d384338b2708')"
+    ]
+  }
+}
\ No newline at end of file
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/eventregistration/storage/CheckInDatabaseData.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/eventregistration/storage/CheckInDatabaseData.kt
index 500e91485..370276daf 100644
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/eventregistration/storage/CheckInDatabaseData.kt
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/eventregistration/storage/CheckInDatabaseData.kt
@@ -9,7 +9,6 @@ object CheckInDatabaseData {
 
     val testCheckIn = TraceLocationCheckInEntity(
         traceLocationIdBase64 = "traceLocationId1".encode().base64(),
-        traceLocationIdHashBase64 = "traceLocationIdHash1".encode().base64(),
         version = 1,
         type = TraceLocationOuterClass.TraceLocationType.LOCATION_TYPE_TEMPORARY_OTHER.number,
         description = "testDescription1",
@@ -27,7 +26,6 @@ object CheckInDatabaseData {
 
     val testCheckInWithoutCheckOutTime = TraceLocationCheckInEntity(
         traceLocationIdBase64 = "traceLocationId1".encode().base64(),
-        traceLocationIdHashBase64 = "traceLocationIdHash1".encode().base64(),
         version = 1,
         type = TraceLocationOuterClass.TraceLocationType.LOCATION_TYPE_TEMPORARY_OTHER.number,
         description = "testDescription2",
diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorkerTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorkerTest.kt
deleted file mode 100644
index bc8b3025f..000000000
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorkerTest.kt
+++ /dev/null
@@ -1,150 +0,0 @@
-package de.rki.coronawarnapp.worker
-
-import android.content.Context
-import androidx.test.core.app.ApplicationProvider
-import androidx.test.ext.junit.runners.AndroidJUnit4
-import androidx.work.WorkInfo
-import androidx.work.WorkManager
-import androidx.work.WorkRequest
-import androidx.work.testing.TestDriver
-import androidx.work.testing.WorkManagerTestInitHelper
-import io.mockk.Runs
-import io.mockk.every
-import io.mockk.just
-import io.mockk.mockkObject
-import org.hamcrest.CoreMatchers.`is`
-import org.hamcrest.CoreMatchers.notNullValue
-import org.hamcrest.CoreMatchers.nullValue
-import org.hamcrest.MatcherAssert.assertThat
-import org.junit.After
-import org.junit.Before
-import org.junit.Ignore
-import org.junit.Test
-import org.junit.runner.RunWith
-import testhelpers.BaseTestInstrumentation
-
-/**
- * DiagnosisKeyRetrievalPeriodicWorker test.
- */
-@Ignore("FixMe:DiagnosisKeyRetrievalPeriodicWorkerTest")
-@RunWith(AndroidJUnit4::class)
-class DiagnosisKeyRetrievalPeriodicWorkerTest : BaseTestInstrumentation() {
-    private lateinit var context: Context
-    private lateinit var workManager: WorkManager
-    private lateinit var request: WorkRequest
-    private lateinit var request2: WorkRequest
-
-    // small delay because WorkManager does not run work instantly when delay is off
-    private val delay = 500L
-
-    @Before
-    fun setUp() {
-        mockkObject(BackgroundWorkScheduler)
-        // do not init Test WorkManager instance again between tests
-        // leads to all tests instead of first one to fail
-        context = ApplicationProvider.getApplicationContext()
-        if (WorkManager.getInstance(context) !is TestDriver) {
-            WorkManagerTestInitHelper.initializeTestWorkManager(context)
-        }
-        workManager = WorkManager.getInstance(context)
-
-        every { BackgroundWorkScheduler["buildDiagnosisKeyRetrievalPeriodicWork"]() } answers {
-            request = this.callOriginal() as WorkRequest
-            request
-        }
-    }
-
-    /**
-     * Test worker for success.
-     */
-    @Test
-    fun testDiagnosisKeyRetrievalPeriodicWorkerSuccess() {
-        every { BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork() } just Runs
-
-        BackgroundWorkScheduler.scheduleDiagnosisKeyPeriodicWork()
-
-        assertThat(request, notNullValue())
-
-        var workInfo = workManager.getWorkInfoById(request.id).get()
-        assertThat(workInfo, notNullValue())
-        assertThat(workInfo.state, `is`((WorkInfo.State.ENQUEUED)))
-
-        runPeriodicJobInitialDelayMet()
-        assertThat(request, notNullValue())
-        workInfo = workManager.getWorkInfoById(request.id).get()
-        assertThat(workInfo.runAttemptCount, `is`(0))
-
-        runPeriodicJobPeriodDelayMet()
-        assertThat(request, notNullValue())
-        workInfo = workManager.getWorkInfoById(request.id).get()
-        assertThat(workInfo.runAttemptCount, `is`(0))
-    }
-
-    /**
-     * Test worker for retries and fail.
-     */
-    @Test
-    fun testDiagnosisKeyRetrievalPeriodicWorkerRetryAndFail() {
-        every { BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork() } throws Exception("test exception")
-
-        BackgroundWorkScheduler.scheduleDiagnosisKeyPeriodicWork()
-
-        assertThat(request, notNullValue())
-        var workInfo = workManager.getWorkInfoById(request.id).get()
-        assertThat(workInfo, notNullValue())
-        assertThat(workInfo.state, `is`((WorkInfo.State.ENQUEUED)))
-
-        for (i in 1..BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 2) {
-            // run job i times
-            when (i) {
-                BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 2 -> {
-                    every { BackgroundWorkScheduler["buildDiagnosisKeyRetrievalPeriodicWork"]() } answers {
-                        request2 = this.callOriginal() as WorkRequest
-                        request2
-                    }
-                    runPeriodicJobInitialDelayMet()
-                }
-                else -> {
-                    runPeriodicJobInitialDelayMet()
-                }
-            }
-
-            // get job run #i result
-            when (i) {
-                BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 2 -> {
-                    assertThat(request, notNullValue())
-                    assertThat(request2, notNullValue())
-                    workInfo = workManager.getWorkInfoById(request.id).get()
-                    val workInfo2 = workManager.getWorkInfoById(request2.id).get()
-                    assertThat(workInfo, nullValue())
-                    assertThat(workInfo2.state, `is`(WorkInfo.State.ENQUEUED))
-                    assertThat(workInfo2.runAttemptCount, `is`(0))
-                }
-                else -> {
-                    assertThat(request, notNullValue())
-                    workInfo = workManager.getWorkInfoById(request.id).get()
-                    assertThat(workInfo.runAttemptCount, `is`(i))
-                }
-            }
-        }
-    }
-
-    @After
-    fun cleanUp() {
-        workManager.cancelAllWork()
-    }
-
-    private fun runPeriodicJobInitialDelayMet() {
-        val testDriver = WorkManagerTestInitHelper.getTestDriver(context)
-        testDriver?.setAllConstraintsMet(request.id)
-        testDriver?.setInitialDelayMet(request.id)
-        Thread.sleep(delay)
-    }
-
-    private fun runPeriodicJobPeriodDelayMet() {
-        val testDriver = WorkManagerTestInitHelper.getTestDriver(context)
-        testDriver?.setAllConstraintsMet(request.id)
-        testDriver?.setPeriodDelayMet(request.id)
-        Thread.sleep(delay)
-    }
-}
diff --git a/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
index c88e6445b..f8ac9f20d 100644
--- a/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
+++ b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
@@ -1,6 +1,6 @@
 package de.rki.coronawarnapp.risk.storage
 
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
 import de.rki.coronawarnapp.risk.EwRiskLevelResult
 import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
 import de.rki.coronawarnapp.util.coroutine.AppScope
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
index ee15ef5ab..7fbb56a67 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
@@ -1,6 +1,6 @@
 package de.rki.coronawarnapp.risk.storage
 
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
 import de.rki.coronawarnapp.risk.EwRiskLevelResult
 import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
 import de.rki.coronawarnapp.risk.storage.internal.windows.PersistedExposureWindowDao.PersistedScanInstance
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/eventregistration/ui/EventRegistrationTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/eventregistration/ui/EventRegistrationTestFragment.kt
index 70f15828d..95d522e04 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/eventregistration/ui/EventRegistrationTestFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/eventregistration/ui/EventRegistrationTestFragment.kt
@@ -4,7 +4,6 @@ import android.annotation.SuppressLint
 import android.os.Bundle
 import android.text.SpannedString
 import android.view.View
-import android.widget.Toast
 import androidx.core.text.bold
 import androidx.core.text.buildSpannedString
 import androidx.core.text.color
@@ -35,34 +34,34 @@ class EventRegistrationTestFragment : Fragment(R.layout.fragment_test_eventregis
     override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
         super.onViewCreated(view, savedInstanceState)
 
-        binding.runMatcher.setOnClickListener {
-            viewModel.runMatcher()
-        }
-
-        binding.downloadReportedCheckIns.setOnClickListener {
-            Toast.makeText(context, "Not implemented", Toast.LENGTH_SHORT).show()
+        binding.resetProcessedWarningPackages.setOnClickListener {
+            viewModel.resetProcessedWarningPackages()
         }
 
         binding.calculateRisk.setOnClickListener {
             viewModel.runRiskCalculationPerCheckInDay()
         }
 
-        viewModel.checkInOverlapsText.observe2(this) {
-            binding.matchingResultText.text = it
+        viewModel.presenceTracingWarningTaskResult.observe2(this) {
+            binding.tracingWarningTaskResult.text = it
         }
 
         viewModel.checkInRiskPerDayText.observe2(this) {
             binding.riskCalculationResultText.text = it
         }
 
-        viewModel.matchingRuntime.observe2(this) {
-            binding.matchingRuntimeText.text = "Matching runtime in millis: $it"
+        viewModel.taskRunTime.observe2(this) {
+            binding.taskRunTime.text = "Task finished in ${it}ms"
         }
 
         viewModel.riskCalculationRuntime.observe2(this) {
             binding.riskCalculationRuntimeText.text = "Risk calculation runtime in millis: $it"
         }
 
+        binding.runPtWarningTask.setOnClickListener {
+            viewModel.runPresenceTracingWarningTask()
+        }
+
         viewModel.lastOrganiserLocation.observe(viewLifecycleOwner) {
             binding.lastOrganiserLocationCard.isVisible = it != null
             it?.let { traceLocation ->
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/eventregistration/ui/EventRegistrationTestFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/eventregistration/ui/EventRegistrationTestFragmentViewModel.kt
index a44796fb2..3913503fe 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/eventregistration/ui/EventRegistrationTestFragmentViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/eventregistration/ui/EventRegistrationTestFragmentViewModel.kt
@@ -9,23 +9,31 @@ import de.rki.coronawarnapp.eventregistration.checkins.CheckIn
 import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository
 import de.rki.coronawarnapp.eventregistration.checkins.qrcode.TraceLocation
 import de.rki.coronawarnapp.eventregistration.storage.repo.TraceLocationRepository
-import de.rki.coronawarnapp.presencetracing.risk.CheckInWarningMatcher
-import de.rki.coronawarnapp.presencetracing.risk.CheckInWarningOverlap
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskCalculator
+import de.rki.coronawarnapp.presencetracing.risk.calculation.PresenceTracingRiskCalculator
+import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningTask
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningRepository
 import de.rki.coronawarnapp.server.protocols.internal.pt.TraceLocationOuterClass
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
+import de.rki.coronawarnapp.task.submitBlocking
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.debug.measureTime
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.map
 import timber.log.Timber
+import kotlin.system.measureTimeMillis
 
 class EventRegistrationTestFragmentViewModel @AssistedInject constructor(
     dispatcherProvider: DispatcherProvider,
     traceLocationRepository: TraceLocationRepository,
     checkInRepository: CheckInRepository,
-    private val checkInWarningMatcher: CheckInWarningMatcher,
-    private val presenceTracingRiskCalculator: PresenceTracingRiskCalculator
+    private val presenceTracingRiskCalculator: PresenceTracingRiskCalculator,
+    private val taskController: TaskController,
+    private val presenceTracingRiskRepository: PresenceTracingRiskRepository,
+    private val traceWarningRepository: TraceWarningRepository
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
 
     val lastOrganiserLocation: LiveData<TraceLocation?> =
@@ -38,42 +46,49 @@ class EventRegistrationTestFragmentViewModel @AssistedInject constructor(
             .map { lastAttendeeLocationData(it) }
             .asLiveData(dispatcherProvider.Default)
 
-    private val checkInWarningOverlaps = mutableListOf<CheckInWarningOverlap>()
-    val checkInOverlapsText = MutableLiveData<String>()
-    val matchingRuntime = MutableLiveData<Long>()
+    val presenceTracingWarningTaskResult = MutableLiveData<String>()
+    val taskRunTime = MutableLiveData<Long>()
     val riskCalculationRuntime = MutableLiveData<Long>()
 
     val checkInRiskPerDayText = MutableLiveData<String>()
 
-    fun runMatcher() {
-        launch {
-            measureTime(
-                {
-                    Timber.d("Time to find matches: $it millis")
-                    matchingRuntime.postValue(it)
-                },
-                {
-                    checkInWarningOverlaps.clear()
-                    val matches = checkInWarningMatcher.execute()
+    fun runPresenceTracingWarningTask() = launch {
+        Timber.d("runWarningPackageTask()")
+        presenceTracingWarningTaskResult.postValue("Running")
+        taskRunTime.postValue(-1L)
 
-                    checkInWarningOverlaps.addAll(matches)
-
-                    if (checkInWarningOverlaps.size < 100) {
-                        val text = checkInWarningOverlaps.fold(StringBuilder()) { stringBuilder, checkInOverlap ->
-                            stringBuilder
-                                .append("CheckIn Id ${checkInOverlap.checkInId}, ")
-                                .append("Date ${checkInOverlap.localDateUtc}, ")
-                                .append("Min. ${checkInOverlap.overlap.standardMinutes}")
-                                .append("\n")
-                        }
-                        if (text.isBlank()) checkInOverlapsText.postValue("No matches found")
-                        else checkInOverlapsText.postValue(text.toString())
-                    } else {
-                        checkInOverlapsText.postValue("Output too large. ${checkInWarningOverlaps.size} lines")
-                    }
-                }
+        val duration = measureTimeMillis {
+            taskController.submitBlocking(
+                DefaultTaskRequest(
+                    PresenceTracingWarningTask::class,
+                    originTag = "EventRegistrationTestFragmentViewModel"
+                )
             )
         }
+        taskRunTime.postValue(duration)
+
+        val warningPackages = traceWarningRepository.allMetaData.first()
+        val overlaps = presenceTracingRiskRepository.checkInWarningOverlaps.first()
+        val lastResult = presenceTracingRiskRepository.latestEntries(1).first().singleOrNull()
+
+        val infoText = when {
+            !lastResult!!.wasSuccessfullyCalculated -> "Last calculation failed"
+            overlaps.isEmpty() -> "No matches found (${warningPackages.size} warning packages)."
+            overlaps.size > 100 -> "Output too large. ${overlaps.size} lines"
+            overlaps.isNotEmpty() -> overlaps.fold(StringBuilder()) { stringBuilder, checkInOverlap ->
+                stringBuilder
+                    .append("CheckIn Id ${checkInOverlap.checkInId}, ")
+                    .append("Date ${checkInOverlap.localDateUtc}, ")
+                    .append("Min. ${checkInOverlap.overlap.standardMinutes}")
+                    .appendLine()
+            }.toString()
+            else -> "Unknown state"
+        }
+        presenceTracingWarningTaskResult.postValue(infoText)
+    }
+
+    fun resetProcessedWarningPackages() = launch {
+        traceWarningRepository.clear()
     }
 
     fun runRiskCalculationPerCheckInDay() {
@@ -84,6 +99,7 @@ class EventRegistrationTestFragmentViewModel @AssistedInject constructor(
                     riskCalculationRuntime.postValue(it)
                 },
                 {
+                    val checkInWarningOverlaps = presenceTracingRiskRepository.checkInWarningOverlaps.first()
                     val normalizedTimePerCheckInDayList =
                         presenceTracingRiskCalculator.calculateNormalizedTime(checkInWarningOverlaps)
                     val riskStates =
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 10ba1a3b2..e631969b4 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
@@ -37,7 +37,6 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() {
             DataDonationTestFragment.MENU_ITEM,
             DeltaonboardingFragment.MENU_ITEM,
             EventRegistrationTestFragment.MENU_ITEM,
-            DeltaonboardingFragment.MENU_ITEM
         ).let { MutableLiveData(it) }
     }
     val showTestScreenEvent = SingleLiveEvent<TestMenuItem>()
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_eventregistration.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_eventregistration.xml
index 1f01bcef3..1e4441e00 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_eventregistration.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_eventregistration.xml
@@ -25,31 +25,30 @@
                 android:text="Download, matching &amp; risk calculation" />
 
             <com.google.android.material.button.MaterialButton
-                android:id="@+id/downloadReportedCheckIns"
+                android:id="@+id/reset_processed_warning_packages"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_tiny"
-                android:text="download check-ins" />
+                android:text="Reset processed packages" />
 
             <com.google.android.material.button.MaterialButton
-                android:id="@+id/runMatcher"
+                android:id="@+id/run_pt_warning_task"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_small"
-                android:text="Run matcher" />
+                android:text="Run task (download+matching)" />
 
             <TextView
-                android:id="@+id/matchingRuntimeText"
+                android:id="@+id/task_run_time"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_small"
                 android:text="Matching runtime:" />
 
             <TextView
-                android:id="@+id/matchingResultText"
+                android:id="@+id/tracing_warning_task_result"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/spacing_small"
                 android:text="Matching result:" />
 
             <com.google.android.material.button.MaterialButton
@@ -70,7 +69,6 @@
                 android:id="@+id/riskCalculationResultText"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
-                android:layout_marginTop="@dimen/spacing_small"
                 android:text="Risk calculation result:" />
 
         </LinearLayout>
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
index f364e27d4..cee7752cb 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt
@@ -65,6 +65,7 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
     @Inject lateinit var onboardingSettings: OnboardingSettings
     @Inject lateinit var autoCheckOut: AutoCheckOut
     @Inject lateinit var traceLocationDbCleanupScheduler: TraceLocationDbCleanUpScheduler
+    @Inject lateinit var backgroundWorkScheduler: BackgroundWorkScheduler
 
     @LogHistoryTree @Inject lateinit var rollingLogHistory: Timber.Tree
 
@@ -83,8 +84,6 @@ class CoronaWarnApplication : Application(), HasAndroidInjector {
             compPreview.inject(this)
         }
 
-        BackgroundWorkScheduler.init(component)
-
         Timber.plant(rollingLogHistory)
 
         Timber.v("onCreate(): WorkManager setup done: $workManager")
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
index 3c0a31697..d61dd1525 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt
@@ -16,6 +16,8 @@ interface KeyDownloadConfig {
 
     val revokedHourPackages: Collection<RevokedKeyPackage.Hour>
 
+    val revokedTraceWarningPackages: Collection<RevokedKeyPackage.TraceWarning>
+
     interface RevokedKeyPackage {
         val etag: String
         val region: LocationCode
@@ -27,6 +29,8 @@ interface KeyDownloadConfig {
         interface Hour : Day, RevokedKeyPackage {
             val hour: LocalTime
         }
+
+        interface TraceWarning : RevokedKeyPackage
     }
 
     interface Mapper : ConfigMapper<KeyDownloadConfig>
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt
index f1a5a2671..f9c95b418 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt
@@ -26,7 +26,8 @@ class KeyDownloadParametersMapper @Inject constructor() : KeyDownloadConfig.Mapp
             individualDownloadTimeout = rawParameters.individualTimeout(),
             overallDownloadTimeout = rawParameters.overAllTimeout(),
             revokedDayPackages = rawParameters.mapDayEtags(),
-            revokedHourPackages = rawParameters.mapHourEtags()
+            revokedHourPackages = rawParameters.mapHourEtags(),
+            revokedTraceWarningPackages = rawParameters.mapTraceWarningEtags()
         )
     }
 
@@ -81,11 +82,27 @@ class KeyDownloadParametersMapper @Inject constructor() : KeyDownloadConfig.Mapp
         }
     }
 
+    private fun KeyDownloadParametersAndroid?.mapTraceWarningEtags(): List<RevokedKeyPackage.TraceWarning> {
+        if (this == null) return emptyList()
+
+        return this.revokedTraceWarningPackagesList.mapNotNull {
+            if (it.etag == null) {
+                Timber.e("TraceWarningPackageMeta data had no ETAG: %s", it)
+                return@mapNotNull null
+            }
+            RevokedKeyPackage.TraceWarning(
+                etag = it.etag,
+                region = LocationCode("DE"),
+            )
+        }
+    }
+
     data class KeyDownloadConfigContainer(
         override val individualDownloadTimeout: Duration,
         override val overallDownloadTimeout: Duration,
         override val revokedDayPackages: Collection<KeyDownloadConfig.RevokedKeyPackage.Day>,
-        override val revokedHourPackages: Collection<KeyDownloadConfig.RevokedKeyPackage.Hour>
+        override val revokedHourPackages: Collection<KeyDownloadConfig.RevokedKeyPackage.Hour>,
+        override val revokedTraceWarningPackages: Collection<KeyDownloadConfig.RevokedKeyPackage.TraceWarning>
     ) : KeyDownloadConfig
 
     companion object {
@@ -109,4 +126,9 @@ internal sealed class RevokedKeyPackage : KeyDownloadConfig.RevokedKeyPackage {
         override val day: LocalDate,
         override val hour: LocalTime
     ) : RevokedKeyPackage(), KeyDownloadConfig.RevokedKeyPackage.Hour
+
+    data class TraceWarning(
+        override val etag: String,
+        override val region: LocationCode
+    ) : RevokedKeyPackage(), KeyDownloadConfig.RevokedKeyPackage.TraceWarning
 }
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 47e84e294..bb81b5205 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
@@ -21,8 +21,8 @@ import de.rki.coronawarnapp.contactdiary.ui.overview.adapter.day.riskevent.RiskE
 import de.rki.coronawarnapp.contactdiary.ui.overview.adapter.subheader.OverviewSubHeaderItem
 import de.rki.coronawarnapp.eventregistration.checkins.CheckIn
 import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository
+import de.rki.coronawarnapp.presencetracing.risk.TraceLocationCheckInRisk
 import de.rki.coronawarnapp.risk.RiskState
-import de.rki.coronawarnapp.risk.TraceLocationCheckInRisk
 import de.rki.coronawarnapp.risk.result.ExposureWindowDayRisk
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/execution/DiagnosisKeyRetrievalWorkBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/execution/DiagnosisKeyRetrievalWorkBuilder.kt
new file mode 100644
index 000000000..5e48acc14
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/execution/DiagnosisKeyRetrievalWorkBuilder.kt
@@ -0,0 +1,34 @@
+package de.rki.coronawarnapp.diagnosiskeys.execution
+
+import androidx.work.BackoffPolicy
+import androidx.work.PeriodicWorkRequest
+import androidx.work.PeriodicWorkRequestBuilder
+import dagger.Reusable
+import de.rki.coronawarnapp.worker.BackgroundConstants
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+@Reusable
+class DiagnosisKeyRetrievalWorkBuilder @Inject constructor() {
+
+    /**
+     * This has no network constraints, because even if there is no internet,
+     * the worker+task will trigger diagnosis key submission to the ENF.
+     * We don't want to prevent that.
+     */
+    fun createPeriodicWorkRequest(): PeriodicWorkRequest =
+        PeriodicWorkRequestBuilder<DiagnosisKeyRetrievalWorker>(
+            60,
+            TimeUnit.MINUTES
+        )
+            .setInitialDelay(
+                BackgroundConstants.KIND_DELAY,
+                TimeUnit.MINUTES
+            )
+            .setBackoffCriteria(
+                BackoffPolicy.EXPONENTIAL,
+                BackgroundConstants.BACKOFF_INITIAL_DELAY,
+                TimeUnit.MINUTES
+            )
+            .build()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/execution/DiagnosisKeyRetrievalWorker.kt
similarity index 50%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/execution/DiagnosisKeyRetrievalWorker.kt
index f087a031b..a503798ac 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/execution/DiagnosisKeyRetrievalWorker.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.worker
+package de.rki.coronawarnapp.diagnosiskeys.execution
 
 import android.content.Context
 import androidx.work.CoroutineWorker
@@ -6,6 +6,7 @@ import androidx.work.WorkerParameters
 import dagger.assisted.Assisted
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
+import de.rki.coronawarnapp.bugreporting.reportProblem
 import de.rki.coronawarnapp.diagnosiskeys.download.DownloadDiagnosisKeysTask
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.task.common.DefaultTaskRequest
@@ -13,49 +14,44 @@ import de.rki.coronawarnapp.task.submitBlocking
 import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
 import timber.log.Timber
 
-/**
- * One time diagnosis key retrieval work
- * Executes the retrieve diagnosis key transaction
- *
- * @see BackgroundWorkScheduler
- */
-class DiagnosisKeyRetrievalOneTimeWorker @AssistedInject constructor(
+class DiagnosisKeyRetrievalWorker @AssistedInject constructor(
     @Assisted val context: Context,
     @Assisted workerParams: WorkerParameters,
     private val taskController: TaskController
 ) : CoroutineWorker(context, workerParams) {
 
-    override suspend fun doWork(): Result {
+    override suspend fun doWork(): Result = try {
         Timber.tag(TAG).d("$id: doWork() started. Run attempt: $runAttemptCount")
 
-        var result = Result.success()
-        taskController.submitBlocking(
+        val taskState = taskController.submitBlocking(
             DefaultTaskRequest(
                 DownloadDiagnosisKeysTask::class,
                 DownloadDiagnosisKeysTask.Arguments(),
-                originTag = "DiagnosisKeyRetrievalOneTimeWorker"
+                originTag = "DiagnosisKeyRetrievalWorker"
             )
-        ).error?.also { error: Throwable ->
-            Timber.tag(TAG).w(error, "$id: Error when submitting DownloadDiagnosisKeysTask.")
+        )
 
-            if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
-                Timber.tag(TAG).w(error, "$id: Retry attempts exceeded.")
-
-                return Result.failure()
-            } else {
-                Timber.tag(TAG).d(error, "$id: Retrying.")
-                result = Result.retry()
+        when {
+            taskState.isSuccessful -> {
+                Timber.tag(TAG).d("$id: DownloadDiagnosisKeysTask finished successfully.")
+                Result.success()
+            }
+            else -> {
+                taskState.error?.let {
+                    Timber.tag(TAG).w(it, "$id: Error during DownloadDiagnosisKeysTask.")
+                }
+                Result.retry()
             }
         }
-
-        Timber.tag(TAG).d("$id: doWork() finished with %s", result)
-        return result
+    } catch (e: Exception) {
+        e.reportProblem(TAG, "DownloadDiagnosisKeysTask failed exceptionally, will retry.")
+        Result.retry()
     }
 
     @AssistedFactory
-    interface Factory : InjectedWorkerFactory<DiagnosisKeyRetrievalOneTimeWorker>
+    interface Factory : InjectedWorkerFactory<DiagnosisKeyRetrievalWorker>
 
     companion object {
-        private val TAG = DiagnosisKeyRetrievalOneTimeWorker::class.java.simpleName
+        private val TAG = DiagnosisKeyRetrievalWorker::class.java.simpleName
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/EventRegistrationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/EventRegistrationModule.kt
index 2a1be8176..2704dfe26 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/EventRegistrationModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/EventRegistrationModule.kt
@@ -3,23 +3,20 @@ package de.rki.coronawarnapp.eventregistration
 import dagger.Binds
 import dagger.Module
 import de.rki.coronawarnapp.environment.eventregistration.qrcodeposter.QrCodePosterTemplateModule
-import de.rki.coronawarnapp.eventregistration.checkins.download.FakeTraceTimeIntervalWarningRepository
-import de.rki.coronawarnapp.eventregistration.checkins.download.TraceTimeIntervalWarningRepository
 import de.rki.coronawarnapp.eventregistration.storage.repo.DefaultTraceLocationRepository
 import de.rki.coronawarnapp.eventregistration.storage.repo.TraceLocationRepository
+import de.rki.coronawarnapp.presencetracing.warning.PresenceTracingWarningModule
 
 @Module(
     includes = [
-        QrCodePosterTemplateModule::class
+        QrCodePosterTemplateModule::class,
+        PresenceTracingWarningModule::class,
     ]
 )
 abstract class EventRegistrationModule {
 
     @Binds
-    abstract fun traceLocationRepository(defaultTraceLocationRepo: DefaultTraceLocationRepository):
-        TraceLocationRepository
-
-    @Binds
-    abstract fun traceTimeIntervalWarningRepository(repository: FakeTraceTimeIntervalWarningRepository):
-        TraceTimeIntervalWarningRepository
+    abstract fun traceLocationRepository(
+        defaultTraceLocationRepo: DefaultTraceLocationRepository
+    ): TraceLocationRepository
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/CheckIn.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/CheckIn.kt
index eae4e6abe..1f755923a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/CheckIn.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/CheckIn.kt
@@ -1,15 +1,15 @@
 package de.rki.coronawarnapp.eventregistration.checkins
 
+import de.rki.coronawarnapp.eventregistration.checkins.qrcode.TraceLocationId
+import de.rki.coronawarnapp.eventregistration.checkins.qrcode.toTraceLocationIdHash
 import de.rki.coronawarnapp.eventregistration.storage.entity.TraceLocationCheckInEntity
 import okio.ByteString
-import okio.ByteString.Companion.encode
 import org.joda.time.Instant
 
 @Suppress("LongParameterList")
 data class CheckIn(
     val id: Long = 0L,
-    val traceLocationId: ByteString = "TODO: calculate".encode(),
-    val traceLocationIdHash: ByteString = "TODO: calculate".encode(),
+    val traceLocationId: TraceLocationId,
     val version: Int,
     val type: Int,
     val description: String,
@@ -24,13 +24,18 @@ data class CheckIn(
     val completed: Boolean,
     val createJournalEntry: Boolean
 ) {
-    // val locationGuidHash: com.google.protobuf.ByteString by lazy { copyFromUtf8(guid.toSHA256()) }
+    /**
+     *  Returns SHA-256 hash of [traceLocationId] which itself may also be SHA-256 hash.
+     *  For privacy reasons TraceTimeIntervalWarnings only offer a hash of the actual locationId.
+     *
+     *  @see [de.rki.coronawarnapp.eventregistration.checkins.qrcode.TraceLocation]
+     */
+    val traceLocationIdHash by lazy { traceLocationId.toTraceLocationIdHash() }
 }
 
 fun CheckIn.toEntity() = TraceLocationCheckInEntity(
     id = id,
     traceLocationIdBase64 = traceLocationId.base64(),
-    traceLocationIdHashBase64 = traceLocationIdHash.base64(),
     version = version,
     type = type,
     description = description,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/download/TraceTimeIntervalWarningPackage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/download/TraceTimeIntervalWarningPackage.kt
deleted file mode 100644
index be16290c8..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/download/TraceTimeIntervalWarningPackage.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package de.rki.coronawarnapp.eventregistration.checkins.download
-
-import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
-
-interface TraceTimeIntervalWarningPackage {
-
-    /**
-     * Hides the file reading
-     */
-    suspend fun extractTraceTimeIntervalWarnings(): List<TraceWarning.TraceTimeIntervalWarning>
-
-    /**
-     * Numeric identifier representing the hour since epoch, used in the Api endpoint
-     */
-    val warningPackageId: String
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/download/TraceTimeIntervalWarningRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/download/TraceTimeIntervalWarningRepository.kt
deleted file mode 100644
index 70a8d6332..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/download/TraceTimeIntervalWarningRepository.kt
+++ /dev/null
@@ -1,59 +0,0 @@
-package de.rki.coronawarnapp.eventregistration.checkins.download
-
-import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.asFlow
-import org.joda.time.Duration
-import org.joda.time.Instant
-import javax.inject.Inject
-
-interface TraceTimeIntervalWarningRepository {
-
-    val allWarningPackages: Flow<List<TraceTimeIntervalWarningPackage>>
-
-    fun addWarningPackages(list: List<TraceTimeIntervalWarningPackage>)
-
-    fun removeWarningPackages(list: List<TraceTimeIntervalWarningPackage>)
-}
-
-// proprietary dummy implementations
-class FakeTraceTimeIntervalWarningRepository @Inject constructor() : TraceTimeIntervalWarningRepository {
-    override val allWarningPackages: Flow<List<TraceTimeIntervalWarningPackage>>
-        get() = listOf(listOf<TraceTimeIntervalWarningPackage>(DummyCheckInPackage)).asFlow()
-
-    override fun addWarningPackages(list: List<TraceTimeIntervalWarningPackage>) {
-        // TODO("Not yet implemented")
-    }
-
-    override fun removeWarningPackages(list: List<TraceTimeIntervalWarningPackage>) {
-        // TODO("Not yet implemented")
-    }
-}
-
-object DummyCheckInPackage : TraceTimeIntervalWarningPackage {
-    override suspend fun extractTraceTimeIntervalWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
-        return warnings
-    }
-
-    override val warningPackageId: String
-        get() = "id"
-}
-
-val warnings = (1L..1000L).map {
-    createWarning(
-        startIntervalDateStr = "2021-03-04T10:00+01:00",
-        period = 6,
-        transmissionRiskLevel = 8
-    )
-}
-
-fun createWarning(
-    startIntervalDateStr: String,
-    period: Int,
-    transmissionRiskLevel: Int
-) = TraceWarning.TraceTimeIntervalWarning.newBuilder()
-    // .locationIdHash = TODO: set location Id hash
-    .setPeriod(period)
-    .setStartIntervalNumber((Duration(Instant.parse(startIntervalDateStr).millis).standardMinutes / 10).toInt())
-    .setTransmissionRiskLevel(transmissionRiskLevel)
-    .build()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/TraceLocation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/TraceLocation.kt
index 3bdc275bc..bccfcf4b1 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/TraceLocation.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/TraceLocation.kt
@@ -41,7 +41,7 @@ data class TraceLocation(
      *  The ID is the byte representation of SHA-256 hash.
      */
     @IgnoredOnParcel
-    val locationId: ByteString by lazy {
+    val locationId: TraceLocationId by lazy {
         val cwaDomain = CWA_GUID.toByteArray()
         val payloadBytes = qrCodePayload().toByteArray()
         val totalByteSequence = cwaDomain + payloadBytes
@@ -49,12 +49,13 @@ data class TraceLocation(
     }
 
     /**
-     *  Returns SHA-256 hash of [locationId] which itself is SHA-256 hash
+     *  Returns SHA-256 hash of [locationId] which itself may also be SHA-256 hash.
+     *  For privacy reasons TraceTimeIntervalWarnings only offer a hash of the actual locationId.
+     *
+     *  @see [de.rki.coronawarnapp.eventregistration.checkins.CheckIn]
      */
     @IgnoredOnParcel
-    val locationIdHash: ByteString by lazy {
-        locationId.sha256()
-    }
+    val locationIdHash: ByteString by lazy { locationId.toTraceLocationIdHash() }
 
     fun isBeforeStartTime(now: Instant): Boolean = startDate?.isAfter(now) ?: false
 
@@ -71,6 +72,10 @@ data class TraceLocation(
     }
 }
 
+typealias TraceLocationId = ByteString
+
+fun TraceLocationId.toTraceLocationIdHash() = sha256()
+
 fun List<TraceLocationEntity>.toTraceLocations() = this.map { it.toTraceLocation() }
 
 fun TraceLocationEntity.toTraceLocation() = TraceLocation(
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/storage/entity/TraceLocationCheckInEntity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/storage/entity/TraceLocationCheckInEntity.kt
index 2df991c63..2616685c8 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/storage/entity/TraceLocationCheckInEntity.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/storage/entity/TraceLocationCheckInEntity.kt
@@ -11,7 +11,6 @@ import org.joda.time.Instant
 data class TraceLocationCheckInEntity(
     @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long = 0L,
     @ColumnInfo(name = "traceLocationIdBase64") val traceLocationIdBase64: String,
-    @ColumnInfo(name = "traceLocationIdHashBase64") val traceLocationIdHashBase64: String,
     @ColumnInfo(name = "version") val version: Int,
     @ColumnInfo(name = "type") val type: Int,
     @ColumnInfo(name = "description") val description: String,
@@ -30,7 +29,6 @@ data class TraceLocationCheckInEntity(
 fun TraceLocationCheckInEntity.toCheckIn() = CheckIn(
     id = id,
     traceLocationId = traceLocationIdBase64.decodeBase64()!!,
-    traceLocationIdHash = traceLocationIdHashBase64.decodeBase64()!!,
     version = version,
     type = type,
     description = description,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt
index cce89d650..57791940b 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/playbook/BackgroundNoise.kt
@@ -10,11 +10,12 @@ import kotlin.random.Random
 @Singleton
 class BackgroundNoise @Inject constructor(
     private val submissionSettings: SubmissionSettings,
-    private val playbook: Playbook
+    private val playbook: Playbook,
+    private val backgroundWorkScheduler: BackgroundWorkScheduler
 ) {
     fun scheduleDummyPattern() {
         if (BackgroundConstants.NUMBER_OF_DAYS_TO_RUN_PLAYBOOK > 0)
-            BackgroundWorkScheduler.scheduleBackgroundNoisePeriodicWork()
+            backgroundWorkScheduler.scheduleBackgroundNoisePeriodicWork()
     }
 
     suspend fun foregroundScheduleCheck() {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/CheckOutNotification.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/CheckOutNotification.kt
index 0a169254e..419cc7637 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/CheckOutNotification.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/CheckOutNotification.kt
@@ -5,7 +5,7 @@ import androidx.core.app.NotificationCompat
 import dagger.Reusable
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.notification.NotificationConstants
-import de.rki.coronawarnapp.presencetracing.common.TraceLocationNotifications
+import de.rki.coronawarnapp.presencetracing.common.PresenceTracingNotifications
 import de.rki.coronawarnapp.ui.launcher.LauncherActivity
 import de.rki.coronawarnapp.util.device.ForegroundState
 import de.rki.coronawarnapp.util.di.AppContext
@@ -19,7 +19,7 @@ import javax.inject.Inject
 class CheckOutNotification @Inject constructor(
     @AppContext private val context: Context,
     private val foregroundState: ForegroundState,
-    private val notificationHelper: TraceLocationNotifications,
+    private val notificationHelper: PresenceTracingNotifications,
     private val deepLinkBuilderFactory: NavDeepLinkBuilderFactory,
 ) {
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/common/TraceLocationNotifications.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/common/PresenceTracingNotifications.kt
similarity index 96%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/common/TraceLocationNotifications.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/common/PresenceTracingNotifications.kt
index 349096d2d..7fd3c1f25 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/common/TraceLocationNotifications.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/common/PresenceTracingNotifications.kt
@@ -19,18 +19,18 @@ import timber.log.Timber
 import javax.inject.Inject
 
 /**
- * Helper to send notifications on the notification channel for trace location related events.
+ * Helper to send notifications on the notification channel for presence tracing related events.
  *
  * Also see **[de.rki.coronawarnapp.notification.GeneralNotifications]**
  */
 @Reusable
-class TraceLocationNotifications @Inject constructor(
+class PresenceTracingNotifications @Inject constructor(
     @AppContext private val context: Context,
     private val apiLevel: ApiLevel,
     private val notificationManagerCompat: NotificationManagerCompat,
 ) {
 
-    private val channelId = "${context.packageName}.notification.traceLocationChannelId"
+    private val channelId = "${context.packageName}.notification.presenceTracingChannelId"
     private var isNotificationChannelSetup = false
 
     @TargetApi(Build.VERSION_CODES.O)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/CheckInWarningMatcher.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/CheckInWarningMatcher.kt
deleted file mode 100644
index 66f82291f..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/CheckInWarningMatcher.kt
+++ /dev/null
@@ -1,156 +0,0 @@
-package de.rki.coronawarnapp.presencetracing.risk
-
-import androidx.annotation.VisibleForTesting
-import de.rki.coronawarnapp.eventregistration.checkins.CheckIn
-import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository
-import de.rki.coronawarnapp.eventregistration.checkins.download.TraceTimeIntervalWarningPackage
-import de.rki.coronawarnapp.eventregistration.checkins.download.TraceTimeIntervalWarningRepository
-import de.rki.coronawarnapp.eventregistration.checkins.split.splitByMidnightUTC
-import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
-import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.flow.firstOrNull
-import kotlinx.coroutines.withContext
-import okio.ByteString.Companion.toByteString
-import org.joda.time.Instant
-import timber.log.Timber
-import java.lang.reflect.Modifier.PRIVATE
-import javax.inject.Inject
-import kotlin.coroutines.CoroutineContext
-
-class CheckInWarningMatcher @Inject constructor(
-    private val checkInsRepository: CheckInRepository,
-    private val traceTimeIntervalWarningRepository: TraceTimeIntervalWarningRepository,
-    private val presenceTracingRiskRepository: PresenceTracingRiskRepository,
-    private val dispatcherProvider: DispatcherProvider
-) {
-    suspend fun execute(): List<CheckInWarningOverlap> {
-
-        presenceTracingRiskRepository.deleteStaleData()
-
-        val checkIns = checkInsRepository.allCheckIns.firstOrNull()
-        if (checkIns.isNullOrEmpty()) {
-            Timber.i("No check-ins available. Deleting all matches.")
-            presenceTracingRiskRepository.deleteAllMatches()
-            presenceTracingRiskRepository.reportSuccessfulCalculation(emptyList())
-            return emptyList()
-        }
-
-        val warningPackages = traceTimeIntervalWarningRepository.allWarningPackages.firstOrNull()
-
-        if (warningPackages.isNullOrEmpty()) {
-            // nothing to be done here
-            return emptyList()
-        }
-
-        val splitCheckIns = checkIns.flatMap { it.splitByMidnightUTC() }
-
-        val matchLists = createMatchingLaunchers(
-            splitCheckIns,
-            warningPackages,
-            dispatcherProvider.IO
-        )
-            .awaitAll()
-
-        if (matchLists.contains(null)) {
-            Timber.e("Error occurred during matching. Abort calculation.")
-            presenceTracingRiskRepository.reportFailedCalculation()
-            return emptyList()
-        }
-
-        // delete stale matches from new packages and mark packages as processed
-        warningPackages.forEach {
-            presenceTracingRiskRepository.deleteMatchesOfPackage(it.warningPackageId)
-            presenceTracingRiskRepository.markPackageProcessed(it.warningPackageId)
-        }
-        val matches = matchLists.filterNotNull().flatten()
-
-        presenceTracingRiskRepository.reportSuccessfulCalculation(matches)
-
-        return matches
-    }
-}
-
-@VisibleForTesting(otherwise = PRIVATE)
-internal suspend fun createMatchingLaunchers(
-    checkIns: List<CheckIn>,
-    warningPackages: List<TraceTimeIntervalWarningPackage>,
-    coroutineContext: CoroutineContext
-): Collection<Deferred<List<CheckInWarningOverlap>?>> {
-
-    val launcher: CoroutineScope.(
-        List<CheckIn>,
-        List<TraceTimeIntervalWarningPackage>
-    ) -> Deferred<List<CheckInWarningOverlap>?> =
-        { list, packageChunk ->
-            async {
-                try {
-                    packageChunk.flatMap {
-                        findMatches(list, it)
-                    }
-                } catch (e: Throwable) {
-                    Timber.e("Failed to process packages $packageChunk")
-                    null
-                }
-            }
-        }
-
-    // at most 4 parallel processes
-    val chunkSize = (checkIns.size / 4) + 1
-
-    return warningPackages.chunked(chunkSize).map { packageChunk ->
-        withContext(context = coroutineContext) {
-            launcher(checkIns, packageChunk)
-        }
-    }
-}
-
-@VisibleForTesting(otherwise = PRIVATE)
-internal suspend fun findMatches(
-    checkIns: List<CheckIn>,
-    warningPackage: TraceTimeIntervalWarningPackage
-): List<CheckInWarningOverlap> {
-    return warningPackage
-        .extractTraceTimeIntervalWarnings()
-        .flatMap { warning ->
-            checkIns
-                .mapNotNull { checkIn ->
-                    checkIn.calculateOverlap(warning, warningPackage.warningPackageId).also { overlap ->
-                        if (overlap == null) {
-                            Timber.d("No match/overlap found for $checkIn and $warning")
-                        } else {
-                            Timber.i("Overlap found $overlap")
-                        }
-                    }
-                }
-        }
-}
-
-@VisibleForTesting(otherwise = PRIVATE)
-internal fun CheckIn.calculateOverlap(
-    warning: TraceWarning.TraceTimeIntervalWarning,
-    traceWarningPackageId: String
-): CheckInWarningOverlap? {
-
-    if (warning.locationIdHash.toByteArray().toByteString() != traceLocationIdHash) return null
-
-    val warningStartMillis = warning.startIntervalNumber.tenMinIntervalToMillis()
-    val warningEndMillis = (warning.startIntervalNumber + warning.period).tenMinIntervalToMillis()
-
-    val overlapStartMillis = kotlin.math.max(checkInStart.millis, warningStartMillis)
-    val overlapEndMillis = kotlin.math.min(checkInEnd.millis, warningEndMillis)
-    val overlapMillis = overlapEndMillis - overlapStartMillis
-
-    if (overlapMillis <= 0) return null
-
-    return CheckInWarningOverlap(
-        checkInId = id,
-        transmissionRiskLevel = warning.transmissionRiskLevel,
-        traceWarningPackageId = traceWarningPackageId,
-        startTime = Instant.ofEpochMilli(overlapStartMillis),
-        endTime = Instant.ofEpochMilli(overlapEndMillis)
-    )
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskRepository.kt
deleted file mode 100644
index 0d0f7add0..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskRepository.kt
+++ /dev/null
@@ -1,224 +0,0 @@
-package de.rki.coronawarnapp.presencetracing.risk
-
-import androidx.room.ColumnInfo
-import androidx.room.Dao
-import androidx.room.Entity
-import androidx.room.ForeignKey
-import androidx.room.Insert
-import androidx.room.OnConflictStrategy.REPLACE
-import androidx.room.PrimaryKey
-import androidx.room.Query
-import androidx.room.TypeConverter
-import de.rki.coronawarnapp.eventregistration.storage.entity.TraceLocationCheckInEntity
-import de.rki.coronawarnapp.risk.RiskState
-import de.rki.coronawarnapp.risk.TraceLocationCheckInRisk
-import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc
-import de.rki.coronawarnapp.util.TimeStamper
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.map
-import org.joda.time.Days
-import org.joda.time.Instant
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class PresenceTracingRiskRepository @Inject constructor(
-    private val presenceTracingRiskCalculator: PresenceTracingRiskCalculator,
-    private val databaseFactory: PresenceTracingRiskDatabase.Factory,
-    private val timeStamper: TimeStamper
-) {
-
-    private val database by lazy {
-        databaseFactory.create()
-    }
-
-    private val traceTimeIntervalMatchDao by lazy {
-        database.traceTimeIntervalMatchDao()
-    }
-
-    private val riskLevelResultDao by lazy {
-        database.presenceTracingRiskLevelResultDao()
-    }
-
-    private val allMatches = traceTimeIntervalMatchDao.allMatches().map { list ->
-        list.map {
-            it.toModel()
-        }
-    }
-
-    private val normalizedTime = allMatches.map {
-        presenceTracingRiskCalculator.calculateNormalizedTime(it)
-    }
-
-    private val fifteenDaysAgo: Instant
-        get() = timeStamper.nowUTC.minus(Days.days(15).toStandardDuration())
-
-    val traceLocationCheckInRiskStates: Flow<List<TraceLocationCheckInRisk>> =
-        normalizedTime.map {
-            presenceTracingRiskCalculator.calculateCheckInRiskPerDay(it)
-        }
-
-    val presenceTracingDayRisk: Flow<List<PresenceTracingDayRisk>> =
-        normalizedTime.map {
-            presenceTracingRiskCalculator.calculateAggregatedRiskPerDay(it)
-        }
-
-    internal suspend fun reportSuccessfulCalculation(list: List<CheckInWarningOverlap>) {
-        traceTimeIntervalMatchDao.insert(list.map { it.toEntity() })
-        val last14days = normalizedTime.first().filter { it.localDateUtc.isAfter(fifteenDaysAgo.toLocalDateUtc()) }
-        val risk = presenceTracingRiskCalculator.calculateTotalRisk(last14days)
-        add(
-            PtRiskLevelResult(
-                timeStamper.nowUTC,
-                risk
-            )
-        )
-    }
-
-    internal suspend fun deleteStaleData() {
-        traceTimeIntervalMatchDao.deleteOlderThan(fifteenDaysAgo.millis)
-        riskLevelResultDao.deleteOlderThan(fifteenDaysAgo.millis)
-    }
-
-    internal suspend fun markPackageProcessed(warningPackageId: String) {
-        // TODO
-    }
-
-    internal suspend fun deleteMatchesOfPackage(warningPackageId: String) {
-        traceTimeIntervalMatchDao.deleteMatchesForPackage(warningPackageId)
-    }
-
-    suspend fun deleteAllMatches() {
-        traceTimeIntervalMatchDao.deleteAll()
-    }
-
-    fun latestAndLastSuccessful() = riskLevelResultDao.latestAndLastSuccessful().map { list ->
-        list.map { entity ->
-            entity.toModel()
-        }
-    }
-
-    fun latestEntries(limit: Int) = riskLevelResultDao.latestEntries(limit).map { list ->
-        list.map { entity ->
-            entity.toModel()
-        }
-    }
-
-    fun add(riskLevelResult: PtRiskLevelResult) {
-        riskLevelResultDao.insert(riskLevelResult.toEntity())
-    }
-
-    fun reportFailedCalculation() {
-        add(
-            PtRiskLevelResult(
-                timeStamper.nowUTC,
-                RiskState.CALCULATION_FAILED
-            )
-        )
-    }
-}
-
-@Dao
-interface TraceTimeIntervalMatchDao {
-
-    @Query("SELECT * FROM TraceTimeIntervalMatchEntity")
-    fun allMatches(): Flow<List<TraceTimeIntervalMatchEntity>>
-
-    @Query("DELETE FROM TraceTimeIntervalMatchEntity")
-    suspend fun deleteAll()
-
-    @Query("DELETE FROM TraceTimeIntervalMatchEntity WHERE endTimeMillis < :endTimeMillis")
-    suspend fun deleteOlderThan(endTimeMillis: Long)
-
-    @Query("DELETE FROM TraceTimeIntervalMatchEntity WHERE traceWarningPackageId = :warningPackageId")
-    suspend fun deleteMatchesForPackage(warningPackageId: String)
-
-    @Insert
-    suspend fun insert(entities: List<TraceTimeIntervalMatchEntity>)
-}
-
-@Entity
-data class TraceTimeIntervalMatchEntity(
-    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long? = null,
-    @ForeignKey(
-        entity = TraceLocationCheckInEntity::class,
-        parentColumns = ["id"],
-        childColumns = ["checkInId"],
-        onDelete = ForeignKey.CASCADE
-    )
-    @ColumnInfo(name = "checkInId") val checkInId: Long,
-    @ColumnInfo(name = "traceWarningPackageId") val traceWarningPackageId: String,
-    @ColumnInfo(name = "transmissionRiskLevel") val transmissionRiskLevel: Int,
-    @ColumnInfo(name = "startTimeMillis") val startTimeMillis: Long,
-    @ColumnInfo(name = "endTimeMillis") val endTimeMillis: Long
-)
-
-private fun CheckInWarningOverlap.toEntity() = TraceTimeIntervalMatchEntity(
-    checkInId = checkInId,
-    traceWarningPackageId = traceWarningPackageId,
-    transmissionRiskLevel = transmissionRiskLevel,
-    startTimeMillis = startTime.millis,
-    endTimeMillis = endTime.millis
-)
-
-private fun TraceTimeIntervalMatchEntity.toModel() = CheckInWarningOverlap(
-    checkInId = checkInId,
-    traceWarningPackageId = traceWarningPackageId,
-    transmissionRiskLevel = transmissionRiskLevel,
-    startTime = Instant.ofEpochMilli(startTimeMillis),
-    endTime = Instant.ofEpochMilli(endTimeMillis)
-)
-
-@Suppress("MaxLineLength")
-@Dao
-interface PresenceTracingRiskLevelResultDao {
-    @Query("SELECT * FROM (SELECT * FROM PresenceTracingRiskLevelResultEntity ORDER BY calculatedAtMillis DESC LIMIT 1) UNION ALL SELECT * FROM (SELECT * FROM PresenceTracingRiskLevelResultEntity where riskStateCode is not 0 ORDER BY calculatedAtMillis DESC LIMIT 1)")
-    fun latestAndLastSuccessful(): Flow<List<PresenceTracingRiskLevelResultEntity>>
-
-    @Query("SELECT * FROM PresenceTracingRiskLevelResultEntity ORDER BY calculatedAtMillis DESC LIMIT :limit")
-    fun latestEntries(limit: Int): Flow<List<PresenceTracingRiskLevelResultEntity>>
-
-    @Insert(onConflict = REPLACE)
-    fun insert(entity: PresenceTracingRiskLevelResultEntity)
-
-    @Query("DELETE FROM PresenceTracingRiskLevelResultEntity WHERE calculatedAtMillis < :calculatedAtMillis")
-    suspend fun deleteOlderThan(calculatedAtMillis: Long)
-}
-
-@Entity
-data class PresenceTracingRiskLevelResultEntity(
-    @PrimaryKey @ColumnInfo(name = "calculatedAtMillis") val calculatedAtMillis: Long,
-    @ColumnInfo(name = "riskStateCode")val riskState: RiskState
-)
-
-private fun PresenceTracingRiskLevelResultEntity.toModel() = PtRiskLevelResult(
-    calculatedAt = Instant.ofEpochMilli((calculatedAtMillis)),
-    riskState = riskState
-)
-
-private fun PtRiskLevelResult.toEntity() = PresenceTracingRiskLevelResultEntity(
-    calculatedAtMillis = calculatedAt.millis,
-    riskState = riskState
-)
-
-class RiskStateConverter {
-    @TypeConverter
-    fun toRiskStateCode(value: Int?): RiskState? = value?.toRiskState()
-
-    @TypeConverter
-    fun fromRiskStateCode(code: RiskState?): Int? = code?.toCode()
-
-    private fun RiskState.toCode() = when (this) {
-        RiskState.CALCULATION_FAILED -> 0
-        RiskState.LOW_RISK -> 1
-        RiskState.INCREASED_RISK -> 2
-    }
-
-    private fun Int.toRiskState() = when (this) {
-        0 -> RiskState.CALCULATION_FAILED
-        1 -> RiskState.LOW_RISK
-        2 -> RiskState.INCREASED_RISK
-        else -> null
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PtRiskLevelResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PtRiskLevelResult.kt
index 1fa338aab..62b25b760 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PtRiskLevelResult.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PtRiskLevelResult.kt
@@ -1,5 +1,6 @@
 package de.rki.coronawarnapp.presencetracing.risk
 
+import de.rki.coronawarnapp.presencetracing.risk.calculation.PresenceTracingDayRisk
 import de.rki.coronawarnapp.risk.RiskState
 import org.joda.time.Instant
 import org.joda.time.LocalDate
@@ -7,6 +8,7 @@ import org.joda.time.LocalDate
 data class PtRiskLevelResult(
     val calculatedAt: Instant,
     val riskState: RiskState,
+    // only available for the last calculation if successful, otherwise null
     val presenceTracingDayRisk: List<PresenceTracingDayRisk>? = null
 ) {
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/TraceLocationCheckInRisk.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/TraceLocationCheckInRisk.kt
similarity index 62%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/TraceLocationCheckInRisk.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/TraceLocationCheckInRisk.kt
index a43306755..a9d2d3d01 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/TraceLocationCheckInRisk.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/TraceLocationCheckInRisk.kt
@@ -1,5 +1,6 @@
-package de.rki.coronawarnapp.risk
+package de.rki.coronawarnapp.presencetracing.risk
 
+import de.rki.coronawarnapp.risk.RiskState
 import org.joda.time.LocalDate
 
 interface TraceLocationCheckInRisk {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/CheckInWarningMatcher.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/CheckInWarningMatcher.kt
new file mode 100644
index 000000000..8c318df97
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/CheckInWarningMatcher.kt
@@ -0,0 +1,143 @@
+package de.rki.coronawarnapp.presencetracing.risk.calculation
+
+import androidx.annotation.VisibleForTesting
+import de.rki.coronawarnapp.eventregistration.checkins.CheckIn
+import de.rki.coronawarnapp.eventregistration.checkins.split.splitByMidnightUTC
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningPackage
+import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.toOkioByteString
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.withContext
+import org.joda.time.Instant
+import timber.log.Timber
+import java.lang.reflect.Modifier.PRIVATE
+import javax.inject.Inject
+import kotlin.coroutines.CoroutineContext
+
+class CheckInWarningMatcher @Inject constructor(
+    private val dispatcherProvider: DispatcherProvider
+) {
+    suspend fun process(
+        checkIns: List<CheckIn>,
+        warningPackages: List<TraceWarningPackage>
+    ): Result {
+        val splitCheckIns = checkIns.flatMap { it.splitByMidnightUTC() }
+
+        val matchLists: List<List<MatchesPerPackage>?> = runMatchingLaunchers(
+            splitCheckIns,
+            warningPackages,
+            dispatcherProvider.IO
+        )
+
+        val successful = if (matchLists.contains(null)) {
+            Timber.e("Calculation partially failed.")
+            false
+        } else {
+            Timber.d("Matching was successful.")
+            true
+        }
+
+        return Result(
+            successful = successful,
+            processedPackages = matchLists.filterNotNull().flatten()
+        )
+    }
+
+    data class Result(
+        val successful: Boolean,
+        val processedPackages: Collection<MatchesPerPackage> = emptyList(),
+    )
+
+    @VisibleForTesting(otherwise = PRIVATE)
+    internal suspend fun runMatchingLaunchers(
+        checkIns: List<CheckIn>,
+        warningPackages: List<TraceWarningPackage>,
+        coroutineContext: CoroutineContext
+    ): List<List<MatchesPerPackage>?> {
+
+        val launcher: CoroutineScope.(
+            List<CheckIn>,
+            List<TraceWarningPackage>
+        ) -> Deferred<List<MatchesPerPackage>?> = { list, packageChunk ->
+            async {
+                try {
+                    packageChunk.map {
+                        val overlaps = findMatches(list, it)
+                        Timber.d("%d overlaps for %s", overlaps.size, it.packageId)
+                        MatchesPerPackage(warningPackage = it, overlaps = overlaps)
+                    }
+                } catch (e: Throwable) {
+                    Timber.e(e, "Failed to process packages $packageChunk")
+                    null
+                }
+            }
+        }
+
+        // at most 4 parallel processes
+        val chunkSize = (checkIns.size / 4) + 1
+
+        return warningPackages.chunked(chunkSize).map { packageChunk ->
+            withContext(context = coroutineContext) {
+                launcher(checkIns, packageChunk)
+            }
+        }.awaitAll()
+    }
+
+    data class MatchesPerPackage(
+        val warningPackage: TraceWarningPackage,
+        val overlaps: List<CheckInWarningOverlap>,
+    )
+}
+
+@VisibleForTesting(otherwise = PRIVATE)
+internal suspend fun findMatches(
+    checkIns: List<CheckIn>,
+    warningPackage: TraceWarningPackage
+): List<CheckInWarningOverlap> {
+    return warningPackage
+        .extractWarnings()
+        .flatMap { warning ->
+            checkIns
+                .mapNotNull { checkIn ->
+                    checkIn.calculateOverlap(warning, warningPackage.packageId).also { overlap ->
+                        if (overlap == null) {
+                            Timber.v("No match found for $checkIn and $warning")
+                        } else {
+                            Timber.w("Overlap was found $overlap")
+                        }
+                    }
+                }
+        }
+}
+
+@VisibleForTesting(otherwise = PRIVATE)
+internal fun CheckIn.calculateOverlap(
+    warning: TraceWarning.TraceTimeIntervalWarning,
+    traceWarningPackageId: String
+): CheckInWarningOverlap? {
+    if (warning.locationIdHash.toOkioByteString() != traceLocationIdHash) return null
+
+    val warningStartMillis = warning.startIntervalNumber.tenMinIntervalToMillis()
+    val warningEndMillis = (warning.startIntervalNumber + warning.period).tenMinIntervalToMillis()
+
+    val overlapStartMillis = kotlin.math.max(checkInStart.millis, warningStartMillis)
+    val overlapEndMillis = kotlin.math.min(checkInEnd.millis, warningEndMillis)
+    val overlapMillis = overlapEndMillis - overlapStartMillis
+
+    if (overlapMillis <= 0) {
+        Timber.i("No overlap (%dms) with match %s (%s)", overlapMillis, description, traceLocationIdHash)
+        return null
+    }
+
+    return CheckInWarningOverlap(
+        checkInId = id,
+        transmissionRiskLevel = warning.transmissionRiskLevel,
+        traceWarningPackageId = traceWarningPackageId,
+        startTime = Instant.ofEpochMilli(overlapStartMillis),
+        endTime = Instant.ofEpochMilli(overlapEndMillis)
+    )
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingConversions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingConversions.kt
similarity index 73%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingConversions.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingConversions.kt
index 1d5c98667..4e529779d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingConversions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingConversions.kt
@@ -1,12 +1,11 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.calculation
 
 import de.rki.coronawarnapp.risk.RiskState
 import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel
+import java.util.concurrent.TimeUnit
 
 // converts number of 10min intervals into milliseconds
-internal fun Int.tenMinIntervalToMillis() = this * MILLIS_IN_MIN
-
-private const val MILLIS_IN_MIN = 600L * 1000L
+internal fun Int.tenMinIntervalToMillis() = this * TimeUnit.MINUTES.toMillis(10L)
 
 fun RiskLevel.mapToRiskState(): RiskState {
     return when (this) {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskCalculator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskCalculator.kt
similarity index 97%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskCalculator.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskCalculator.kt
index 1c2bd002f..568273ff1 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskCalculator.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskCalculator.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.calculation
 
 import de.rki.coronawarnapp.risk.RiskState
 import javax.inject.Inject
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskMapper.kt
similarity index 97%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskMapper.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskMapper.kt
index 6558491ca..1cebd0aec 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskMapper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskMapper.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.calculation
 
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.appconfig.PresenceTracingRiskCalculationParamContainer
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskModel.kt
similarity index 90%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskModel.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskModel.kt
index 91591e74a..4fc9b892d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskModel.kt
@@ -1,7 +1,7 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.calculation
 
+import de.rki.coronawarnapp.presencetracing.risk.TraceLocationCheckInRisk
 import de.rki.coronawarnapp.risk.RiskState
-import de.rki.coronawarnapp.risk.TraceLocationCheckInRisk
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc
 import org.joda.time.DateTimeConstants
 import org.joda.time.Duration
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningTask.kt
new file mode 100644
index 000000000..e0d9556af
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningTask.kt
@@ -0,0 +1,158 @@
+package de.rki.coronawarnapp.presencetracing.risk.execution
+
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.bugreporting.reportProblem
+import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository
+import de.rki.coronawarnapp.exception.ExceptionCategory
+import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.presencetracing.risk.calculation.CheckInWarningMatcher
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.presencetracing.warning.download.TraceWarningPackageSyncTool
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningRepository
+import de.rki.coronawarnapp.task.Task
+import de.rki.coronawarnapp.task.TaskCancellationException
+import de.rki.coronawarnapp.task.TaskFactory
+import de.rki.coronawarnapp.task.common.DefaultProgress
+import de.rki.coronawarnapp.util.TimeStamper
+import kotlinx.coroutines.channels.ConflatedBroadcastChannel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.firstOrNull
+import org.joda.time.Duration
+import org.joda.time.Instant
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Provider
+
+class PresenceTracingWarningTask @Inject constructor(
+    private val timeStamper: TimeStamper,
+    private val syncTool: TraceWarningPackageSyncTool,
+    private val checkInWarningMatcher: CheckInWarningMatcher,
+    private val presenceTracingRiskRepository: PresenceTracingRiskRepository,
+    private val traceWarningRepository: TraceWarningRepository,
+    private val checkInsRepository: CheckInRepository,
+) : Task<DefaultProgress, PresenceTracingWarningTask.Result> {
+
+    private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>()
+    override val progress: Flow<DefaultProgress> = internalProgress.asFlow()
+
+    private var isCanceled = false
+
+    override suspend fun run(arguments: Task.Arguments): Result = try {
+        Timber.d("Running with arguments=%s", arguments)
+
+        try {
+            doWork()
+        } catch (e: Exception) {
+            // We need to reported a failed calculation to update the risk card state
+            presenceTracingRiskRepository.reportCalculation(successful = false)
+            throw e
+        }
+    } catch (error: Exception) {
+        Timber.tag(TAG).e(error)
+        error.report(ExceptionCategory.EXPOSURENOTIFICATION)
+        throw error
+    } finally {
+        Timber.i("Finished (isCanceled=$isCanceled).")
+        internalProgress.close()
+    }
+
+    private suspend fun doWork(): Result {
+        val nowUTC = timeStamper.nowUTC
+
+        Timber.tag(TAG).d("Running package sync.")
+        syncTool.syncPackages()
+
+        checkCancel()
+
+        presenceTracingRiskRepository.deleteStaleData()
+
+        val checkIns = checkInsRepository.allCheckIns.firstOrNull() ?: emptyList()
+        Timber.tag(TAG).d("There are %d check-ins to match against.", checkIns.size)
+
+        if (checkIns.isEmpty()) {
+            Timber.tag(TAG).i("No check-ins available. Deleting all matches.")
+            presenceTracingRiskRepository.deleteAllMatches()
+
+            presenceTracingRiskRepository.reportCalculation(successful = true)
+
+            return Result(calculatedAt = nowUTC)
+        }
+
+        val unprocessedPackages = traceWarningRepository.unprocessedWarningPackages.firstOrNull() ?: emptyList()
+        Timber.tag(TAG).d("There are %d unprocessed warning packages.", unprocessedPackages.size)
+
+        if (unprocessedPackages.isEmpty()) {
+            Timber.tag(TAG).i("No new warning packages available.")
+
+            presenceTracingRiskRepository.reportCalculation(successful = true)
+
+            return Result(calculatedAt = nowUTC)
+        }
+
+        Timber.tag(TAG).d("Running check-in matcher.")
+        val matcherResult = checkInWarningMatcher.process(
+            checkIns = checkIns,
+            warningPackages = unprocessedPackages,
+        )
+        Timber.tag(TAG).i("Check-in matcher result: %s", matcherResult)
+
+        val overlaps = matcherResult.processedPackages.flatMap { it.overlaps }
+        val overlapsDistinct = overlaps.distinct()
+        if (overlaps.size != overlapsDistinct.size) {
+            IllegalArgumentException("Matched overlaps are not distinct").also {
+                it.reportProblem(TAG, "CheckInWarningMatcher results are not distinct.")
+            }
+        }
+
+        // Partial processing: if calculation was not successful, but some packages were processed, we still save them
+        presenceTracingRiskRepository.reportCalculation(
+            successful = matcherResult.successful,
+            overlaps = overlapsDistinct,
+        )
+
+        // markPackagesProcessed only after reportCalculation, if there is an exception, then we can process again.
+        traceWarningRepository.markPackagesProcessed(
+            matcherResult.processedPackages.map { it.warningPackage.packageId }
+        )
+
+        return Result(calculatedAt = nowUTC)
+    }
+
+    private fun checkCancel() {
+        if (isCanceled) throw TaskCancellationException()
+    }
+
+    override suspend fun cancel() {
+        Timber.w("cancel() called.")
+        isCanceled = true
+    }
+
+    data class Result(
+        val calculatedAt: Instant
+    ) : Task.Result
+
+    data class Config(
+        override val executionTimeout: Duration = Duration.standardMinutes(9),
+        override val collisionBehavior: TaskFactory.Config.CollisionBehavior =
+            TaskFactory.Config.CollisionBehavior.SKIP_IF_SIBLING_RUNNING
+    ) : TaskFactory.Config
+
+    class Factory @Inject constructor(
+        private val taskByDagger: Provider<PresenceTracingWarningTask>,
+        private val appConfigProvider: AppConfigProvider
+    ) : TaskFactory<DefaultProgress, Task.Result> {
+
+        override suspend fun createConfig(): TaskFactory.Config = Config(
+            executionTimeout = appConfigProvider.getAppConfig().overallDownloadTimeout
+        )
+
+        override val taskProvider: () -> Task<DefaultProgress, Task.Result> = {
+            taskByDagger.get()
+        }
+    }
+
+    companion object {
+        private const val TAG = "TracingWarningTask"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorkBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorkBuilder.kt
new file mode 100644
index 000000000..1479650a5
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorkBuilder.kt
@@ -0,0 +1,37 @@
+package de.rki.coronawarnapp.presencetracing.risk.execution
+
+import androidx.work.BackoffPolicy
+import androidx.work.Constraints
+import androidx.work.NetworkType
+import androidx.work.PeriodicWorkRequest
+import androidx.work.PeriodicWorkRequestBuilder
+import dagger.Reusable
+import de.rki.coronawarnapp.worker.BackgroundConstants
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+@Reusable
+class PresenceTracingWarningWorkBuilder @Inject constructor() {
+
+    fun createPeriodicWorkRequest(): PeriodicWorkRequest =
+        PeriodicWorkRequestBuilder<PresenceTracingWarningWorker>(
+            60,
+            TimeUnit.MINUTES
+        )
+            .setInitialDelay(
+                BackgroundConstants.KIND_DELAY,
+                TimeUnit.MINUTES
+            )
+            .setBackoffCriteria(
+                BackoffPolicy.EXPONENTIAL,
+                BackgroundConstants.BACKOFF_INITIAL_DELAY,
+                TimeUnit.MINUTES
+            )
+            .setConstraints(buildConstraints())
+            .build()
+
+    private fun buildConstraints() =
+        Constraints.Builder()
+            .setRequiredNetworkType(NetworkType.CONNECTED)
+            .build()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorker.kt
new file mode 100644
index 000000000..300cab25a
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorker.kt
@@ -0,0 +1,52 @@
+package de.rki.coronawarnapp.presencetracing.risk.execution
+
+import android.content.Context
+import androidx.work.CoroutineWorker
+import androidx.work.WorkerParameters
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import de.rki.coronawarnapp.bugreporting.reportProblem
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
+import de.rki.coronawarnapp.task.submitBlocking
+import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
+import timber.log.Timber
+
+class PresenceTracingWarningWorker @AssistedInject constructor(
+    @Assisted val context: Context,
+    @Assisted workerParams: WorkerParameters,
+    private val taskController: TaskController
+) : CoroutineWorker(context, workerParams) {
+
+    override suspend fun doWork(): Result = try {
+        Timber.tag(TAG).v("$id: doWork() started. Run attempt: $runAttemptCount")
+
+        val taskState = taskController.submitBlocking(
+            DefaultTaskRequest(PresenceTracingWarningTask::class, originTag = TAG)
+        )
+
+        when {
+            taskState.isSuccessful -> {
+                Timber.tag(TAG).d("$id: PresenceTracingWarningTask finished successfully.")
+                Result.success()
+            }
+            else -> {
+                taskState.error?.let {
+                    Timber.tag(TAG).w(it, "$id: Error during PresenceTracingWarningTask.")
+                }
+                Result.retry()
+            }
+        }
+    } catch (e: Exception) {
+        e.reportProblem(TAG, "PresenceTracingWarningTask failed exceptionally, will retry.")
+        Result.retry()
+    }
+
+    @AssistedFactory
+    interface Factory : InjectedWorkerFactory<PresenceTracingWarningWorker>
+
+    companion object {
+        private val TAG = PresenceTracingWarningWorker::class.java.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/storage/PresenceTracingRiskDatabase.kt
similarity index 85%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskDatabase.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/storage/PresenceTracingRiskDatabase.kt
index 2d39bd5ea..b80b28fbf 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskDatabase.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/storage/PresenceTracingRiskDatabase.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.storage
 
 import android.content.Context
 import androidx.room.Database
@@ -27,6 +27,8 @@ abstract class PresenceTracingRiskDatabase : RoomDatabase() {
             .databaseBuilder(context, PresenceTracingRiskDatabase::class.java, DATABASE_NAME)
             .build()
     }
-}
 
-private const val DATABASE_NAME = "PresenceTracingRisk_db"
+    companion object {
+        private const val DATABASE_NAME = "PresenceTracingRisk_db"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/storage/PresenceTracingRiskRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/storage/PresenceTracingRiskRepository.kt
new file mode 100644
index 000000000..daf5c60bc
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/risk/storage/PresenceTracingRiskRepository.kt
@@ -0,0 +1,283 @@
+package de.rki.coronawarnapp.presencetracing.risk.storage
+
+import androidx.room.ColumnInfo
+import androidx.room.Dao
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy.REPLACE
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.TypeConverter
+import de.rki.coronawarnapp.eventregistration.storage.entity.TraceLocationCheckInEntity
+import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
+import de.rki.coronawarnapp.presencetracing.risk.TraceLocationCheckInRisk
+import de.rki.coronawarnapp.presencetracing.risk.calculation.CheckInWarningOverlap
+import de.rki.coronawarnapp.presencetracing.risk.calculation.PresenceTracingDayRisk
+import de.rki.coronawarnapp.presencetracing.risk.calculation.PresenceTracingRiskCalculator
+import de.rki.coronawarnapp.risk.RiskState
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc
+import de.rki.coronawarnapp.util.TimeStamper
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import org.joda.time.Days
+import org.joda.time.Instant
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class PresenceTracingRiskRepository @Inject constructor(
+    private val presenceTracingRiskCalculator: PresenceTracingRiskCalculator,
+    private val databaseFactory: PresenceTracingRiskDatabase.Factory,
+    private val timeStamper: TimeStamper,
+) {
+
+    private val database by lazy {
+        databaseFactory.create()
+    }
+
+    private val traceTimeIntervalMatchDao by lazy {
+        database.traceTimeIntervalMatchDao()
+    }
+
+    private val riskLevelResultDao by lazy {
+        database.presenceTracingRiskLevelResultDao()
+    }
+
+    private val matchesOfLast14DaysPlusToday = traceTimeIntervalMatchDao.allMatches()
+        .map { timeIntervalMatchEntities ->
+            timeIntervalMatchEntities
+                .map { it.toCheckInWarningOverlap() }
+                .filter { it.localDateUtc.isAfter(fifteenDaysAgo.toLocalDateUtc()) }
+        }
+
+    val checkInWarningOverlaps: Flow<List<CheckInWarningOverlap>> =
+        traceTimeIntervalMatchDao.allMatches().map { matchEntities ->
+            matchEntities.map {
+                it.toCheckInWarningOverlap()
+            }
+        }
+
+    private val normalizedTimeOfLast14DaysPlusToday = matchesOfLast14DaysPlusToday.map {
+        presenceTracingRiskCalculator.calculateNormalizedTime(it)
+    }
+
+    private val fifteenDaysAgo: Instant
+        get() = timeStamper.nowUTC.minus(Days.days(15).toStandardDuration())
+
+    val traceLocationCheckInRiskStates: Flow<List<TraceLocationCheckInRisk>> =
+        normalizedTimeOfLast14DaysPlusToday.map {
+            presenceTracingRiskCalculator.calculateCheckInRiskPerDay(it)
+        }
+
+    val presenceTracingDayRisk: Flow<List<PresenceTracingDayRisk>> =
+        normalizedTimeOfLast14DaysPlusToday.map {
+            presenceTracingRiskCalculator.calculateAggregatedRiskPerDay(it)
+        }
+
+    /**
+     * We delete warning packages after processing, we need to store the latest matches independent of success state
+     * For a future update we should look into partial processing.
+     */
+    internal suspend fun reportCalculation(
+        successful: Boolean,
+        overlaps: List<CheckInWarningOverlap> = emptyList()
+    ) {
+        Timber.v("reportCalculation(successful=%b, overlaps=%s)", successful, overlaps)
+
+        val nowUTC = timeStamper.nowUTC
+
+        // delete stale matches from new packages, old matches are superseeded
+        overlaps.map { it.traceWarningPackageId }.forEach {
+            traceTimeIntervalMatchDao.deleteMatchesForPackage(it)
+        }
+
+        if (overlaps.isNotEmpty()) {
+            traceTimeIntervalMatchDao.insert(overlaps.map { it.toTraceTimeIntervalMatchEntity() })
+        }
+
+        val result = if (successful) {
+            val last14daysPlusToday = normalizedTimeOfLast14DaysPlusToday.first()
+            val risk = presenceTracingRiskCalculator.calculateTotalRisk(last14daysPlusToday)
+            PtRiskLevelResult(nowUTC, risk)
+        } else {
+            PtRiskLevelResult(nowUTC, RiskState.CALCULATION_FAILED)
+        }
+        addResult(result)
+    }
+
+    internal suspend fun deleteStaleData() {
+        Timber.d("deleteStaleData()")
+        traceTimeIntervalMatchDao.deleteOlderThan(fifteenDaysAgo.millis)
+        riskLevelResultDao.deleteOlderThan(fifteenDaysAgo.millis)
+    }
+
+    suspend fun deleteAllMatches() {
+        Timber.d("deleteAllMatches()")
+        traceTimeIntervalMatchDao.deleteAll()
+    }
+
+    fun latestEntries(limit: Int) = riskLevelResultDao.latestEntries(limit).map { list ->
+        var lastSuccessfulFound = false
+        list.sortedByDescending {
+            it.calculatedAtMillis
+        }
+            .map { entity ->
+                if (!lastSuccessfulFound && entity.riskState != RiskState.CALCULATION_FAILED) {
+                    lastSuccessfulFound = true
+                    // add risk per day to the last successful result
+                    entity.toCheckInWarningOverlap(presenceTracingDayRisk.first())
+                } else {
+                    entity.toCheckInWarningOverlap(null)
+                }
+            }
+    }
+
+    fun allEntries() = riskLevelResultDao.allEntries().map { list ->
+        var lastSuccessfulFound = false
+        list.sortedByDescending {
+            it.calculatedAtMillis
+        }
+            .map { entity ->
+                if (!lastSuccessfulFound && entity.riskState != RiskState.CALCULATION_FAILED) {
+                    lastSuccessfulFound = true
+                    // add risk per day to the last successful result
+                    entity.toCheckInWarningOverlap(presenceTracingDayRisk.first())
+                } else {
+                    entity.toCheckInWarningOverlap(null)
+                }
+            }
+    }
+
+    private fun addResult(result: PtRiskLevelResult) {
+        Timber.i("Saving risk calculation from ${result.calculatedAt} with result ${result.riskState}.")
+        riskLevelResultDao.insert(result.toTraceTimeIntervalMatchEntity())
+    }
+
+    suspend fun clearAllTables() {
+        traceTimeIntervalMatchDao.deleteAll()
+        riskLevelResultDao.deleteAll()
+    }
+}
+
+/*
+* Stores matches from the last successful execution
+* */
+@Dao
+interface TraceTimeIntervalMatchDao {
+
+    @Query("SELECT * FROM TraceTimeIntervalMatchEntity")
+    fun allMatches(): Flow<List<TraceTimeIntervalMatchEntity>>
+
+    @Query("DELETE FROM TraceTimeIntervalMatchEntity")
+    suspend fun deleteAll()
+
+    @Query("DELETE FROM TraceTimeIntervalMatchEntity WHERE endTimeMillis < :endTimeMillis")
+    suspend fun deleteOlderThan(endTimeMillis: Long)
+
+    @Query("DELETE FROM TraceTimeIntervalMatchEntity WHERE traceWarningPackageId = :warningPackageId")
+    suspend fun deleteMatchesForPackage(warningPackageId: String)
+
+    @Insert
+    suspend fun insert(entities: List<TraceTimeIntervalMatchEntity>)
+}
+
+@Entity
+data class TraceTimeIntervalMatchEntity(
+    @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") val id: Long? = null,
+    @ForeignKey(
+        entity = TraceLocationCheckInEntity::class,
+        parentColumns = ["id"],
+        childColumns = ["checkInId"],
+        onDelete = ForeignKey.CASCADE
+    )
+    @ColumnInfo(name = "checkInId") val checkInId: Long,
+    @ColumnInfo(name = "traceWarningPackageId") val traceWarningPackageId: String,
+    @ColumnInfo(name = "transmissionRiskLevel") val transmissionRiskLevel: Int,
+    @ColumnInfo(name = "startTimeMillis") val startTimeMillis: Long,
+    @ColumnInfo(name = "endTimeMillis") val endTimeMillis: Long
+)
+
+internal fun CheckInWarningOverlap.toTraceTimeIntervalMatchEntity() = TraceTimeIntervalMatchEntity(
+    checkInId = checkInId,
+    traceWarningPackageId = traceWarningPackageId,
+    transmissionRiskLevel = transmissionRiskLevel,
+    startTimeMillis = startTime.millis,
+    endTimeMillis = endTime.millis
+)
+
+internal fun TraceTimeIntervalMatchEntity.toCheckInWarningOverlap() = CheckInWarningOverlap(
+    checkInId = checkInId,
+    traceWarningPackageId = traceWarningPackageId,
+    transmissionRiskLevel = transmissionRiskLevel,
+    startTime = Instant.ofEpochMilli(startTimeMillis),
+    endTime = Instant.ofEpochMilli(endTimeMillis)
+)
+
+@Suppress("MaxLineLength")
+@Dao
+interface PresenceTracingRiskLevelResultDao {
+
+    @Query("SELECT * FROM PresenceTracingRiskLevelResultEntity ORDER BY calculatedAtMillis DESC LIMIT :limit")
+    fun latestEntries(limit: Int): Flow<List<PresenceTracingRiskLevelResultEntity>>
+
+    @Query("SELECT * FROM PresenceTracingRiskLevelResultEntity")
+    fun allEntries(): Flow<List<PresenceTracingRiskLevelResultEntity>>
+
+    @Insert(onConflict = REPLACE)
+    fun insert(entity: PresenceTracingRiskLevelResultEntity)
+
+    @Query("DELETE FROM PresenceTracingRiskLevelResultEntity WHERE calculatedAtMillis < :calculatedAtMillis")
+    suspend fun deleteOlderThan(calculatedAtMillis: Long)
+
+    @Query("DELETE FROM PresenceTracingRiskLevelResultEntity")
+    suspend fun deleteAll()
+}
+
+@Entity
+data class PresenceTracingRiskLevelResultEntity(
+    @PrimaryKey @ColumnInfo(name = "calculatedAtMillis") val calculatedAtMillis: Long,
+    @ColumnInfo(name = "riskStateCode") val riskState: RiskState
+)
+
+private fun PresenceTracingRiskLevelResultEntity.toCheckInWarningOverlap(
+    presenceTracingDayRisk: List<PresenceTracingDayRisk>?
+) = PtRiskLevelResult(
+    calculatedAt = Instant.ofEpochMilli((calculatedAtMillis)),
+    riskState = riskState,
+    presenceTracingDayRisk = presenceTracingDayRisk
+)
+
+private fun PtRiskLevelResult.toTraceTimeIntervalMatchEntity() = PresenceTracingRiskLevelResultEntity(
+    calculatedAtMillis = calculatedAt.millis,
+    riskState = riskState
+)
+
+class RiskStateConverter {
+    @TypeConverter
+    fun toRiskStateCode(value: Int?): RiskState? = value?.toRiskState()
+
+    @TypeConverter
+    fun fromRiskStateCode(code: RiskState?): Int? = code?.toCode()
+
+    private fun RiskState.toCode() = when (this) {
+        RiskState.CALCULATION_FAILED -> CALCULATION_FAILED
+        RiskState.LOW_RISK -> LOW_RISK
+        RiskState.INCREASED_RISK -> INCREASED_RISK
+    }
+
+    private fun Int.toRiskState() = when (this) {
+        CALCULATION_FAILED -> RiskState.CALCULATION_FAILED
+        LOW_RISK -> RiskState.LOW_RISK
+        INCREASED_RISK -> RiskState.INCREASED_RISK
+        else -> null
+    }
+
+    companion object {
+        private const val CALCULATION_FAILED = 0
+        private const val LOW_RISK = 1
+        private const val INCREASED_RISK = 2
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/PresenceTracingWarningModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/PresenceTracingWarningModule.kt
new file mode 100644
index 000000000..49ced6787
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/PresenceTracingWarningModule.kt
@@ -0,0 +1,43 @@
+package de.rki.coronawarnapp.presencetracing.warning
+
+import dagger.Module
+import dagger.Provides
+import dagger.multibindings.IntoMap
+import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient
+import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl
+import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningTask
+import de.rki.coronawarnapp.presencetracing.warning.download.server.TraceWarningApiV1
+import de.rki.coronawarnapp.task.Task
+import de.rki.coronawarnapp.task.TaskFactory
+import de.rki.coronawarnapp.task.TaskTypeKey
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Singleton
+
+@Module
+class PresenceTracingWarningModule {
+
+    @Provides
+    @IntoMap
+    @TaskTypeKey(PresenceTracingWarningTask::class)
+    fun taskFactory(
+        factory: PresenceTracingWarningTask.Factory
+    ): TaskFactory<out Task.Progress, out Task.Result> = factory
+
+    @Singleton
+    @Provides
+    fun api(
+        @DownloadCDNHttpClient client: OkHttpClient,
+        @DownloadCDNServerUrl url: String,
+        gsonConverterFactory: GsonConverterFactory,
+    ): TraceWarningApiV1 {
+
+        return Retrofit.Builder()
+            .client(client)
+            .baseUrl(url)
+            .addConverterFactory(gsonConverterFactory)
+            .build()
+            .create(TraceWarningApiV1::class.java)
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageDownloader.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageDownloader.kt
new file mode 100644
index 000000000..581df13ed
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageDownloader.kt
@@ -0,0 +1,164 @@
+package de.rki.coronawarnapp.presencetracing.warning.download
+
+import dagger.Reusable
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.presencetracing.warning.download.server.TraceWarningServer
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningPackageMetadata
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningRepository
+import de.rki.coronawarnapp.util.HourInterval
+import de.rki.coronawarnapp.util.ZipHelper.readIntoMap
+import de.rki.coronawarnapp.util.ZipHelper.unzip
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.security.SignatureValidation
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.withContext
+import kotlinx.coroutines.withTimeout
+import org.joda.time.Duration
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class TraceWarningPackageDownloader @Inject constructor(
+    private val repository: TraceWarningRepository,
+    private val dispatcherProvider: DispatcherProvider,
+    private val server: TraceWarningServer,
+    private val signatureValidation: SignatureValidation,
+) {
+
+    data class DownloadResult(
+        val successful: Boolean,
+        val newPackages: Collection<TraceWarningPackageMetadata>
+    ) {
+        override fun toString(): String {
+            return "DownloadResult(successful=$successful, newPackages.size=${newPackages.size})"
+        }
+    }
+
+    suspend fun launchDownloads(
+        location: LocationCode,
+        hourIntervals: List<HourInterval>,
+        downloadTimeout: Duration
+    ): DownloadResult {
+        val launcher: CoroutineScope.(HourInterval) -> Deferred<TraceWarningPackageMetadata?> = { hourInterval ->
+            async {
+                val metadata = repository.createMetadata(location, hourInterval)
+                withTimeout(downloadTimeout.millis) {
+                    downloadPackageForMetaData(metadata)
+                }
+            }
+        }
+
+        Timber.tag(TAG).d("Launching %d downloads.", hourIntervals.size)
+
+        val launchedDownloads: Collection<Deferred<TraceWarningPackageMetadata?>> =
+            hourIntervals.map { warningPackageId ->
+                withContext(context = dispatcherProvider.IO) {
+                    launcher(warningPackageId)
+                }
+            }
+
+        val successfulDownloads = launchedDownloads.awaitAll()
+            .filterNotNull()
+            .also {
+                Timber.tag(TAG).v("Downloaded keyfile: %s", it.joinToString("\n"))
+            }
+        Timber.tag(TAG).i("Download success: ${successfulDownloads.size}/${launchedDownloads.size}")
+
+        return DownloadResult(
+            successful = launchedDownloads.size == successfulDownloads.size,
+            newPackages = successfulDownloads
+        )
+    }
+
+    private suspend fun downloadPackageForMetaData(
+        metaData: TraceWarningPackageMetadata
+    ): TraceWarningPackageMetadata? = try {
+        val downloadInfo = server.downloadPackage(
+            location = metaData.location,
+            hourInterval = metaData.hourInterval
+        )
+
+        if (!downloadInfo.isEmptyPkg) {
+            val fileMap = downloadInfo.readBody().unzip().readIntoMap()
+            val rawProtoBuf = getValidatedBinary(metaData, fileMap)
+            writeProtoBufToFile(metaData, rawProtoBuf)
+        } else {
+            Timber.tag(TAG).v("Empty package for %s", metaData)
+        }
+
+        Timber.tag(TAG).v("Download finished: %s -> %s", metaData, downloadInfo)
+
+        val eTag = requireNotNull(downloadInfo.etag) { "Server provided no ETAG!" }
+
+        repository.markDownloadComplete(metaData, eTag, downloadInfo.isEmptyPkg)
+    } catch (e: Exception) {
+        Timber.tag(TAG).e(e, "Download failed: %s", metaData)
+        null
+    }
+
+    private fun writeProtoBufToFile(
+        metaData: TraceWarningPackageMetadata,
+        rawProtoBuf: ByteArray,
+    ) {
+        if (rawProtoBuf.isEmpty()) {
+            Timber.tag(TAG).d("rawProtoBuf was empty for  %s", metaData.packageId)
+            return
+        }
+
+        val saveTo = repository.getPathForMetaData(metaData)
+        if (saveTo.exists()) {
+            Timber.tag(TAG).w("File existed, overwriting: %s", saveTo)
+            if (saveTo.delete()) {
+                Timber.tag(TAG).e("%s exists, but can't be deleted.", saveTo)
+            }
+        }
+        try {
+            saveTo.parentFile?.let {
+                if (!it.exists() && it.mkdir()) {
+                    Timber.w("Had to create parent dir: %s", it)
+                }
+            }
+            saveTo.writeBytes(rawProtoBuf)
+        } catch (e: Exception) {
+            Timber.tag(TAG).e(e, "Failed to write %s to %s", metaData, saveTo)
+            saveTo.delete()
+            throw e
+        }
+        Timber.tag(TAG).v("%d bytes written to %s.", rawProtoBuf.size, saveTo)
+    }
+
+    private fun getValidatedBinary(
+        metaData: TraceWarningPackageMetadata,
+        fileMap: Map<String, ByteArray>
+    ): ByteArray {
+        val signature = fileMap[EXPORT_SIGNATURE_NAME] ?: throw TraceWarningPackageValidationException(
+            message = "Signature was null for ${metaData.packageId}(${metaData.eTag})."
+        )
+
+        val binary = fileMap[EXPORT_BINARY_NAME] ?: throw TraceWarningPackageValidationException(
+            message = "Binary was null for ${metaData.packageId}(${metaData.eTag})."
+        )
+
+        val hasValidSignature = signatureValidation.hasValidSignature(
+            binary,
+            SignatureValidation.parseTEKStyleSignature(signature)
+        )
+
+        if (!hasValidSignature) {
+            throw TraceWarningPackageValidationException(
+                message = "Signature didn't match for ${metaData.packageId}(${metaData.eTag})."
+            )
+        }
+
+        return binary
+    }
+
+    companion object {
+        private const val TAG = "TraceWarningDownloader"
+        private const val EXPORT_BINARY_NAME = "export.bin"
+        private const val EXPORT_SIGNATURE_NAME = "export.sig"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageSyncTool.kt
new file mode 100644
index 000000000..ea2d87f22
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageSyncTool.kt
@@ -0,0 +1,178 @@
+package de.rki.coronawarnapp.presencetracing.warning.download
+
+import androidx.annotation.VisibleForTesting
+import dagger.Reusable
+import de.rki.coronawarnapp.appconfig.AppConfigProvider
+import de.rki.coronawarnapp.appconfig.KeyDownloadConfig
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository
+import de.rki.coronawarnapp.presencetracing.warning.download.server.TraceWarningApiV1
+import de.rki.coronawarnapp.presencetracing.warning.download.server.TraceWarningServer
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningPackageMetadata
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningRepository
+import de.rki.coronawarnapp.storage.DeviceStorage
+import de.rki.coronawarnapp.util.HourInterval
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.deriveHourInterval
+import de.rki.coronawarnapp.util.debug.measureTime
+import kotlinx.coroutines.flow.first
+import timber.log.Timber
+import javax.inject.Inject
+import kotlin.math.max
+
+@Reusable
+class TraceWarningPackageSyncTool @Inject constructor(
+    private val deviceStorage: DeviceStorage,
+    private val server: TraceWarningServer,
+    private val repository: TraceWarningRepository,
+    private val configProvider: AppConfigProvider,
+    private val checkInRepository: CheckInRepository,
+    private val downloader: TraceWarningPackageDownloader
+) {
+
+    suspend fun syncPackages(): SyncResult {
+        repository.cleanMetadata()
+        return measureTime(
+            { Timber.tag(TAG).d("syncPackagesForLocation(DE), took %dms", it) },
+            { syncPackagesForLocation(LocationCode("DE")) }
+        )
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal suspend fun syncPackagesForLocation(location: LocationCode): SyncResult {
+        Timber.tag(TAG).d("syncTraceWarningPackages(location=%s)", location)
+
+        val oldestCheckIn = checkInRepository.allCheckIns.first().minByOrNull { it.checkInStart }.also {
+            Timber.tag(TAG).d("Our oldest check-in is %s", it)
+        }
+
+        if (oldestCheckIn == null) {
+            Timber.tag(TAG).w("There were no checkins, cleaning up package metadata, aborting early.")
+            val metaDataForLocation = repository.getMetaDataForLocation(location)
+            repository.delete(metaDataForLocation)
+            return SyncResult(successful = true)
+        }
+
+        val downloadConfig: KeyDownloadConfig = configProvider.getAppConfig()
+
+        cleanUpRevokedPackages(downloadConfig)
+
+        val intervalDiscovery: TraceWarningApiV1.DiscoveryResult = try {
+            server.getAvailableIds(location)
+        } catch (e: Exception) {
+            Timber.tag(TAG).w(e, "Failed to discover available IDs.")
+            return SyncResult(successful = false)
+        }
+
+        val firstRelevantInterval: HourInterval = max(
+            oldestCheckIn.checkInStart.deriveHourInterval(),
+            intervalDiscovery.oldest
+        )
+
+        cleanUpIrrelevantPackages(location, firstRelevantInterval)
+
+        if (firstRelevantInterval > intervalDiscovery.latest) {
+            Timber.tag(TAG).d("Known server IDs are older then ours newest, aborting early.")
+            return SyncResult(successful = true)
+        }
+
+        val missingHourIntervals = determineIntervalsToDownload(
+            location = location,
+            firstRelevant = oldestCheckIn.checkInStart.deriveHourInterval(),
+            lastRelevant = intervalDiscovery.latest
+        )
+
+        if (missingHourIntervals.isEmpty()) {
+            Timber.tag(TAG).d("There are no missing intervals for %s", location)
+            return SyncResult(successful = true)
+        }
+
+        requireStorageSpaceFor(missingHourIntervals.size)
+
+        val downloadResult = downloader.launchDownloads(
+            location = location,
+            hourIntervals = missingHourIntervals,
+            downloadTimeout = downloadConfig.individualDownloadTimeout
+        )
+        Timber.tag(TAG).i("Download result: %s", downloadResult)
+
+        return SyncResult(
+            successful = downloadResult.successful,
+            newPackages = downloadResult.newPackages,
+        )
+    }
+
+    /**
+     * Returns true if any of our cached keys were revoked
+     */
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal suspend fun cleanUpRevokedPackages(
+        config: KeyDownloadConfig
+    ): List<TraceWarningPackageMetadata> {
+        val revokedKeyPackages = config.revokedTraceWarningPackages
+
+        if (revokedKeyPackages.isEmpty()) {
+            Timber.tag(TAG).d("No revoked key packages to delete.")
+            return emptyList()
+        }
+
+        val badEtags = revokedKeyPackages.map { it.etag }
+        val toDelete = repository.allMetaData.first().filter { badEtags.contains(it.eTag) }
+        Timber.tag(TAG).d("Revoked key packages matched %s", toDelete)
+
+        repository.delete(toDelete)
+
+        return toDelete.also {
+            Timber.tag(TAG).d("Cleaned up TraceWarning ids: %s", it)
+        }
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal suspend fun cleanUpIrrelevantPackages(
+        location: LocationCode,
+        oldestRelevantInterval: HourInterval
+    ): List<TraceWarningPackageMetadata> {
+        val downloaded = repository.getMetaDataForLocation(location)
+        val toDelete = downloaded.filter { it.hourInterval < oldestRelevantInterval }
+        Timber.tag(TAG).d("Removing irrelevant ids older than %d: %s", oldestRelevantInterval, toDelete)
+
+        repository.delete(toDelete)
+
+        return toDelete.also {
+            Timber.tag(TAG).d("Removed irrelevant packages: %s", it)
+        }
+    }
+
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal suspend fun determineIntervalsToDownload(
+        location: LocationCode,
+        firstRelevant: HourInterval,
+        lastRelevant: HourInterval
+    ): List<HourInterval> {
+        val metadatas = repository.getMetaDataForLocation(location)
+
+        return (firstRelevant..lastRelevant).filter { interval ->
+            // If there is no metadata, it's unknown, so we want to download it
+            metadatas.none { it.hourInterval == interval }
+        }
+    }
+
+    private suspend fun requireStorageSpaceFor(size: Int): DeviceStorage.CheckResult {
+        val requiredBytes: Long = APPROX_FILE_SIZE * size
+        Timber.tag(TAG).d("%dB are required for %d files", requiredBytes, size)
+        return deviceStorage.requireSpacePrivateStorage(requiredBytes).also {
+            Timber.tag(TAG).d("Storage check result: %s", it)
+        }
+    }
+
+    data class SyncResult(
+        val successful: Boolean,
+        val newPackages: Collection<TraceWarningPackageMetadata> = emptyList()
+    )
+
+    companion object {
+        private const val TAG = "TraceWarningSyncTool"
+
+        // TODO check size
+        private const val APPROX_FILE_SIZE = 22 * 1024L // ~22KB
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageValidationException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageValidationException.kt
new file mode 100644
index 000000000..b05d7ac34
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageValidationException.kt
@@ -0,0 +1,9 @@
+package de.rki.coronawarnapp.presencetracing.warning.download
+
+import de.rki.coronawarnapp.exception.reporting.ErrorCodes
+import de.rki.coronawarnapp.util.security.InvalidSignatureException
+
+class TraceWarningPackageValidationException(message: String) : InvalidSignatureException(
+    code = ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code,
+    message = message
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningApiV1.kt
new file mode 100644
index 000000000..0c99daef7
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningApiV1.kt
@@ -0,0 +1,31 @@
+package de.rki.coronawarnapp.presencetracing.warning.download.server
+
+import androidx.annotation.Keep
+import com.google.gson.annotations.SerializedName
+import de.rki.coronawarnapp.util.HourInterval
+import okhttp3.ResponseBody
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Path
+import retrofit2.http.Streaming
+
+interface TraceWarningApiV1 {
+
+    @Keep
+    data class DiscoveryResult(
+        @SerializedName("oldest") val oldest: HourInterval,
+        @SerializedName("latest") val latest: HourInterval
+    )
+
+    @GET("/version/v1/twp/country/{region}/hour")
+    suspend fun getWarningPackageIds(
+        @Path("region") region: String
+    ): DiscoveryResult
+
+    @Streaming
+    @GET("/version/v1/twp/country/{region}/hour/{timeId}")
+    suspend fun downloadKeyFileForHour(
+        @Path("region") region: String,
+        @Path("timeId") timeId: Long
+    ): Response<ResponseBody>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningPackageDownload.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningPackageDownload.kt
new file mode 100644
index 000000000..02e5d4448
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningPackageDownload.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.presencetracing.warning.download.server
+
+import okhttp3.ResponseBody
+import retrofit2.Response
+import java.io.InputStream
+
+data class TraceWarningPackageDownload(val response: Response<ResponseBody>) {
+
+    private val headers = response.headers()
+
+    val etag by lazy { headers.values("ETag").singleOrNull() }
+
+    val isEmptyPkg by lazy { headers.values("cwa-empty-pkg").singleOrNull() == "1" }
+
+    fun readBody(): InputStream = requireNotNull(response.body()) { "Response body was null" }.byteStream()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningServer.kt
new file mode 100644
index 000000000..3cc3c8bb4
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/download/server/TraceWarningServer.kt
@@ -0,0 +1,54 @@
+package de.rki.coronawarnapp.presencetracing.warning.download.server
+
+import dagger.Lazy
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.util.HourInterval
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import retrofit2.HttpException
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class TraceWarningServer @Inject constructor(
+    private val traceWarningApi: Lazy<TraceWarningApiV1>
+) {
+
+    private val warningApi: TraceWarningApiV1
+        get() = traceWarningApi.get()
+
+    suspend fun getAvailableIds(
+        location: LocationCode
+    ): TraceWarningApiV1.DiscoveryResult = withContext(Dispatchers.IO) {
+        warningApi.getWarningPackageIds(location.identifier).also {
+            Timber.d("getAvailableIds(location=%s): %s", location, it)
+        }
+    }
+
+    suspend fun downloadPackage(
+        location: LocationCode,
+        hourInterval: HourInterval
+    ): TraceWarningPackageDownload = withContext(Dispatchers.IO) {
+        Timber.tag(TAG).v("downloadPackage(location=%s, hourInterval=%s)", location, hourInterval)
+
+        val response = warningApi.downloadKeyFileForHour(
+            location.identifier,
+            hourInterval
+        )
+
+        val downloadInfo = TraceWarningPackageDownload(response)
+
+        if (response.isSuccessful) {
+            Timber.tag(TAG).v("TraceTimeWarning download available: %s", downloadInfo)
+
+            return@withContext downloadInfo
+        } else {
+            throw HttpException(response)
+        }
+    }
+
+    companion object {
+        private val TAG = TraceWarningServer::class.java.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackage.kt
new file mode 100644
index 000000000..91e655f43
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackage.kt
@@ -0,0 +1,14 @@
+package de.rki.coronawarnapp.presencetracing.warning.storage
+
+import de.rki.coronawarnapp.presencetracing.warning.WarningPackageId
+import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
+
+interface TraceWarningPackage {
+
+    val packageId: WarningPackageId
+
+    /**
+     * May throw an exception if there is an issue with the protobuf
+     */
+    suspend fun extractWarnings(): List<TraceWarning.TraceTimeIntervalWarning>
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageContainer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageContainer.kt
new file mode 100644
index 000000000..e16436f59
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageContainer.kt
@@ -0,0 +1,23 @@
+package de.rki.coronawarnapp.presencetracing.warning.storage
+
+import de.rki.coronawarnapp.presencetracing.warning.WarningPackageId
+import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
+import java.io.File
+
+data class TraceWarningPackageContainer(
+    override val packageId: WarningPackageId,
+    private val packagePath: File,
+) : TraceWarningPackage {
+
+    private val warningPackage by lazy<TraceWarning.TraceWarningPackage> {
+        if (packagePath.exists()) {
+            TraceWarning.TraceWarningPackage.parseFrom(packagePath.readBytes())
+        } else {
+            TraceWarning.TraceWarningPackage.getDefaultInstance()
+        }
+    }
+
+    override suspend fun extractWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
+        return warningPackage.timeIntervalWarningsList
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageDatabase.kt
new file mode 100644
index 000000000..5939360ce
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageDatabase.kt
@@ -0,0 +1,62 @@
+package de.rki.coronawarnapp.presencetracing.warning.storage
+
+import android.content.Context
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import androidx.room.Update
+import de.rki.coronawarnapp.presencetracing.warning.WarningPackageId
+import de.rki.coronawarnapp.util.database.CommonConverters
+import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.flow.Flow
+import javax.inject.Inject
+
+@Dao
+interface TraceWarningPackageDao {
+
+    @Query("SELECT * FROM TraceWarningPackageMetadata")
+    fun getAllMetaData(): Flow<List<TraceWarningPackageMetadata>>
+
+    @Query("SELECT * FROM TraceWarningPackageMetadata WHERE location = :location")
+    suspend fun getAllMetaDataForLocation(location: String): List<TraceWarningPackageMetadata>
+
+    @Query("SELECT * FROM TraceWarningPackageMetadata WHERE id = :packageId")
+    suspend fun get(packageId: WarningPackageId): TraceWarningPackageMetadata?
+
+    @Insert(onConflict = OnConflictStrategy.IGNORE)
+    suspend fun insert(entity: TraceWarningPackageMetadata)
+
+    @Update(entity = TraceWarningPackageMetadata::class)
+    suspend fun updateMetaData(update: TraceWarningPackageMetadata.UpdateDownload)
+
+    @Update(entity = TraceWarningPackageMetadata::class)
+    suspend fun updateMetaData(update: TraceWarningPackageMetadata.UpdateProcessed)
+
+    @Query("DELETE FROM TraceWarningPackageMetadata WHERE id in (:packageIds)")
+    suspend fun deleteByIds(packageIds: List<WarningPackageId>)
+
+    @Query("DELETE FROM TraceWarningPackageMetadata")
+    suspend fun clear()
+}
+
+@Database(
+    entities = [TraceWarningPackageMetadata::class],
+    version = 1,
+    exportSchema = true
+)
+@TypeConverters(CommonConverters::class)
+abstract class TraceWarningDatabase : RoomDatabase() {
+
+    abstract fun traceWarningPackageDao(): TraceWarningPackageDao
+
+    class Factory @Inject constructor(@AppContext private val context: Context) {
+        fun create() = Room
+            .databaseBuilder(context, TraceWarningDatabase::class.java, "TraceWarning_db")
+            .build()
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageMetadata.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageMetadata.kt
new file mode 100644
index 000000000..54725874a
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningPackageMetadata.kt
@@ -0,0 +1,58 @@
+package de.rki.coronawarnapp.presencetracing.warning.storage
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.presencetracing.warning.WarningPackageId
+import de.rki.coronawarnapp.util.HourInterval
+import org.joda.time.Instant
+
+@Entity(tableName = "TraceWarningPackageMetadata")
+data class TraceWarningPackageMetadata(
+    @PrimaryKey @ColumnInfo(name = "id") val packageId: WarningPackageId,
+    @ColumnInfo(name = "createdAt") val createdAt: Instant,
+    @ColumnInfo(name = "location") val location: LocationCode, // i.e. "DE"
+    @ColumnInfo(name = "hourInterval") val hourInterval: HourInterval,
+    @ColumnInfo(name = "eTag") val eTag: String? = null,
+    @ColumnInfo(name = "downloaded") val isDownloaded: Boolean = false,
+    @ColumnInfo(name = "emptyPkg") val isEmptyPkg: Boolean = false,
+    @ColumnInfo(name = "processed") val isProcessed: Boolean = false
+) {
+
+    constructor(
+        location: LocationCode,
+        hourInterval: HourInterval,
+        createdAt: Instant
+    ) : this(
+        packageId = calcluateId(location, hourInterval),
+        location = location,
+        hourInterval = hourInterval,
+        createdAt = createdAt,
+    )
+
+    @Transient
+    val fileName: String = "$packageId.bin"
+
+    companion object {
+        fun calcluateId(
+            location: LocationCode,
+            hourInterval: HourInterval
+        ): WarningPackageId = "${location.identifier}_$hourInterval"
+    }
+
+    @Entity
+    data class UpdateDownload(
+        @PrimaryKey @ColumnInfo(name = "id") val packageId: WarningPackageId,
+        @ColumnInfo(name = "eTag") val eTag: String?,
+        @ColumnInfo(name = "downloaded") val isDownloaded: Boolean,
+        @ColumnInfo(name = "emptyPkg") val isEmptyPkg: Boolean,
+        @ColumnInfo(name = "processed") val isProcessed: Boolean,
+    )
+
+    @Entity
+    data class UpdateProcessed(
+        @PrimaryKey @ColumnInfo(name = "id") val packageId: WarningPackageId,
+        @ColumnInfo(name = "processed") val isProcessed: Boolean,
+    )
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningRepository.kt
new file mode 100644
index 000000000..4af922212
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/TraceWarningRepository.kt
@@ -0,0 +1,182 @@
+package de.rki.coronawarnapp.presencetracing.warning.storage
+
+import android.content.Context
+import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode
+import de.rki.coronawarnapp.presencetracing.warning.WarningPackageId
+import de.rki.coronawarnapp.util.HourInterval
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.di.AppContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import timber.log.Timber
+import java.io.File
+import java.io.IOException
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class TraceWarningRepository @Inject constructor(
+    @AppContext private val context: Context,
+    private val factory: TraceWarningDatabase.Factory,
+    private val timeStamper: TimeStamper
+) {
+    private val database by lazy { factory.create() }
+    private val dao: TraceWarningPackageDao by lazy { database.traceWarningPackageDao() }
+
+    private val storageDir by lazy {
+        File(context.cacheDir, "trace_warning_packages").apply {
+            if (!exists()) {
+                if (mkdirs()) {
+                    Timber.tag(TAG).d("Trace warning package directory created: %s", this)
+                } else {
+                    throw IOException("Trace warning package directory creation failed: $this")
+                }
+            }
+        }
+    }
+
+    val unprocessedWarningPackages: Flow<List<TraceWarningPackage>> = dao.getAllMetaData()
+        .map { metadatas ->
+            Timber.tag(TAG).v("Known packages: ${metadatas.size}")
+            metadatas.filter { !it.isProcessed }
+        }
+        .map { unprocessed ->
+            Timber.tag(TAG).v("Unprocessed packages: ${unprocessed.size}")
+            unprocessed.filter { !it.isEmptyPkg }
+        }
+        .map { metaDatas ->
+            Timber.tag(TAG).v("There are ${metaDatas.size} unprocessed non-empty warning packages.")
+            metaDatas.map { metaData ->
+                TraceWarningPackageContainer(
+                    packageId = metaData.packageId,
+                    packagePath = getPathForMetaData(metaData)
+                )
+            }
+        }
+
+    fun getPathForMetaData(metaData: TraceWarningPackageMetadata): File {
+        return File(storageDir, metaData.fileName)
+    }
+
+    val allMetaData = dao.getAllMetaData()
+
+    suspend fun createMetadata(location: LocationCode, hourInterval: HourInterval): TraceWarningPackageMetadata {
+        val metadata = TraceWarningPackageMetadata(
+            location = location,
+            hourInterval = hourInterval,
+            createdAt = timeStamper.nowUTC
+        )
+        dao.insert(metadata)
+        Timber.tag(TAG).d("Inserted new Metadata: %s", metadata)
+        return metadata
+    }
+
+    suspend fun getMetaDataForLocation(location: LocationCode): List<TraceWarningPackageMetadata> {
+        return dao.getAllMetaDataForLocation(location.identifier)
+    }
+
+    suspend fun markDownloadComplete(
+        metadata: TraceWarningPackageMetadata,
+        eTag: String,
+        isEmptyPkg: Boolean
+    ): TraceWarningPackageMetadata {
+        Timber.tag(TAG).d("markDownloadComplete(metaData=%s, eTag=%s)", metadata, eTag)
+        val update = TraceWarningPackageMetadata.UpdateDownload(
+            packageId = metadata.packageId,
+            eTag = eTag,
+            isDownloaded = true,
+            isProcessed = false,
+            isEmptyPkg = isEmptyPkg,
+        )
+        Timber.tag(TAG).d("Metadata marked as complete: %s", update)
+        dao.updateMetaData(update)
+        return metadata.copy(
+            eTag = eTag,
+            isDownloaded = true,
+            isProcessed = false,
+            isEmptyPkg = isEmptyPkg,
+        )
+    }
+
+    suspend fun markPackagesProcessed(packageIds: List<WarningPackageId>) {
+        Timber.tag(TAG).v("markPackagesProcessed(packageIds=%s)", packageIds)
+
+        packageIds.forEach { packageId ->
+            Timber.tag(TAG).d("markPackageProcessed(packageId=%s)", packageId)
+            val update = TraceWarningPackageMetadata.UpdateProcessed(
+                packageId = packageId,
+                isProcessed = true,
+            )
+            dao.updateMetaData(update)
+
+            dao.get(packageId)?.also {
+                val file = getPathForMetaData(it)
+                if (file.delete()) {
+                    Timber.tag(TAG).v("Deleted processed file: %s", file)
+                }
+            }
+        }
+    }
+
+    suspend fun delete(metadata: List<TraceWarningPackageMetadata>) {
+        Timber.tag(TAG).d("delete(metaData=%s)", metadata.map { it.packageId })
+        dao.deleteByIds(metadata.map { it.packageId })
+        metadata.map { getPathForMetaData(it) }.forEach {
+            if (it.exists()) {
+                if (it.delete()) {
+                    Timber.tag(TAG).d("Delete TraceWarningPackage file.")
+                } else {
+                    Timber.tag(TAG).w("Failed to delete TraceWarningPackage file: %s", it)
+                }
+            }
+        }
+    }
+
+    suspend fun clear() {
+        Timber.tag(TAG).d("clear()")
+        dao.clear()
+
+        if (!storageDir.deleteRecursively()) {
+            Timber.tag(TAG).e("Failed to delete all TraceWarningPackage files.")
+        }
+    }
+
+    suspend fun cleanMetadata() {
+        Timber.tag(TAG).d("cleanMetadata()")
+        val allMetadata = allMetaData.first()
+
+        // Lost files, system deleted cache?
+        run {
+            val shouldHaveFile = allMetadata.filter { it.isDownloaded && !it.isProcessed && !it.isEmptyPkg }
+            val toDelete = shouldHaveFile.filter { !getPathForMetaData(it).exists() }
+            if (toDelete.isNotEmpty()) {
+                Timber.tag(TAG).w("%d Metadata items lost their file", toDelete.size)
+            }
+            delete(toDelete)
+        }
+
+        // Shouldn't have a file, but has one? Gremlins?
+        run {
+            val shouldNotHaveFile = allMetadata.filter { it.isDownloaded && (it.isProcessed || it.isEmptyPkg) }
+            val toDelete = shouldNotHaveFile.filter { getPathForMetaData(it).exists() }
+            if (toDelete.isNotEmpty()) {
+                Timber.tag(TAG).w("%d Metadata items have unexpected files", toDelete.size)
+            }
+            delete(toDelete)
+        }
+
+        // File without owner?
+        storageDir.listFiles()?.forEach { file ->
+            val orphan = allMetadata.none { getPathForMetaData(it) == file }
+
+            if (orphan && file.delete()) {
+                Timber.tag(TAG).w("Deleted orphaned file: %s", file)
+            }
+        }
+    }
+
+    companion object {
+        private val TAG = TraceWarningRepository::class.java.simpleName
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/WarningPackageId.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/WarningPackageId.kt
new file mode 100644
index 000000000..af612ce8b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/warning/storage/WarningPackageId.kt
@@ -0,0 +1,3 @@
+package de.rki.coronawarnapp.presencetracing.warning
+
+typealias WarningPackageId = String
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/CombinedEwPtRisk.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/CombinedEwPtRisk.kt
new file mode 100644
index 000000000..b4e6305fb
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/CombinedEwPtRisk.kt
@@ -0,0 +1,64 @@
+package de.rki.coronawarnapp.risk
+
+import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
+import de.rki.coronawarnapp.risk.storage.combine
+import de.rki.coronawarnapp.risk.storage.max
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc
+import org.joda.time.Instant
+import org.joda.time.LocalDate
+
+data class CombinedEwPtDayRisk(
+    val localDate: LocalDate,
+    val riskState: RiskState
+)
+
+data class CombinedEwPtRiskLevelResult(
+    val ptRiskLevelResult: PtRiskLevelResult,
+    val ewRiskLevelResult: EwRiskLevelResult
+) {
+
+    val riskState: RiskState by lazy {
+        combine(ptRiskLevelResult.riskState, ewRiskLevelResult.riskState)
+    }
+
+    val wasSuccessfullyCalculated: Boolean by lazy {
+        riskState != RiskState.CALCULATION_FAILED
+    }
+
+    val calculatedAt: Instant by lazy {
+        max(ewRiskLevelResult.calculatedAt, ptRiskLevelResult.calculatedAt)
+    }
+
+    val daysWithEncounters: Int by lazy {
+        when (riskState) {
+            RiskState.INCREASED_RISK -> {
+                (ewRiskLevelResult.ewAggregatedRiskResult?.numberOfDaysWithHighRisk ?: 0) +
+                    ptRiskLevelResult.numberOfDaysWithHighRisk
+            }
+            RiskState.LOW_RISK -> {
+                (ewRiskLevelResult.ewAggregatedRiskResult?.numberOfDaysWithLowRisk ?: 0) +
+                    ptRiskLevelResult.numberOfDaysWithLowRisk
+            }
+            else -> 0
+        }
+    }
+
+    val lastRiskEncounterAt: LocalDate? by lazy {
+        when (riskState) {
+            RiskState.INCREASED_RISK -> max(
+                ewRiskLevelResult.ewAggregatedRiskResult?.mostRecentDateWithHighRisk?.toLocalDateUtc(),
+                ptRiskLevelResult.mostRecentDateWithHighRisk
+            )
+            RiskState.LOW_RISK -> max(
+                ewRiskLevelResult.ewAggregatedRiskResult?.mostRecentDateWithLowRisk?.toLocalDateUtc(),
+                ptRiskLevelResult.mostRecentDateWithLowRisk
+            )
+            else -> null
+        }
+    }
+}
+
+data class LastCombinedRiskResults(
+    val lastCalculated: CombinedEwPtRiskLevelResult,
+    val lastSuccessfullyCalculated: CombinedEwPtRiskLevelResult
+)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetector.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetector.kt
index d09e184b9..123f45edc 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetector.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetector.kt
@@ -8,7 +8,6 @@ import de.rki.coronawarnapp.datadonation.analytics.storage.TestResultDonorSettin
 import de.rki.coronawarnapp.datadonation.survey.Surveys
 import de.rki.coronawarnapp.notification.GeneralNotifications
 import de.rki.coronawarnapp.notification.NotificationConstants.NEW_MESSAGE_RISK_LEVEL_SCORE_NOTIFICATION_ID
-import de.rki.coronawarnapp.risk.storage.CombinedEwPtRiskLevelResult
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.storage.TracingSettings
 import de.rki.coronawarnapp.submission.SubmissionSettings
@@ -49,7 +48,7 @@ class RiskLevelChangeDetector @Inject constructor(
             }
             .filter { it.size == 2 }
             .onEach {
-                Timber.v("Checking for risklevel change.")
+                Timber.v("Checking for ew risklevel change.")
                 checkEwRiskForStateChanges(it)
             }
             .catch { Timber.e(it, "App config change checks failed.") }
@@ -61,7 +60,7 @@ class RiskLevelChangeDetector @Inject constructor(
             }
             .filter { it.size == 2 }
             .onEach {
-                Timber.v("Checking for risklevel change.")
+                Timber.v("Checking for combined risklevel change.")
                 checkCombinedRiskForStateChanges(it)
             }
             .catch { Timber.e(it, "App config change checks failed.") }
@@ -69,7 +68,6 @@ class RiskLevelChangeDetector @Inject constructor(
     }
 
     private suspend fun checkCombinedRiskForStateChanges(results: List<CombinedEwPtRiskLevelResult>) {
-        // TODO refactor
         val oldResult = results.first()
         val newResult = results.last()
 
@@ -164,7 +162,6 @@ class RiskLevelChangeDetector @Inject constructor(
             } else {
                 Timber.d("App is in foreground, not sending notifications")
             }
-
             Timber.d("Risk level changed and notification sent. Current Risk level is $newRiskState")
         }
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelResultExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelResultExtensions.kt
index c47ff8c40..1d0ea0988 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelResultExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelResultExtensions.kt
@@ -1,9 +1,7 @@
 package de.rki.coronawarnapp.risk
 
 import com.google.android.gms.nearby.exposurenotification.ExposureWindow
-import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
 import de.rki.coronawarnapp.risk.result.EwAggregatedRiskResult
-import de.rki.coronawarnapp.risk.storage.CombinedEwPtRiskLevelResult
 import org.joda.time.Instant
 
 fun List<EwRiskLevelResult>.tryLatestEwResultsWithDefaults(): DisplayableEwRiskResults {
@@ -24,41 +22,8 @@ data class DisplayableEwRiskResults(
     val lastSuccessfullyCalculated: EwRiskLevelResult
 )
 
-fun List<CombinedEwPtRiskLevelResult>.tryLatestResultsWithDefaults(): DisplayableRiskResults {
-    val latestCalculation = this.maxByOrNull { it.calculatedAt }
-        ?: initialLowLevelEwRiskLevelResult
-
-    val lastSuccessfullyCalculated = this.filter { it.wasSuccessfullyCalculated }
-        .maxByOrNull { it.calculatedAt } ?: undeterminedEwRiskLevelResult
-
-    return DisplayableRiskResults(
-        lastCalculated = latestCalculation,
-        lastSuccessfullyCalculated = lastSuccessfullyCalculated
-    )
-}
-
-data class DisplayableRiskResults(
-    val lastCalculated: CombinedEwPtRiskLevelResult,
-    val lastSuccessfullyCalculated: CombinedEwPtRiskLevelResult
-)
-
-private val undeterminedEwRiskLevelResult = CombinedEwPtRiskLevelResult(
-    PtRiskLevelResult(
-        calculatedAt = Instant.EPOCH,
-        riskState = RiskState.CALCULATION_FAILED
-    ),
-    EwUndeterminedRiskLevelResult
-)
-
-private val initialLowLevelEwRiskLevelResult = CombinedEwPtRiskLevelResult(
-    PtRiskLevelResult(
-        calculatedAt = Instant.now(),
-        riskState = RiskState.LOW_RISK
-    ),
-    EwInitialLowRiskLevelResult
-)
-
 private object EwInitialLowRiskLevelResult : EwRiskLevelResult {
+    // TODO this causes flaky tests as this is set in memory, once.
     override val calculatedAt: Instant = Instant.now()
     override val riskState: RiskState = RiskState.LOW_RISK
     override val failureReason: EwRiskLevelResult.FailureReason? = null
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/execution/RiskWorkScheduler.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/execution/RiskWorkScheduler.kt
new file mode 100644
index 000000000..ba05ce2a8
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/execution/RiskWorkScheduler.kt
@@ -0,0 +1,109 @@
+package de.rki.coronawarnapp.risk.execution
+
+import androidx.work.ExistingPeriodicWorkPolicy
+import androidx.work.PeriodicWorkRequest
+import androidx.work.WorkInfo
+import androidx.work.WorkManager
+import de.rki.coronawarnapp.diagnosiskeys.download.DownloadDiagnosisKeysTask
+import de.rki.coronawarnapp.diagnosiskeys.execution.DiagnosisKeyRetrievalWorkBuilder
+import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningTask
+import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningWorkBuilder
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.TaskState
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
+import de.rki.coronawarnapp.task.submitBlocking
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.coroutine.await
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class RiskWorkScheduler @Inject constructor(
+    @AppScope private val appScope: CoroutineScope,
+    private val workManager: WorkManager,
+    private val taskController: TaskController,
+    private val presenceWorkBuilder: PresenceTracingWarningWorkBuilder,
+    private val diagnosisWorkBuilder: DiagnosisKeyRetrievalWorkBuilder,
+) {
+
+    suspend fun runRiskTasksNow(): List<TaskState> {
+        val diagnosisKeysState = appScope.async {
+            Timber.tag(TAG).d("Running DownloadDiagnosisKeysTask")
+            val result = taskController.submitBlocking(
+                DefaultTaskRequest(
+                    DownloadDiagnosisKeysTask::class,
+                    DownloadDiagnosisKeysTask.Arguments(),
+                    originTag = "RiskWorkScheduler-runRiskTasksNow"
+                )
+            )
+            Timber.tag(TAG).d("DownloadDiagnosisKeysTask finished with %s", result)
+            result
+        }
+        val presenceWarningState = appScope.async {
+            Timber.tag(TAG).d("Running PresenceTracingWarningTask")
+            val result = taskController.submitBlocking(
+                DefaultTaskRequest(
+                    PresenceTracingWarningTask::class,
+                    originTag = "RiskWorkScheduler-runRiskTasksNow"
+                )
+            )
+            Timber.tag(TAG).d("PresenceTracingWarningTask finished with %s", result)
+            result
+        }
+        return listOf(diagnosisKeysState, presenceWarningState).awaitAll()
+    }
+
+    suspend fun isScheduled(): Boolean {
+        val diagnosisWorkerInfos = appScope.async {
+            workManager.getWorkInfosForUniqueWork(WORKER_ID_PRESENCE_TRACING).await()
+        }
+        val warningWorkerInfos = appScope.async {
+            workManager.getWorkInfosForUniqueWork(WORKER_ID_DIAGNOSIS_KEY_DOWNLOAD).await()
+        }
+        return listOf(diagnosisWorkerInfos, warningWorkerInfos).awaitAll().all { perWorkerInfos ->
+            perWorkerInfos.any { it.isScheduled }
+        }
+    }
+
+    fun setPeriodicRiskCalculation(enabled: Boolean) {
+        Timber.tag(TAG).i("setPeriodicRiskCalculation(enabled=$enabled)")
+
+        if (enabled) {
+            val diagnosisRequest = diagnosisWorkBuilder.createPeriodicWorkRequest()
+            queueWorker(WORKER_ID_DIAGNOSIS_KEY_DOWNLOAD, diagnosisRequest)
+
+            val warningRequest = presenceWorkBuilder.createPeriodicWorkRequest()
+            queueWorker(WORKER_ID_PRESENCE_TRACING, warningRequest)
+        } else {
+            cancelWorker(WORKER_ID_DIAGNOSIS_KEY_DOWNLOAD)
+            cancelWorker(WORKER_ID_PRESENCE_TRACING)
+        }
+    }
+
+    private fun queueWorker(workerId: String, request: PeriodicWorkRequest) {
+        Timber.tag(TAG).d("queueWorker(workerId=%s, request=%s)", workerId, request)
+        workManager.enqueueUniquePeriodicWork(
+            workerId,
+            ExistingPeriodicWorkPolicy.KEEP,
+            request,
+        )
+    }
+
+    private fun cancelWorker(workerId: String) {
+        Timber.tag(TAG).d("cancelWorker(workerId=$workerId")
+        workManager.cancelUniqueWork(workerId)
+    }
+
+    private val WorkInfo.isScheduled: Boolean
+        get() = state == WorkInfo.State.RUNNING || state == WorkInfo.State.ENQUEUED
+
+    companion object {
+        private const val WORKER_ID_DIAGNOSIS_KEY_DOWNLOAD = "DiagnosisKeyRetrievalWorker"
+        private const val WORKER_ID_PRESENCE_TRACING = "PresenceTracingWarningWorker"
+        private const val TAG = "RiskWorkScheduler"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorage.kt
index a0844cd0b..d5d0d71f0 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorage.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorage.kt
@@ -1,14 +1,19 @@
 package de.rki.coronawarnapp.risk.storage
 
 import androidx.annotation.VisibleForTesting
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingDayRisk
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskRepository
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
 import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
-import de.rki.coronawarnapp.presencetracing.risk.mapToRiskState
+import de.rki.coronawarnapp.presencetracing.risk.TraceLocationCheckInRisk
+import de.rki.coronawarnapp.presencetracing.risk.calculation.PresenceTracingDayRisk
+import de.rki.coronawarnapp.presencetracing.risk.calculation.mapToRiskState
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.risk.CombinedEwPtDayRisk
+import de.rki.coronawarnapp.risk.CombinedEwPtRiskLevelResult
 import de.rki.coronawarnapp.risk.EwRiskLevelResult
 import de.rki.coronawarnapp.risk.EwRiskLevelTaskResult
+import de.rki.coronawarnapp.risk.LastCombinedRiskResults
 import de.rki.coronawarnapp.risk.RiskState
-import de.rki.coronawarnapp.risk.TraceLocationCheckInRisk
+import de.rki.coronawarnapp.risk.result.EwAggregatedRiskResult
 import de.rki.coronawarnapp.risk.result.ExposureWindowDayRisk
 import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase
 import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao
@@ -30,7 +35,7 @@ import de.rki.coronawarnapp.util.flow.combine as flowCombine
 
 abstract class BaseRiskLevelStorage constructor(
     private val riskResultDatabaseFactory: RiskResultDatabase.Factory,
-    presenceTracingRiskRepository: PresenceTracingRiskRepository,
+    private val presenceTracingRiskRepository: PresenceTracingRiskRepository,
     scope: CoroutineScope
 ) : RiskLevelStorage {
 
@@ -185,44 +190,20 @@ abstract class BaseRiskLevelStorage constructor(
         }
         .shareLatest(tag = TAG, scope = scope)
 
-    private val latestAndLastSuccessfulPtRiskLevelResult: Flow<List<PtRiskLevelResult>> =
-        presenceTracingRiskRepository
-            .latestAndLastSuccessful()
-            .shareLatest(tag = TAG, scope = scope)
-
-    // TODO maybe refactor
-    override val latestAndLastSuccessfulCombinedEwPtRiskLevelResult: Flow<List<CombinedEwPtRiskLevelResult>>
+    // used for risk state in tracing state/details
+    override val latestAndLastSuccessfulCombinedEwPtRiskLevelResult: Flow<LastCombinedRiskResults>
         get() = combine(
-            latestAndLastSuccessfulEwRiskLevelResult,
-            latestAndLastSuccessfulPtRiskLevelResult
+            allEwRiskLevelResults,
+            presenceTracingRiskRepository.allEntries()
         ) { ewRiskLevelResults, ptRiskLevelResults ->
-            val latestEwResult = ewRiskLevelResults.maxByOrNull { it.calculatedAt }
-            val latestPtResult = ptRiskLevelResults.maxByOrNull { it.calculatedAt }
-            val combinedList = mutableListOf<CombinedEwPtRiskLevelResult>()
-            if (latestEwResult != null && latestPtResult != null) {
-                combinedList.add(
-                    CombinedEwPtRiskLevelResult(
-                        ewRiskLevelResult = latestEwResult,
-                        ptRiskLevelResult = latestPtResult
-                    )
-                )
-            }
-            val lastSuccessfulEwResult = ewRiskLevelResults
-                .filter { it.wasSuccessfullyCalculated }.maxByOrNull { it.calculatedAt }
-            val lastSuccessfulPtResult = ptRiskLevelResults
-                .filter { it.wasSuccessfullyCalculated }.maxByOrNull { it.calculatedAt }
-            if (lastSuccessfulEwResult != null && lastSuccessfulPtResult != null) {
-                combinedList.add(
-                    CombinedEwPtRiskLevelResult(
-                        ewRiskLevelResult = lastSuccessfulEwResult,
-                        // current ptDayRiskStates belong to the last successful calculation - ugly
-                        ptRiskLevelResult = lastSuccessfulPtResult.copy(
-                            presenceTracingDayRisk = ptDayRiskStates.first()
-                        )
-                    )
-                )
-            }
-            combinedList
+
+            val combinedResults = combineEwPtRiskLevelResults(ptRiskLevelResults, ewRiskLevelResults)
+                .sortedByDescending { it.calculatedAt }
+
+            LastCombinedRiskResults(
+                lastCalculated = combinedResults.firstOrNull() ?: currentCombinedLowRisk,
+                lastSuccessfullyCalculated = combinedResults.find { it.wasSuccessfullyCalculated } ?: initialCombined
+            )
         }
 
     private val latestPtRiskLevelResults: Flow<List<PtRiskLevelResult>> =
@@ -230,33 +211,15 @@ abstract class BaseRiskLevelStorage constructor(
             .latestEntries(2)
             .shareLatest(tag = TAG, scope = scope)
 
+    // used for risk level change detector to trigger notification
     override val latestCombinedEwPtRiskLevelResults: Flow<List<CombinedEwPtRiskLevelResult>>
         get() = combine(
             latestEwRiskLevelResults,
             latestPtRiskLevelResults
         ) { ewRiskLevelResults, ptRiskLevelResults ->
-            val latestEwResult = ewRiskLevelResults.maxByOrNull { it.calculatedAt }
-            val latestPtResult = ptRiskLevelResults.maxByOrNull { it.calculatedAt }
-            val olderEwResult = ewRiskLevelResults.maxByOrNull { it.calculatedAt }
-            val olderPtResult = ptRiskLevelResults.maxByOrNull { it.calculatedAt }
-            val combinedList = mutableListOf<CombinedEwPtRiskLevelResult>()
-            if (latestEwResult != null && latestPtResult != null) {
-                combinedList.add(
-                    CombinedEwPtRiskLevelResult(
-                        ewRiskLevelResult = latestEwResult,
-                        ptRiskLevelResult = latestPtResult
-                    )
-                )
-            }
-            if (olderEwResult != null && olderPtResult != null) {
-                combinedList.add(
-                    CombinedEwPtRiskLevelResult(
-                        ewRiskLevelResult = olderEwResult,
-                        ptRiskLevelResult = olderPtResult
-                    )
-                )
-            }
-            combinedList
+            combineEwPtRiskLevelResults(ptRiskLevelResults, ewRiskLevelResults)
+                .sortedByDescending { it.calculatedAt }
+                .take(2)
         }
 
     internal abstract suspend fun storeExposureWindows(storedResultId: String, resultEw: EwRiskLevelResult)
@@ -266,6 +229,7 @@ abstract class BaseRiskLevelStorage constructor(
     override suspend fun clear() {
         Timber.w("clear() - Clearing stored risklevel/exposure-detection results.")
         database.clearAllTables()
+        presenceTracingRiskRepository.clearAllTables()
     }
 
     companion object {
@@ -284,7 +248,7 @@ internal fun combineRisk(
         val ewRisk = exposureWindowDayRiskList.find { it.localDateUtc == date }
         CombinedEwPtDayRisk(
             date,
-            max(
+            combine(
                 ptRisk?.riskState,
                 ewRisk?.riskLevel?.mapToRiskState()
             )
@@ -292,7 +256,7 @@ internal fun combineRisk(
     }
 }
 
-internal fun max(left: RiskState?, right: RiskState?): RiskState {
+internal fun combine(left: RiskState?, right: RiskState?): RiskState {
     return if (left == RiskState.INCREASED_RISK || right == RiskState.INCREASED_RISK) RiskState.INCREASED_RISK
     else if (left == RiskState.LOW_RISK || right == RiskState.LOW_RISK) RiskState.LOW_RISK
     else RiskState.CALCULATION_FAILED
@@ -308,3 +272,66 @@ internal fun max(left: LocalDate?, right: LocalDate?): LocalDate? {
     return if (left.isAfter(right)) left
     else right
 }
+
+@VisibleForTesting(otherwise = PRIVATE)
+internal fun combineEwPtRiskLevelResults(
+    ptRiskResults: List<PtRiskLevelResult>,
+    ewRiskResults: List<EwRiskLevelResult>
+): List<CombinedEwPtRiskLevelResult> {
+    val allDates = ptRiskResults.map { it.calculatedAt }.plus(ewRiskResults.map { it.calculatedAt }).distinct()
+    val sortedPtResults = ptRiskResults.sortedByDescending { it.calculatedAt }
+    val sortedEwResults = ewRiskResults.sortedByDescending { it.calculatedAt }
+    return allDates.map { date ->
+        val ptRisk = sortedPtResults.find { it.calculatedAt <= date } ?: ptInitialRiskLevelResult
+        val ewRisk = sortedEwResults.find { it.calculatedAt <= date } ?: EwInitialRiskLevelResult
+        CombinedEwPtRiskLevelResult(
+            ptRisk,
+            ewRisk
+        )
+    }
+}
+
+private object EwInitialRiskLevelResult : EwRiskLevelResult {
+    override val calculatedAt: Instant = Instant.EPOCH
+    override val riskState: RiskState = RiskState.CALCULATION_FAILED
+    override val failureReason: EwRiskLevelResult.FailureReason? = null
+    override val ewAggregatedRiskResult: EwAggregatedRiskResult? = null
+    override val exposureWindows: List<ExposureWindow>? = null
+    override val matchedKeyCount: Int = 0
+    override val daysWithEncounters: Int = 0
+}
+
+private val ptInitialRiskLevelResult: PtRiskLevelResult by lazy {
+    PtRiskLevelResult(
+        calculatedAt = Instant.EPOCH,
+        riskState = RiskState.CALCULATION_FAILED
+    )
+}
+
+private val ewCurrentLowRiskLevelResult
+    get() = object : EwRiskLevelResult {
+        override val calculatedAt: Instant = Instant.now()
+        override val riskState: RiskState = RiskState.LOW_RISK
+        override val failureReason: EwRiskLevelResult.FailureReason? = null
+        override val ewAggregatedRiskResult: EwAggregatedRiskResult? = null
+        override val exposureWindows: List<ExposureWindow>? = null
+        override val matchedKeyCount: Int = 0
+        override val daysWithEncounters: Int = 0
+    }
+
+private val ptCurrentLowRiskLevelResult: PtRiskLevelResult
+    get() = PtRiskLevelResult(
+        calculatedAt = Instant.now(),
+        riskState = RiskState.LOW_RISK
+    )
+
+private val initialCombined = CombinedEwPtRiskLevelResult(
+    ptInitialRiskLevelResult,
+    EwInitialRiskLevelResult
+)
+
+private val currentCombinedLowRisk: CombinedEwPtRiskLevelResult
+    get() = CombinedEwPtRiskLevelResult(
+        ptCurrentLowRiskLevelResult,
+        ewCurrentLowRiskLevelResult
+    )
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/RiskLevelStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/RiskLevelStorage.kt
index 46d4366ef..a72b458ba 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/RiskLevelStorage.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/storage/RiskLevelStorage.kt
@@ -1,15 +1,13 @@
 package de.rki.coronawarnapp.risk.storage
 
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingDayRisk
-import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
+import de.rki.coronawarnapp.presencetracing.risk.TraceLocationCheckInRisk
+import de.rki.coronawarnapp.presencetracing.risk.calculation.PresenceTracingDayRisk
+import de.rki.coronawarnapp.risk.CombinedEwPtDayRisk
+import de.rki.coronawarnapp.risk.CombinedEwPtRiskLevelResult
 import de.rki.coronawarnapp.risk.EwRiskLevelResult
-import de.rki.coronawarnapp.risk.RiskState
-import de.rki.coronawarnapp.risk.TraceLocationCheckInRisk
+import de.rki.coronawarnapp.risk.LastCombinedRiskResults
 import de.rki.coronawarnapp.risk.result.ExposureWindowDayRisk
-import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc
 import kotlinx.coroutines.flow.Flow
-import org.joda.time.Instant
-import org.joda.time.LocalDate
 
 interface RiskLevelStorage {
 
@@ -51,7 +49,7 @@ interface RiskLevelStorage {
      * Can be 0-2 entries.
      * Newest item first.
      */
-    val latestAndLastSuccessfulCombinedEwPtRiskLevelResult: Flow<List<CombinedEwPtRiskLevelResult>>
+    val latestAndLastSuccessfulCombinedEwPtRiskLevelResult: Flow<LastCombinedRiskResults>
 
     /** EXPOSURE WINDOW RISK RESULT
      * Risk level per date/day
@@ -82,48 +80,3 @@ interface RiskLevelStorage {
 
     suspend fun clear()
 }
-
-data class CombinedEwPtDayRisk(
-    val localDate: LocalDate,
-    val riskState: RiskState
-)
-
-data class CombinedEwPtRiskLevelResult(
-    val ptRiskLevelResult: PtRiskLevelResult,
-    val ewRiskLevelResult: EwRiskLevelResult
-) {
-
-    val riskState: RiskState = max(ptRiskLevelResult.riskState, ewRiskLevelResult.riskState)
-
-    val wasSuccessfullyCalculated: Boolean
-        get() = ewRiskLevelResult.ewAggregatedRiskResult != null &&
-            ptRiskLevelResult.riskState != RiskState.CALCULATION_FAILED
-
-    val calculatedAt: Instant = max(ewRiskLevelResult.calculatedAt, ptRiskLevelResult.calculatedAt)
-
-    val daysWithEncounters: Int
-        get() = when (riskState) {
-            RiskState.INCREASED_RISK -> {
-                (ewRiskLevelResult.ewAggregatedRiskResult?.numberOfDaysWithHighRisk ?: 0) +
-                    ptRiskLevelResult.numberOfDaysWithHighRisk
-            }
-            RiskState.LOW_RISK -> {
-                (ewRiskLevelResult.ewAggregatedRiskResult?.numberOfDaysWithLowRisk ?: 0) +
-                    ptRiskLevelResult.numberOfDaysWithLowRisk
-            }
-            else -> 0
-        }
-
-    val lastRiskEncounterAt: LocalDate?
-        get() = if (riskState == RiskState.INCREASED_RISK) {
-            max(
-                ewRiskLevelResult.ewAggregatedRiskResult?.mostRecentDateWithHighRisk?.toLocalDateUtc(),
-                ptRiskLevelResult.mostRecentDateWithHighRisk
-            )
-        } else {
-            max(
-                ewRiskLevelResult.ewAggregatedRiskResult?.mostRecentDateWithLowRisk?.toLocalDateUtc(),
-                ptRiskLevelResult.mostRecentDateWithLowRisk
-            )
-        }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionRepository.kt
index 016ce630f..29a64f316 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionRepository.kt
@@ -40,7 +40,8 @@ class SubmissionRepository @Inject constructor(
     private val backgroundNoise: BackgroundNoise,
     private val analyticsKeySubmissionCollector: AnalyticsKeySubmissionCollector,
     private val tracingSettings: TracingSettings,
-    private val testResultDataCollector: TestResultDataCollector
+    private val testResultDataCollector: TestResultDataCollector,
+    private val backgroundWorkScheduler: BackgroundWorkScheduler,
 ) {
     private val testResultReceivedDateFlowInternal =
         MutableStateFlow((submissionSettings.initialTestResultReceivedAt ?: timeStamper.nowUTC).toDate())
@@ -192,7 +193,7 @@ class SubmissionRepository @Inject constructor(
             submissionSettings.initialTestResultReceivedAt = currentTime
             testResultReceivedDateFlowInternal.value = currentTime.toDate()
             if (testResult == TestResult.PENDING) {
-                BackgroundWorkScheduler.startWorkScheduler()
+                backgroundWorkScheduler.startWorkScheduler()
             }
         } else {
             testResultReceivedDateFlowInternal.value = initialTestResultReceivedTimestamp.toDate()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/task/SubmissionTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/task/SubmissionTask.kt
index 7265d2bb6..a860a2edb 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/task/SubmissionTask.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/task/SubmissionTask.kt
@@ -41,7 +41,8 @@ class SubmissionTask @Inject constructor(
     private val testResultAvailableNotificationService: TestResultAvailableNotificationService,
     private val checkInsRepository: CheckInRepository,
     private val checkInsTransformer: CheckInsTransformer,
-    private val analyticsKeySubmissionCollector: AnalyticsKeySubmissionCollector
+    private val analyticsKeySubmissionCollector: AnalyticsKeySubmissionCollector,
+    private val backgroundWorkScheduler: BackgroundWorkScheduler,
 ) : Task<DefaultProgress, SubmissionTask.Result> {
 
     private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>()
@@ -176,9 +177,9 @@ class SubmissionTask @Inject constructor(
 
     private fun setSubmissionFinished() {
         Timber.tag(TAG).d("setSubmissionFinished()")
-        BackgroundWorkScheduler.stopWorkScheduler()
+        backgroundWorkScheduler.stopWorkScheduler()
         submissionSettings.isSubmissionSuccessful = true
-        BackgroundWorkScheduler.startWorkScheduler()
+        backgroundWorkScheduler.startWorkScheduler()
 
         shareTestResultNotificationService.cancelSharePositiveTestResultNotification()
         testResultAvailableNotificationService.cancelTestResultAvailableNotification()
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/states/TracingStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/states/TracingStateProvider.kt
index c3d9018fc..5aa675aae 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/states/TracingStateProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/states/TracingStateProvider.kt
@@ -8,7 +8,6 @@ import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTra
 import de.rki.coronawarnapp.nearby.modules.detectiontracker.latestSubmission
 import de.rki.coronawarnapp.risk.RiskState
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
-import de.rki.coronawarnapp.risk.tryLatestResultsWithDefaults
 import de.rki.coronawarnapp.storage.TracingRepository
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.tracing.TracingProgress
@@ -51,15 +50,13 @@ class TracingStateProvider @AssistedInject constructor(
         latestSubmission,
         isBackgroundJobEnabled ->
 
-        val (
-            latestCalc,
-            latestSuccessfulCalc
-        ) = riskLevelResults.tryLatestResultsWithDefaults()
+        val latestCalc = riskLevelResults.lastCalculated
+        val lastSuccessfullyCalc = riskLevelResults.lastSuccessfullyCalculated
 
         return@combine when {
             tracingStatus == GeneralTracingStatus.Status.TRACING_INACTIVE -> TracingDisabled(
                 isInDetailsMode = isDetailsMode,
-                riskState = latestSuccessfulCalc.riskState,
+                riskState = lastSuccessfullyCalc.riskState,
                 lastExposureDetectionTime = latestSubmission?.startedAt
             )
             tracingProgress != TracingProgress.Idle -> TracingInProgress(
@@ -86,7 +83,7 @@ class TracingStateProvider @AssistedInject constructor(
             )
             else -> TracingFailed(
                 isInDetailsMode = isDetailsMode,
-                riskState = latestSuccessfulCalc.riskState,
+                riskState = lastSuccessfullyCalc.riskState,
                 lastExposureDetectionTime = latestSubmission?.startedAt
             )
         }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsFragmentViewModel.kt
index eba70d84b..dd52cdbc9 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsFragmentViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsFragmentViewModel.kt
@@ -9,7 +9,6 @@ import de.rki.coronawarnapp.datadonation.survey.Surveys.ConsentResult.AlreadyGiv
 import de.rki.coronawarnapp.datadonation.survey.Surveys.ConsentResult.Needed
 import de.rki.coronawarnapp.risk.RiskState
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
-import de.rki.coronawarnapp.risk.tryLatestResultsWithDefaults
 import de.rki.coronawarnapp.storage.TracingRepository
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.tracing.states.IncreasedRisk
@@ -81,7 +80,7 @@ class TracingDetailsFragmentViewModel @AssistedInject constructor(
         riskLevelResults,
         isBackgroundJobEnabled ->
 
-        val (latestCalc, _) = riskLevelResults.tryLatestResultsWithDefaults()
+        val latestCalc = riskLevelResults.lastCalculated
 
         val isRestartButtonEnabled = !isBackgroundJobEnabled || latestCalc.riskState == RiskState.CALCULATION_FAILED
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsItemProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsItemProvider.kt
index f58268644..f47238e68 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsItemProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsItemProvider.kt
@@ -5,7 +5,6 @@ import de.rki.coronawarnapp.datadonation.survey.Surveys
 import de.rki.coronawarnapp.installTime.InstallTimeProvider
 import de.rki.coronawarnapp.risk.RiskState
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
-import de.rki.coronawarnapp.risk.tryLatestResultsWithDefaults
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus.Status
 import de.rki.coronawarnapp.tracing.ui.details.items.DetailsItem
@@ -43,7 +42,7 @@ class TracingDetailsItemProvider @Inject constructor(
         riskLevelResults,
         availableSurveys ->
 
-        val (latestCalc, _) = riskLevelResults.tryLatestResultsWithDefaults()
+        val latestCalc = riskLevelResults.lastCalculated
 
         mutableListOf<DetailsItem>().apply {
             if (status != Status.TRACING_INACTIVE &&
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/items/riskdetails/DetailsLowRiskBox.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/items/riskdetails/DetailsLowRiskBox.kt
index 3628d6add..574698d97 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/items/riskdetails/DetailsLowRiskBox.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/details/items/riskdetails/DetailsLowRiskBox.kt
@@ -50,6 +50,7 @@ class DetailsLowRiskBox(
     ) : RiskDetailsStateItem {
 
         fun getRiskDetailsRiskLevelBody(c: Context): String {
+            // TODO consider pt encounters?
             return c.getString(
                 if (matchedKeyCount > 0) R.string.risk_details_information_body_low_risk_with_encounter
                 else R.string.risk_details_information_body_low_risk
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/settings/SettingsTracingFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/settings/SettingsTracingFragmentViewModel.kt
index 76af2958e..571ae77c0 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/settings/SettingsTracingFragmentViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/tracing/ui/settings/SettingsTracingFragmentViewModel.kt
@@ -34,7 +34,8 @@ class SettingsTracingFragmentViewModel @AssistedInject constructor(
     tracingStatus: GeneralTracingStatus,
     installTimeProvider: InstallTimeProvider,
     private val backgroundStatus: BackgroundModeStatus,
-    tracingPermissionHelperFactory: TracingPermissionHelper.Factory
+    tracingPermissionHelperFactory: TracingPermissionHelper.Factory,
+    private val backgroundWorkScheduler: BackgroundWorkScheduler
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
 
     val loggingPeriod: LiveData<PeriodLoggedBox.Item> =
@@ -75,7 +76,7 @@ class SettingsTracingFragmentViewModel @AssistedInject constructor(
                             if (!backgroundStatus.isIgnoringBatteryOptimizations.first()) {
                                 events.postValue(Event.ManualCheckingDialog)
                             }
-                            BackgroundWorkScheduler.startWorkScheduler()
+                            backgroundWorkScheduler.startWorkScheduler()
                         }
                         isTracingSwitchChecked.postValue(isTracingEnabled)
                     }
@@ -109,7 +110,7 @@ class SettingsTracingFragmentViewModel @AssistedInject constructor(
                 launch {
                     if (InternalExposureNotificationClient.asyncIsEnabled()) {
                         InternalExposureNotificationClient.asyncStop()
-                        BackgroundWorkScheduler.stopWorkScheduler()
+                        backgroundWorkScheduler.stopWorkScheduler()
                     }
                 }
             }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/EventRegistrationUIModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/EventRegistrationUIModule.kt
index cd6d537b1..e6f0123e6 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/EventRegistrationUIModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/EventRegistrationUIModule.kt
@@ -6,10 +6,10 @@ import de.rki.coronawarnapp.ui.eventregistration.attendee.checkins.CheckInsFragm
 import de.rki.coronawarnapp.ui.eventregistration.attendee.checkins.CheckInsModule
 import de.rki.coronawarnapp.ui.eventregistration.attendee.confirm.ConfirmCheckInFragment
 import de.rki.coronawarnapp.ui.eventregistration.attendee.confirm.ConfirmCheckInModule
-import de.rki.coronawarnapp.ui.eventregistration.attendee.onboarding.CheckInOnboardingFragment
-import de.rki.coronawarnapp.ui.eventregistration.attendee.onboarding.CheckInOnboardingModule
 import de.rki.coronawarnapp.ui.eventregistration.attendee.edit.EditCheckInFragment
 import de.rki.coronawarnapp.ui.eventregistration.attendee.edit.EditCheckInModule
+import de.rki.coronawarnapp.ui.eventregistration.attendee.onboarding.CheckInOnboardingFragment
+import de.rki.coronawarnapp.ui.eventregistration.attendee.onboarding.CheckInOnboardingModule
 import de.rki.coronawarnapp.ui.eventregistration.attendee.scan.ScanCheckInQrCodeFragment
 import de.rki.coronawarnapp.ui.eventregistration.attendee.scan.ScanCheckInQrCodeModule
 import de.rki.coronawarnapp.ui.eventregistration.organizer.category.TraceLocationCategoryFragment
@@ -18,10 +18,10 @@ import de.rki.coronawarnapp.ui.eventregistration.organizer.create.TraceLocationC
 import de.rki.coronawarnapp.ui.eventregistration.organizer.create.TraceLocationCreateFragmentModule
 import de.rki.coronawarnapp.ui.eventregistration.organizer.list.TraceLocationsFragment
 import de.rki.coronawarnapp.ui.eventregistration.organizer.list.TraceLocationsFragmentModule
-import de.rki.coronawarnapp.ui.eventregistration.organizer.qrinfo.TraceLocationQRInfoFragment
-import de.rki.coronawarnapp.ui.eventregistration.organizer.qrinfo.TraceLocationQRInfoFragmentModule
 import de.rki.coronawarnapp.ui.eventregistration.organizer.poster.QrCodePosterFragment
 import de.rki.coronawarnapp.ui.eventregistration.organizer.poster.QrCodePosterFragmentModule
+import de.rki.coronawarnapp.ui.eventregistration.organizer.qrinfo.TraceLocationQRInfoFragment
+import de.rki.coronawarnapp.ui.eventregistration.organizer.qrinfo.TraceLocationQRInfoFragmentModule
 
 @Module
 internal abstract class EventRegistrationUIModule {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInViewModel.kt
index 685f2f245..840682e97 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInViewModel.kt
@@ -92,7 +92,6 @@ class ConfirmCheckInViewModel @AssistedInject constructor(
         val traceLocation = verifiedTraceLocation.traceLocation
         return CheckIn(
             traceLocationId = traceLocation.locationId,
-            traceLocationIdHash = traceLocation.locationIdHash,
             version = traceLocation.version,
             type = traceLocation.type.number,
             description = traceLocation.description,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/organizer/details/QrCodeDetailViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/organizer/details/QrCodeDetailViewModel.kt
index 792ff9e53..739078b1e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/organizer/details/QrCodeDetailViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/organizer/details/QrCodeDetailViewModel.kt
@@ -1,15 +1,15 @@
 package de.rki.coronawarnapp.ui.eventregistration.organizer.details
 
 import android.graphics.Bitmap
-import androidx.lifecycle.asLiveData
-import dagger.assisted.Assisted
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.asLiveData
+import dagger.assisted.Assisted
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
+import de.rki.coronawarnapp.eventregistration.checkins.qrcode.QrCodeGenerator
 import de.rki.coronawarnapp.eventregistration.checkins.qrcode.TraceLocation
 import de.rki.coronawarnapp.eventregistration.storage.repo.DefaultTraceLocationRepository
-import de.rki.coronawarnapp.eventregistration.checkins.qrcode.QrCodeGenerator
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
index 196029c9f..798abdfcc 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt
@@ -80,6 +80,7 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector {
     @Inject lateinit var contactDiaryWorkScheduler: ContactDiaryWorkScheduler
     @Inject lateinit var dataDonationAnalyticsScheduler: DataDonationAnalyticsScheduler
     @Inject lateinit var submissionSettings: SubmissionSettings
+    @Inject lateinit var backgroundWorkScheduler: BackgroundWorkScheduler
 
     override fun onCreate(savedInstanceState: Bundle?) {
         AppInjector.setup(this)
@@ -190,7 +191,7 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector {
      */
     override fun onResume() {
         super.onResume()
-        scheduleWork()
+        backgroundWorkScheduler.startWorkScheduler()
         vm.doBackgroundNoiseCheck()
         contactDiaryWorkScheduler.schedulePeriodic()
         dataDonationAnalyticsScheduler.schedulePeriodic()
@@ -271,9 +272,4 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector {
     fun goBack() {
         onBackPressed()
     }
-
-    /**
-     * Scheduling for a download of keys every hour.
-     */
-    private fun scheduleWork() = BackgroundWorkScheduler.startWorkScheduler()
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt
index e61dfcc8a..144593856 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt
@@ -19,7 +19,8 @@ class SettingsResetViewModel @AssistedInject constructor(
     dispatcherProvider: DispatcherProvider,
     private val dataReset: DataReset,
     private val shareTestResultNotificationService: ShareTestResultNotificationService,
-    private val shortcutsHelper: AppShortcutsHelper
+    private val shortcutsHelper: AppShortcutsHelper,
+    private val backgroundWorkScheduler: BackgroundWorkScheduler,
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
 
     val clickEvent: SingleLiveEvent<SettingsEvents> = SingleLiveEvent()
@@ -39,7 +40,7 @@ class SettingsResetViewModel @AssistedInject constructor(
                 // only stop tracing if it is currently enabled
                 if (isTracingEnabled) {
                     InternalExposureNotificationClient.asyncStop()
-                    BackgroundWorkScheduler.stopWorkScheduler()
+                    backgroundWorkScheduler.stopWorkScheduler()
                 }
             } catch (apiException: ApiException) {
                 apiException.report(
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt
index 30c03c4a5..f7f9f38cf 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt
@@ -74,6 +74,11 @@ object TimeAndDateExtensions {
     fun Instant.derive10MinutesInterval(): Long =
         seconds / TimeUnit.MINUTES.toSeconds(10) // 10 min in seconds
 
+    /**
+     * Derive a UNIX timestamp (in seconds) and returns the corresponding 10-minute interval
+     */
+    fun Instant.deriveHourInterval(): HourInterval = millis / 3600000
+
     /**
      * Converts milliseconds to human readable format hh:mm:ss
      *
@@ -110,3 +115,5 @@ object TimeAndDateExtensions {
 
     fun Instant.toUserTimeZone() = this.toDateTime(DateTimeZone.forTimeZone(TimeZone.getDefault()))
 }
+
+typealias HourInterval = Long
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt
index 6ead4ba58..442df0780 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/WatchdogService.kt
@@ -5,11 +5,9 @@ import android.net.wifi.WifiManager
 import android.os.PowerManager
 import androidx.lifecycle.LifecycleOwner
 import androidx.lifecycle.lifecycleScope
-import de.rki.coronawarnapp.diagnosiskeys.download.DownloadDiagnosisKeysTask
+import de.rki.coronawarnapp.risk.execution.RiskWorkScheduler
 import de.rki.coronawarnapp.storage.OnboardingSettings
 import de.rki.coronawarnapp.task.TaskController
-import de.rki.coronawarnapp.task.common.DefaultTaskRequest
-import de.rki.coronawarnapp.task.submitBlocking
 import de.rki.coronawarnapp.util.device.BackgroundModeStatus
 import de.rki.coronawarnapp.util.di.AppContext
 import de.rki.coronawarnapp.util.di.ProcessLifecycle
@@ -28,7 +26,9 @@ class WatchdogService @Inject constructor(
     private val taskController: TaskController,
     private val backgroundModeStatus: BackgroundModeStatus,
     @ProcessLifecycle private val processLifecycleOwner: LifecycleOwner,
-    private val onboardingSettings: OnboardingSettings
+    private val onboardingSettings: OnboardingSettings,
+    private val backgroundWorkScheduler: BackgroundWorkScheduler,
+    private val riskWorkScheduler: RiskWorkScheduler,
 ) {
 
     private val powerManager by lazy {
@@ -55,18 +55,8 @@ class WatchdogService @Inject constructor(
 
             Timber.tag(TAG).d("Automatic mode is on, check if we have downloaded keys already today")
 
-            val state = taskController.submitBlocking(
-                DefaultTaskRequest(
-                    DownloadDiagnosisKeysTask::class,
-                    DownloadDiagnosisKeysTask.Arguments(),
-                    originTag = "WatchdogService"
-                )
-            )
-            if (state.isFailed) {
-                Timber.tag(TAG).e(state.error, "RetrieveDiagnosisKeysTransaction failed")
-                // retry the key retrieval in case of an error with a scheduled work
-                BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()
-            }
+            val results = riskWorkScheduler.runRiskTasksNow()
+            Timber.tag(TAG).d("runRiskTasksNow() results: %s", results)
 
             if (wifiLock.isHeld) wifiLock.release()
             if (wakeLock.isHeld) wakeLock.release()
@@ -75,7 +65,7 @@ class WatchdogService @Inject constructor(
         // if the user is onboarded we will schedule period background jobs
         // in case the app was force stopped and woken up again by the Google WakeUpService
         if (onboardingSettings.isOnboarded) {
-            BackgroundWorkScheduler.startWorkScheduler()
+            backgroundWorkScheduler.startWorkScheduler()
         }
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/ListenableFuture.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/ListenableFuture.kt
new file mode 100644
index 000000000..553f18903
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/ListenableFuture.kt
@@ -0,0 +1,57 @@
+package de.rki.coronawarnapp.util.coroutine
+
+/*
+ * Copyright 2018 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import com.google.common.util.concurrent.ListenableFuture
+import com.google.common.util.concurrent.MoreExecutors
+import kotlinx.coroutines.suspendCancellableCoroutine
+import java.util.concurrent.CancellationException
+import java.util.concurrent.ExecutionException
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+
+/**
+ * Awaits for the completion of the [ListenableFuture] without blocking a thread.
+ *
+ * @return R The result from the [ListenableFuture]
+ *
+ */
+@Suppress("BlockingMethodInNonBlockingContext")
+suspend inline fun <R> ListenableFuture<R>.await(): R {
+    // Fast path
+    if (isDone) {
+        try {
+            return get()
+        } catch (e: ExecutionException) {
+            throw e.cause ?: e
+        }
+    }
+    return suspendCancellableCoroutine { cancellableContinuation ->
+        val action = Runnable {
+            try {
+                cancellableContinuation.resume(get())
+            } catch (throwable: Throwable) {
+                val cause = throwable.cause ?: throwable
+                when (throwable) {
+                    is CancellationException -> cancellableContinuation.cancel(cause)
+                    else -> cancellableContinuation.resumeWithException(cause)
+                }
+            }
+        }
+        addListener(action, MoreExecutors.directExecutor())
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/FormatterStatistics.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/FormatterStatistics.kt
index 8605f6a5a..377c7491c 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/FormatterStatistics.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/FormatterStatistics.kt
@@ -8,31 +8,32 @@ import de.rki.coronawarnapp.statistics.InfectionStats
 import de.rki.coronawarnapp.statistics.KeySubmissionsStats
 import de.rki.coronawarnapp.statistics.SevenDayRValue
 import de.rki.coronawarnapp.statistics.StatsItem
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toUserTimeZone
 import org.joda.time.LocalDate
 import org.joda.time.format.DateTimeFormat
 
 fun StatsItem.getPrimaryLabel(context: Context): String {
     val today = LocalDate()
     val yesterday = today.minusDays(1)
-    val day = LocalDate(updatedAt)
+    val updatedAtDate = LocalDate(updatedAt.toUserTimeZone())
     val dateTimeFormatter = DateTimeFormat.mediumDate().withLocale(context.getLocale())
 
     return when (this) {
         is InfectionStats,
-        is KeySubmissionsStats -> when (day) {
+        is KeySubmissionsStats -> when (updatedAtDate) {
             today -> context.getString(R.string.statistics_primary_value_today)
             yesterday -> context.getString(R.string.statistics_primary_value_yesterday)
-            else -> dateTimeFormatter.print(day)
+            else -> dateTimeFormatter.print(updatedAtDate)
         }
-        is IncidenceStats -> when (day) {
+        is IncidenceStats -> when (updatedAtDate) {
             today -> context.getString(R.string.statistics_primary_value_until_today)
             yesterday -> context.getString(R.string.statistics_primary_value_until_yesterday)
-            else -> context.getString(R.string.statistics_primary_value_until, dateTimeFormatter.print(day))
+            else -> context.getString(R.string.statistics_primary_value_until, dateTimeFormatter.print(updatedAtDate))
         }
-        is SevenDayRValue -> when (day) {
+        is SevenDayRValue -> when (updatedAtDate) {
             today -> context.getString(R.string.statistics_primary_value_current)
             yesterday -> context.getString(R.string.statistics_primary_value_yesterday)
-            else -> context.getString(R.string.statistics_primary_value_until, dateTimeFormatter.print(day))
+            else -> context.getString(R.string.statistics_primary_value_until, dateTimeFormatter.print(updatedAtDate))
         }
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactory.kt
index 2088d2fbe..de0c40995 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactory.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactory.kt
@@ -27,19 +27,29 @@ class CWAWorkerFactory @Inject constructor(
     ): ListenableWorker? {
         Timber.v("Checking in known worker factories for %s", workerClassName)
         val ourWorkerFactories = factories.entries.find {
-            Class.forName(workerClassName).isAssignableFrom(it.key)
+            try {
+                Class.forName(workerClassName).isAssignableFrom(it.key)
+            } catch (e: ClassNotFoundException) {
+                Timber.e(e, "Failed to create worker class $workerClassName")
+                false
+            }
         }?.value
 
-        return if (ourWorkerFactories != null) {
+        if (ourWorkerFactories != null) {
             Timber.v("It's one of ours, creating worker for %s with %s", workerClassName, workerParameters)
-            ourWorkerFactories.get().create(appContext, workerParameters).also {
+            return ourWorkerFactories.get().create(appContext, workerParameters).also {
                 Timber.i("Our worker was created: %s", it)
             }
-        } else {
+        }
+
+        return try {
             Timber.w("Unknown worker class, trying direct instantiation on %s", workerClassName)
             workerClassName.toNewWorkerInstance(appContext, workerParameters).also {
                 Timber.i("Unknown worker was created: %s", it)
             }
+        } catch (e: ClassNotFoundException) {
+            Timber.w(e, "Failed to create unknown worker class: %s", workerClassName)
+            null
         }
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt
index 3cc59eabf..f93c59b45 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt
@@ -8,14 +8,14 @@ import de.rki.coronawarnapp.contactdiary.retention.ContactDiaryRetentionWorker
 import de.rki.coronawarnapp.datadonation.analytics.worker.DataDonationAnalyticsPeriodicWorker
 import de.rki.coronawarnapp.deadman.DeadmanNotificationOneTimeWorker
 import de.rki.coronawarnapp.deadman.DeadmanNotificationPeriodicWorker
+import de.rki.coronawarnapp.diagnosiskeys.execution.DiagnosisKeyRetrievalWorker
 import de.rki.coronawarnapp.eventregistration.storage.retention.TraceLocationDbCleanUpPeriodicWorker
 import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker
 import de.rki.coronawarnapp.presencetracing.checkins.checkout.auto.AutoCheckOutWorker
+import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningWorker
 import de.rki.coronawarnapp.submission.auto.SubmissionWorker
 import de.rki.coronawarnapp.worker.BackgroundNoiseOneTimeWorker
 import de.rki.coronawarnapp.worker.BackgroundNoisePeriodicWorker
-import de.rki.coronawarnapp.worker.DiagnosisKeyRetrievalOneTimeWorker
-import de.rki.coronawarnapp.worker.DiagnosisKeyRetrievalPeriodicWorker
 import de.rki.coronawarnapp.worker.DiagnosisTestResultRetrievalPeriodicWorker
 
 @Module
@@ -44,16 +44,9 @@ abstract class WorkerBinder {
 
     @Binds
     @IntoMap
-    @WorkerKey(DiagnosisKeyRetrievalOneTimeWorker::class)
+    @WorkerKey(DiagnosisKeyRetrievalWorker::class)
     abstract fun diagnosisKeyRetrievalOneTime(
-        factory: DiagnosisKeyRetrievalOneTimeWorker.Factory
-    ): InjectedWorkerFactory<out ListenableWorker>
-
-    @Binds
-    @IntoMap
-    @WorkerKey(DiagnosisKeyRetrievalPeriodicWorker::class)
-    abstract fun diagnosisKeyRetrievalPeriodic(
-        factory: DiagnosisKeyRetrievalPeriodicWorker.Factory
+        factory: DiagnosisKeyRetrievalWorker.Factory
     ): InjectedWorkerFactory<out ListenableWorker>
 
     @Binds
@@ -111,4 +104,11 @@ abstract class WorkerBinder {
     abstract fun traceLocationCleanUpWorker(
         factory: TraceLocationDbCleanUpPeriodicWorker.Factory
     ): InjectedWorkerFactory<out ListenableWorker>
+
+    @Binds
+    @IntoMap
+    @WorkerKey(PresenceTracingWarningWorker::class)
+    abstract fun traceWarningWorker(
+        factory: PresenceTracingWarningWorker.Factory
+    ): InjectedWorkerFactory<out ListenableWorker>
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt
index 75b991b69..7e7340ebb 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt
@@ -9,16 +9,6 @@ import java.util.concurrent.TimeUnit
  */
 object BackgroundConstants {
 
-    /**
-     * Tag for diagnosis key retrieval one time work
-     */
-    const val DIAGNOSIS_KEY_ONE_TIME_WORKER_TAG = "DIAGNOSIS_KEY_ONE_TIME_WORKER"
-
-    /**
-     * Tag for diagnosis key retrieval periodic work
-     */
-    const val DIAGNOSIS_KEY_PERIODIC_WORKER_TAG = "DIAGNOSIS_KEY_PERIODIC_WORKER"
-
     /**
      * Tag for background polling tp check test result periodic work
      */
@@ -34,16 +24,6 @@ object BackgroundConstants {
      */
     const val BACKGROUND_NOISE_ONE_TIME_WORKER_TAG = "BACKGROUND_NOISE_PERIODIC_WORKER"
 
-    /**
-     * Unique name for diagnosis key retrieval one time work
-     */
-    const val DIAGNOSIS_KEY_ONE_TIME_WORK_NAME = "DiagnosisKeyBackgroundOneTimeWork"
-
-    /**
-     * Unique name for diagnosis key retrieval periodic work
-     */
-    const val DIAGNOSIS_KEY_PERIODIC_WORK_NAME = "DiagnosisKeyBackgroundPeriodicWork"
-
     /**
      * Unique name for diagnosis test result retrieval periodic work
      */
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
index 31922abf4..2168b0287 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt
@@ -9,7 +9,6 @@ import dagger.assisted.AssistedInject
 import de.rki.coronawarnapp.submission.SubmissionSettings
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
-import de.rki.coronawarnapp.worker.BackgroundWorkScheduler.stop
 import org.joda.time.Duration
 import org.joda.time.Instant
 import timber.log.Timber
@@ -23,7 +22,8 @@ class BackgroundNoisePeriodicWorker @AssistedInject constructor(
     @Assisted val context: Context,
     @Assisted workerParams: WorkerParameters,
     private val submissionSettings: SubmissionSettings,
-    private val timeStamper: TimeStamper
+    private val timeStamper: TimeStamper,
+    private val backgroundWorkScheduler: BackgroundWorkScheduler,
 ) : CoroutineWorker(context, workerParams) {
 
     /**
@@ -45,7 +45,7 @@ class BackgroundNoisePeriodicWorker @AssistedInject constructor(
                 return result
             }
 
-            BackgroundWorkScheduler.scheduleBackgroundNoiseOneTimeWork()
+            backgroundWorkScheduler.scheduleBackgroundNoiseOneTimeWork()
         } catch (e: Exception) {
             result = if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
                 Result.failure()
@@ -58,7 +58,7 @@ class BackgroundNoisePeriodicWorker @AssistedInject constructor(
     }
 
     private fun stopWorker() {
-        BackgroundWorkScheduler.WorkType.BACKGROUND_NOISE_PERIODIC_WORK.stop()
+        backgroundWorkScheduler.stopBackgroundNoisePeriodicWork()
         Timber.tag(TAG).d("$id: worker stopped")
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt
index b109e8ba2..2c03e9544 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt
@@ -5,131 +5,84 @@ import androidx.work.OneTimeWorkRequestBuilder
 import androidx.work.PeriodicWorkRequestBuilder
 import de.rki.coronawarnapp.worker.BackgroundWorkScheduler.WorkTag
 import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+import javax.inject.Singleton
 
-/**
- * Build diagnosis key periodic work request
- * Set "kind delay" for accessibility reason.
- * Backoff criteria set to Linear type.
- *
- * The launchInterval is 60 minutes as we want to check every hour, for new hour packages on the CDN.
- *
- * @see WorkTag.DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER
- * @see BackgroundConstants.KIND_DELAY
- * @see BackgroundConstants.BACKOFF_INITIAL_DELAY
- * @see BackoffPolicy.LINEAR
- */
-fun buildDiagnosisKeyRetrievalPeriodicWork() =
-    PeriodicWorkRequestBuilder<DiagnosisKeyRetrievalPeriodicWorker>(60, TimeUnit.MINUTES)
-        .addTag(WorkTag.DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER.tag)
-        .setInitialDelay(
-            BackgroundConstants.KIND_DELAY,
-            TimeUnit.MINUTES
-        )
-        .setBackoffCriteria(
-            BackoffPolicy.EXPONENTIAL,
-            BackgroundConstants.BACKOFF_INITIAL_DELAY,
-            TimeUnit.MINUTES
-        )
-        .build()
+@Singleton
+class BackgroundWorkBuilder @Inject constructor() {
 
-/**
- * Build diagnosis key one time work request
- * Set random initial delay for security reason.
- * Backoff criteria set to Linear type.
- *
- * @return OneTimeWorkRequest
- *
- * @see WorkTag.DIAGNOSIS_KEY_RETRIEVAL_ONE_TIME_WORKER
- * @see buildDiagnosisKeyRetrievalOneTimeWork
- * @see BackgroundConstants.BACKOFF_INITIAL_DELAY
- * @see BackoffPolicy.LINEAR
- */
-fun buildDiagnosisKeyRetrievalOneTimeWork() =
-    OneTimeWorkRequestBuilder<DiagnosisKeyRetrievalOneTimeWorker>()
-        .addTag(WorkTag.DIAGNOSIS_KEY_RETRIEVAL_ONE_TIME_WORKER.tag)
-        .setConstraints(BackgroundWorkHelper.getConstraintsForDiagnosisKeyOneTimeBackgroundWork())
-        .setInitialDelay(
-            BackgroundConstants.KIND_DELAY,
-            TimeUnit.MINUTES
-        )
-        .setBackoffCriteria(
-            BackoffPolicy.EXPONENTIAL,
-            BackgroundConstants.BACKOFF_INITIAL_DELAY,
+    /**
+     * Build diagnosis Test Result periodic work request
+     * Set "kind delay" for accessibility reason.
+     *
+     * @return PeriodicWorkRequest
+     *
+     * @see WorkTag.DIAGNOSIS_TEST_RESULT_RETRIEVAL_PERIODIC_WORKER
+     * @see BackgroundConstants.KIND_DELAY
+     */
+    fun buildDiagnosisTestResultRetrievalPeriodicWork() =
+        PeriodicWorkRequestBuilder<DiagnosisTestResultRetrievalPeriodicWorker>(
+            BackgroundWorkHelper.getDiagnosisTestResultRetrievalPeriodicWorkTimeInterval(),
             TimeUnit.MINUTES
         )
-        .build()
+            .addTag(WorkTag.DIAGNOSIS_TEST_RESULT_RETRIEVAL_PERIODIC_WORKER.tag)
+            .setConstraints(BackgroundWorkHelper.getConstraintsForDiagnosisKeyOneTimeBackgroundWork())
+            .setInitialDelay(
+                BackgroundConstants.DIAGNOSIS_TEST_RESULT_PERIODIC_INITIAL_DELAY,
+                TimeUnit.SECONDS
+            ).setBackoffCriteria(
+                BackoffPolicy.LINEAR,
+                BackgroundConstants.KIND_DELAY,
+                TimeUnit.MINUTES
+            )
+            .build()
 
-/**
- * Build diagnosis Test Result periodic work request
- * Set "kind delay" for accessibility reason.
- *
- * @return PeriodicWorkRequest
- *
- * @see WorkTag.DIAGNOSIS_TEST_RESULT_RETRIEVAL_PERIODIC_WORKER
- * @see BackgroundConstants.KIND_DELAY
- */
-fun buildDiagnosisTestResultRetrievalPeriodicWork() =
-    PeriodicWorkRequestBuilder<DiagnosisTestResultRetrievalPeriodicWorker>(
-        BackgroundWorkHelper.getDiagnosisTestResultRetrievalPeriodicWorkTimeInterval(),
-        TimeUnit.MINUTES
-    )
-        .addTag(WorkTag.DIAGNOSIS_TEST_RESULT_RETRIEVAL_PERIODIC_WORKER.tag)
-        .setConstraints(BackgroundWorkHelper.getConstraintsForDiagnosisKeyOneTimeBackgroundWork())
-        .setInitialDelay(
-            BackgroundConstants.DIAGNOSIS_TEST_RESULT_PERIODIC_INITIAL_DELAY,
-            TimeUnit.SECONDS
-        ).setBackoffCriteria(
-            BackoffPolicy.LINEAR,
-            BackgroundConstants.KIND_DELAY,
-            TimeUnit.MINUTES
-        )
-        .build()
+    /**
+     * Build background noise one time work request
+     * Set BackgroundNoiseOneTimeWorkDelay for timing randomness.
+     *
+     * @return PeriodicWorkRequest
+     *
+     * @see WorkTag.BACKGROUND_NOISE_ONE_TIME_WORKER
+     * @see BackgroundWorkHelper.getBackgroundNoiseOneTimeWorkDelay
+     */
+    fun buildBackgroundNoiseOneTimeWork() =
+        OneTimeWorkRequestBuilder<BackgroundNoiseOneTimeWorker>()
+            .addTag(WorkTag.BACKGROUND_NOISE_ONE_TIME_WORKER.tag)
+            .setConstraints(BackgroundWorkHelper.getConstraintsForDiagnosisKeyOneTimeBackgroundWork())
+            .setInitialDelay(
+                BackgroundWorkHelper.getBackgroundNoiseOneTimeWorkDelay(),
+                TimeUnit.HOURS
+            ).setBackoffCriteria(
+                BackoffPolicy.LINEAR,
+                BackgroundConstants.KIND_DELAY,
+                TimeUnit.MINUTES
+            )
+            .build()
 
-/**
- * Build background noise one time work request
- * Set BackgroundNoiseOneTimeWorkDelay for timing randomness.
- *
- * @return PeriodicWorkRequest
- *
- * @see WorkTag.BACKGROUND_NOISE_ONE_TIME_WORKER
- * @see BackgroundWorkHelper.getBackgroundNoiseOneTimeWorkDelay
- */
-fun buildBackgroundNoiseOneTimeWork() =
-    OneTimeWorkRequestBuilder<BackgroundNoiseOneTimeWorker>()
-        .addTag(WorkTag.BACKGROUND_NOISE_ONE_TIME_WORKER.tag)
-        .setConstraints(BackgroundWorkHelper.getConstraintsForDiagnosisKeyOneTimeBackgroundWork())
-        .setInitialDelay(
-            BackgroundWorkHelper.getBackgroundNoiseOneTimeWorkDelay(),
+    /**
+     * Build background noise periodic work request
+     * Set "kind delay" for accessibility reason.
+     *
+     * @return PeriodicWorkRequest
+     *
+     * @see BackgroundConstants.MIN_HOURS_TO_NEXT_BACKGROUND_NOISE_EXECUTION
+     * @see WorkTag.BACKGROUND_NOISE_PERIODIC_WORKER
+     * @see BackgroundConstants.KIND_DELAY
+     */
+    fun buildBackgroundNoisePeriodicWork() =
+        PeriodicWorkRequestBuilder<BackgroundNoisePeriodicWorker>(
+            BackgroundConstants.MIN_HOURS_TO_NEXT_BACKGROUND_NOISE_EXECUTION,
             TimeUnit.HOURS
-        ).setBackoffCriteria(
-            BackoffPolicy.LINEAR,
-            BackgroundConstants.KIND_DELAY,
-            TimeUnit.MINUTES
-        )
-        .build()
-
-/**
- * Build background noise periodic work request
- * Set "kind delay" for accessibility reason.
- *
- * @return PeriodicWorkRequest
- *
- * @see BackgroundConstants.MIN_HOURS_TO_NEXT_BACKGROUND_NOISE_EXECUTION
- * @see WorkTag.BACKGROUND_NOISE_PERIODIC_WORKER
- * @see BackgroundConstants.KIND_DELAY
- */
-fun buildBackgroundNoisePeriodicWork() =
-    PeriodicWorkRequestBuilder<BackgroundNoisePeriodicWorker>(
-        BackgroundConstants.MIN_HOURS_TO_NEXT_BACKGROUND_NOISE_EXECUTION,
-        TimeUnit.HOURS
-    )
-        .addTag(WorkTag.BACKGROUND_NOISE_PERIODIC_WORKER.tag)
-        .setInitialDelay(
-            BackgroundConstants.KIND_DELAY,
-            TimeUnit.SECONDS
-        ).setBackoffCriteria(
-            BackoffPolicy.LINEAR,
-            BackgroundConstants.KIND_DELAY,
-            TimeUnit.MINUTES
         )
-        .build()
+            .addTag(WorkTag.BACKGROUND_NOISE_PERIODIC_WORKER.tag)
+            .setInitialDelay(
+                BackgroundConstants.KIND_DELAY,
+                TimeUnit.SECONDS
+            ).setBackoffCriteria(
+                BackoffPolicy.LINEAR,
+                BackgroundConstants.KIND_DELAY,
+                TimeUnit.MINUTES
+            )
+            .build()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkScheduler.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkScheduler.kt
index aaf6317d9..4f83979c3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkScheduler.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkScheduler.kt
@@ -6,9 +6,13 @@ import androidx.work.Operation
 import androidx.work.WorkInfo
 import androidx.work.WorkManager
 import de.rki.coronawarnapp.CoronaWarnApplication
-import de.rki.coronawarnapp.util.di.ApplicationComponent
+import de.rki.coronawarnapp.risk.execution.RiskWorkScheduler
+import de.rki.coronawarnapp.storage.TracingSettings
+import de.rki.coronawarnapp.submission.SubmissionSettings
 import timber.log.Timber
 import java.util.concurrent.ExecutionException
+import javax.inject.Inject
+import javax.inject.Singleton
 
 /**
  * Singleton class for background work handling
@@ -17,26 +21,24 @@ import java.util.concurrent.ExecutionException
  * @see BackgroundConstants
  * @see BackgroundWorkHelper
  */
-object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
-
-    fun init(component: ApplicationComponent) {
-        component.inject(this)
-    }
+@Singleton
+class BackgroundWorkScheduler @Inject constructor(
+    private val backgroundWorkBuilder: BackgroundWorkBuilder,
+    private val submissionSettings: SubmissionSettings,
+    private val tracingSettings: TracingSettings,
+    private val riskWorkScheduler: RiskWorkScheduler
+) {
 
     /**
      * Enum class for work tags
      *
      * @param tag the tag of the worker
      *
-     * @see BackgroundConstants.DIAGNOSIS_KEY_ONE_TIME_WORKER_TAG
-     * @see BackgroundConstants.DIAGNOSIS_KEY_PERIODIC_WORKER_TAG
      * @see BackgroundConstants.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER_TAG
      * @see BackgroundConstants.BACKGROUND_NOISE_ONE_TIME_WORKER_TAG
      * @see BackgroundConstants.BACKGROUND_NOISE_PERIODIC_WORKER_TAG
      */
     enum class WorkTag(val tag: String) {
-        DIAGNOSIS_KEY_RETRIEVAL_ONE_TIME_WORKER(BackgroundConstants.DIAGNOSIS_KEY_ONE_TIME_WORKER_TAG),
-        DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER(BackgroundConstants.DIAGNOSIS_KEY_PERIODIC_WORKER_TAG),
         DIAGNOSIS_TEST_RESULT_RETRIEVAL_PERIODIC_WORKER(BackgroundConstants.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER_TAG),
         BACKGROUND_NOISE_ONE_TIME_WORKER(BackgroundConstants.BACKGROUND_NOISE_ONE_TIME_WORKER_TAG),
         BACKGROUND_NOISE_PERIODIC_WORKER(BackgroundConstants.BACKGROUND_NOISE_PERIODIC_WORKER_TAG)
@@ -47,15 +49,11 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
      *
      * @param uniqueName the unique name of specified work
      *
-     * @see BackgroundConstants.DIAGNOSIS_KEY_ONE_TIME_WORK_NAME
-     * @see BackgroundConstants.DIAGNOSIS_KEY_PERIODIC_WORK_NAME
      * @see BackgroundConstants.DIAGNOSIS_TEST_RESULT_PERIODIC_WORK_NAME
      * @see BackgroundConstants.BACKGROUND_NOISE_PERIODIC_WORK_NAME
      * @see BackgroundConstants.BACKGROUND_NOISE_ONE_TIME_WORK_NAME
      */
     enum class WorkType(val uniqueName: String) {
-        DIAGNOSIS_KEY_BACKGROUND_ONE_TIME_WORK(BackgroundConstants.DIAGNOSIS_KEY_ONE_TIME_WORK_NAME),
-        DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK(BackgroundConstants.DIAGNOSIS_KEY_PERIODIC_WORK_NAME),
         DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER(BackgroundConstants.DIAGNOSIS_TEST_RESULT_PERIODIC_WORK_NAME),
         BACKGROUND_NOISE_PERIODIC_WORK(BackgroundConstants.BACKGROUND_NOISE_PERIODIC_WORK_NAME),
         BACKGROUND_NOISE_ONE_TIME_WORK(BackgroundConstants.BACKGROUND_NOISE_ONE_TIME_WORK_NAME)
@@ -75,17 +73,9 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
      * @see isWorkActive
      */
     fun startWorkScheduler() {
-        val notificationBody = StringBuilder()
-        notificationBody.append("Jobs starting: ")
-        val isPeriodicWorkActive = isWorkActive(WorkTag.DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER.tag)
-        logWorkActiveStatus(
-            WorkTag.DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER.tag,
-            isPeriodicWorkActive
-        )
-        if (!isPeriodicWorkActive) {
-            WorkType.DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK.start()
-            notificationBody.append("[DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK] ")
-        }
+        Timber.d("startWorkScheduler()")
+        riskWorkScheduler.setPeriodicRiskCalculation(enabled = true)
+
         if (!submissionSettings.isSubmissionSuccessful) {
             if (!isWorkActive(WorkTag.DIAGNOSIS_TEST_RESULT_RETRIEVAL_PERIODIC_WORKER.tag) &&
                 submissionSettings.registrationToken.value != null &&
@@ -93,10 +83,22 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
             ) {
                 WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.start()
                 tracingSettings.initialPollingForTestResultTimeStamp = System.currentTimeMillis()
-                notificationBody.append("[DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER]")
+                Timber.d("Starting DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER")
             }
         }
-        Timber.d("Background Job Starting: %s", notificationBody)
+    }
+
+    /**
+     * Stop work scheduler
+     * Stops all background work by tag.
+     */
+    fun stopWorkScheduler() {
+        WorkTag.values().map { workTag: WorkTag ->
+            workManager.cancelAllWorkByTag(workTag.tag)
+                .also { it.logOperationCancelByTag(workTag) }
+        }
+        riskWorkScheduler.setPeriodicRiskCalculation(enabled = false)
+        Timber.d("All Background Jobs Stopped")
     }
 
     /**
@@ -139,34 +141,12 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
         return result
     }
 
-    /**
-     * Stop work scheduler
-     * Stops all background work by tag.
-     */
-    fun stopWorkScheduler() {
-        WorkTag.values().map { workTag: WorkTag ->
-            workManager.cancelAllWorkByTag(workTag.tag)
-                .also { it.logOperationCancelByTag(workTag) }
-        }
-        Timber.d("All Background Jobs Stopped")
+    fun scheduleDiagnosisTestResultPeriodicWork() {
+        WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.start()
     }
 
-    /**
-     * Schedule diagnosis key periodic time work
-     *
-     * @see WorkType.DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK
-     */
-    fun scheduleDiagnosisKeyPeriodicWork() {
-        WorkType.DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK.start()
-    }
-
-    /**
-     * Schedule diagnosis key one time work
-     *
-     * @see WorkType.DIAGNOSIS_KEY_BACKGROUND_ONE_TIME_WORK
-     */
-    fun scheduleDiagnosisKeyOneTimeWork() {
-        WorkType.DIAGNOSIS_KEY_BACKGROUND_ONE_TIME_WORK.start()
+    fun stopDiagnosisTestResultPeriodicWork() {
+        WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop()
     }
 
     /**
@@ -178,6 +158,10 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
         WorkType.BACKGROUND_NOISE_PERIODIC_WORK.start()
     }
 
+    fun stopBackgroundNoisePeriodicWork() {
+        WorkType.BACKGROUND_NOISE_PERIODIC_WORK.start()
+    }
+
     /**
      * Schedule background noise one time work
      *
@@ -195,41 +179,11 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
      * @see WorkType
      */
     private fun WorkType.start(): Operation = when (this) {
-        WorkType.DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK -> enqueueDiagnosisKeyBackgroundPeriodicWork()
-        WorkType.DIAGNOSIS_KEY_BACKGROUND_ONE_TIME_WORK -> enqueueDiagnosisKeyBackgroundOneTimeWork()
         WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER -> enqueueDiagnosisTestResultBackgroundPeriodicWork()
         WorkType.BACKGROUND_NOISE_PERIODIC_WORK -> enqueueBackgroundNoisePeriodicWork()
         WorkType.BACKGROUND_NOISE_ONE_TIME_WORK -> enqueueBackgroundNoiseOneTimeWork()
     }
 
-    /**
-     * Enqueue diagnosis key periodic work and log it
-     * Replace with new if older work exists.
-     *
-     * @return Operation
-     *
-     * @see WorkType.DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK
-     */
-    private fun enqueueDiagnosisKeyBackgroundPeriodicWork() = workManager.enqueueUniquePeriodicWork(
-        WorkType.DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK.uniqueName,
-        ExistingPeriodicWorkPolicy.REPLACE,
-        buildDiagnosisKeyRetrievalPeriodicWork()
-    ).also { it.logOperationSchedule(WorkType.DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK) }
-
-    /**
-     * Enqueue diagnosis key one time work and log it
-     * Replace with new if older work exists.
-     *
-     * @return Operation
-     *
-     * @see WorkType.DIAGNOSIS_KEY_BACKGROUND_PERIODIC_WORK
-     */
-    private fun enqueueDiagnosisKeyBackgroundOneTimeWork() = workManager.enqueueUniqueWork(
-        WorkType.DIAGNOSIS_KEY_BACKGROUND_ONE_TIME_WORK.uniqueName,
-        ExistingWorkPolicy.REPLACE,
-        buildDiagnosisKeyRetrievalOneTimeWork()
-    ).also { it.logOperationSchedule(WorkType.DIAGNOSIS_KEY_BACKGROUND_ONE_TIME_WORK) }
-
     /**
      * Enqueue diagnosis Test Result periodic
      * Show a Notification when new Test Results are in.
@@ -243,7 +197,7 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
         workManager.enqueueUniquePeriodicWork(
             WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.uniqueName,
             ExistingPeriodicWorkPolicy.REPLACE,
-            buildDiagnosisTestResultRetrievalPeriodicWork()
+            backgroundWorkBuilder.buildDiagnosisTestResultRetrievalPeriodicWork()
         ).also { it.logOperationSchedule(WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER) }
 
     /**
@@ -258,7 +212,7 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
         workManager.enqueueUniquePeriodicWork(
             WorkType.BACKGROUND_NOISE_PERIODIC_WORK.uniqueName,
             ExistingPeriodicWorkPolicy.REPLACE,
-            buildBackgroundNoisePeriodicWork()
+            backgroundWorkBuilder.buildBackgroundNoisePeriodicWork()
         ).also { it.logOperationSchedule(WorkType.BACKGROUND_NOISE_PERIODIC_WORK) }
 
     /**
@@ -273,7 +227,7 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
         workManager.enqueueUniqueWork(
             WorkType.BACKGROUND_NOISE_ONE_TIME_WORK.uniqueName,
             ExistingWorkPolicy.REPLACE,
-            buildBackgroundNoiseOneTimeWork()
+            backgroundWorkBuilder.buildBackgroundNoiseOneTimeWork()
         ).also { it.logOperationSchedule(WorkType.BACKGROUND_NOISE_ONE_TIME_WORK) }
 
     /**
@@ -293,11 +247,4 @@ object BackgroundWorkScheduler : BackgroundWorkSchedulerBase() {
             { Timber.d("All work with tag ${workTag.tag} canceled.") },
             { it.run() }
         ).also { Timber.d("Canceling all work with tag ${workTag.tag}") }
-
-    /**
-     * Log work active status
-     */
-    private fun logWorkActiveStatus(tag: String, active: Boolean) {
-        Timber.d("Work type $tag is active: $active")
-    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkSchedulerBase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkSchedulerBase.kt
deleted file mode 100644
index 9085a1645..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkSchedulerBase.kt
+++ /dev/null
@@ -1,11 +0,0 @@
-package de.rki.coronawarnapp.worker
-
-import de.rki.coronawarnapp.storage.TracingSettings
-import de.rki.coronawarnapp.submission.SubmissionSettings
-import javax.inject.Inject
-
-@Suppress("UnnecessaryAbstractClass")
-abstract class BackgroundWorkSchedulerBase {
-    @Inject internal lateinit var submissionSettings: SubmissionSettings
-    @Inject internal lateinit var tracingSettings: TracingSettings
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
deleted file mode 100644
index 5a59a5b2f..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-package de.rki.coronawarnapp.worker
-
-import android.content.Context
-import androidx.work.CoroutineWorker
-import androidx.work.WorkerParameters
-import dagger.assisted.Assisted
-import dagger.assisted.AssistedFactory
-import dagger.assisted.AssistedInject
-import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
-import timber.log.Timber
-
-/**
- * Periodic diagnosis key retrieval work
- * Executes the scheduling of one time diagnosis key retrieval work
- *
- * @see BackgroundWorkScheduler
- * @see DiagnosisKeyRetrievalOneTimeWorker
- */
-class DiagnosisKeyRetrievalPeriodicWorker @AssistedInject constructor(
-    @Assisted val context: Context,
-    @Assisted workerParams: WorkerParameters
-) : CoroutineWorker(context, workerParams) {
-
-    /**
-     * @see BackgroundWorkScheduler.scheduleDiagnosisKeyPeriodicWork()
-     * @see BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()
-     */
-    override suspend fun doWork(): Result {
-        Timber.tag(TAG).d("$id: doWork() started. Run attempt: $runAttemptCount")
-
-        var result = Result.success()
-        try {
-            BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()
-        } catch (e: Exception) {
-            Timber.tag(TAG).w(
-                e,
-                "$id: Error during BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()."
-            )
-
-            if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
-                Timber.tag(TAG).w(e, "$id: Retry attempts exceeded.")
-
-                return Result.failure()
-            } else {
-                Timber.tag(TAG).d(e, "$id: Retrying.")
-                result = Result.retry()
-            }
-        }
-
-        Timber.tag(TAG).d("$id: doWork() finished with %s", result)
-        return result
-    }
-
-    @AssistedFactory
-    interface Factory : InjectedWorkerFactory<DiagnosisKeyRetrievalPeriodicWorker>
-
-    companion object {
-        private val TAG = DiagnosisKeyRetrievalPeriodicWorker::class.java.simpleName
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
index cd80fd710..348777db1 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt
@@ -17,7 +17,6 @@ import de.rki.coronawarnapp.util.TimeAndDateExtensions
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.formatter.TestResult
 import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory
-import de.rki.coronawarnapp.worker.BackgroundWorkScheduler.stop
 import timber.log.Timber
 
 /**
@@ -34,6 +33,7 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
     private val submissionService: SubmissionService,
     private val timeStamper: TimeStamper,
     private val tracingSettings: TracingSettings,
+    private val backgroundWorkScheduler: BackgroundWorkScheduler,
 ) : CoroutineWorker(context, workerParams) {
 
     override suspend fun doWork(): Result {
@@ -42,7 +42,7 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
         if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
             Timber.tag(TAG).d("$id doWork() failed after $runAttemptCount attempts. Rescheduling")
 
-            BackgroundWorkScheduler.scheduleDiagnosisKeyPeriodicWork()
+            backgroundWorkScheduler.scheduleDiagnosisTestResultPeriodicWork()
             Timber.tag(TAG).d("$id Rescheduled background worker")
 
             return Result.failure()
@@ -117,7 +117,7 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor(
 
     private fun stopWorker() {
         tracingSettings.initialPollingForTestResultTimeStamp = 0L
-        BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop()
+        backgroundWorkScheduler.stopDiagnosisTestResultPeriodicWork()
         Timber.tag(TAG).d("$id: Background worker stopped")
     }
 
diff --git a/Corona-Warn-App/src/main/res/drawable/ic_print.xml b/Corona-Warn-App/src/main/res/drawable/ic_print.xml
index 1f420bdc3..90691b5cc 100644
--- a/Corona-Warn-App/src/main/res/drawable/ic_print.xml
+++ b/Corona-Warn-App/src/main/res/drawable/ic_print.xml
@@ -3,7 +3,7 @@
     android:height="18dp"
     android:viewportWidth="20"
     android:viewportHeight="18">
-  <path
-      android:pathData="M17,5H3C1.34,5 0,6.34 0,8V14H4V18H16V14H20V8C20,6.34 18.66,5 17,5ZM14,16H6V11H14V16ZM17,9C16.45,9 16,8.55 16,8C16,7.45 16.45,7 17,7C17.55,7 18,7.45 18,8C18,8.55 17.55,9 17,9ZM16,0H4V4H16V0Z"
-      android:fillColor="#757575"/>
+    <path
+        android:pathData="M17,5H3C1.34,5 0,6.34 0,8V14H4V18H16V14H20V8C20,6.34 18.66,5 17,5ZM14,16H6V11H14V16ZM17,9C16.45,9 16,8.55 16,8C16,7.45 16.45,7 17,7C17.55,7 18,7.45 18,8C18,8.55 17.55,9 17,9ZM16,0H4V4H16V0Z"
+        android:fillColor="#757575" />
 </vector>
diff --git a/Corona-Warn-App/src/main/res/drawable/ic_share.xml b/Corona-Warn-App/src/main/res/drawable/ic_share.xml
index 2fde22226..2ed6672d4 100644
--- a/Corona-Warn-App/src/main/res/drawable/ic_share.xml
+++ b/Corona-Warn-App/src/main/res/drawable/ic_share.xml
@@ -3,7 +3,7 @@
     android:height="20dp"
     android:viewportWidth="18"
     android:viewportHeight="20">
-  <path
-      android:pathData="M15,14.08C14.24,14.08 13.56,14.38 13.04,14.85L5.91,10.7C5.96,10.47 6,10.24 6,10C6,9.76 5.96,9.53 5.91,9.3L12.96,5.19C13.5,5.69 14.21,6 15,6C16.66,6 18,4.66 18,3C18,1.34 16.66,0 15,0C13.34,0 12,1.34 12,3C12,3.24 12.04,3.47 12.09,3.7L5.04,7.81C4.5,7.31 3.79,7 3,7C1.34,7 0,8.34 0,10C0,11.66 1.34,13 3,13C3.79,13 4.5,12.69 5.04,12.19L12.16,16.35C12.11,16.56 12.08,16.78 12.08,17C12.08,18.61 13.39,19.92 15,19.92C16.61,19.92 17.92,18.61 17.92,17C17.92,15.39 16.61,14.08 15,14.08Z"
-      android:fillColor="#757575"/>
+    <path
+        android:pathData="M15,14.08C14.24,14.08 13.56,14.38 13.04,14.85L5.91,10.7C5.96,10.47 6,10.24 6,10C6,9.76 5.96,9.53 5.91,9.3L12.96,5.19C13.5,5.69 14.21,6 15,6C16.66,6 18,4.66 18,3C18,1.34 16.66,0 15,0C13.34,0 12,1.34 12,3C12,3.24 12.04,3.47 12.09,3.7L5.04,7.81C4.5,7.31 3.79,7 3,7C1.34,7 0,8.34 0,10C0,11.66 1.34,13 3,13C3.79,13 4.5,12.69 5.04,12.19L12.16,16.35C12.11,16.56 12.08,16.78 12.08,17C12.08,18.61 13.39,19.92 15,19.92C16.61,19.92 17.92,18.61 17.92,17C17.92,15.39 16.61,14.08 15,14.08Z"
+        android:fillColor="#757575" />
 </vector>
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 0ad2d0822..7c0754dd1 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
@@ -17,8 +17,8 @@ import de.rki.coronawarnapp.contactdiary.util.ContactDiaryData
 import de.rki.coronawarnapp.contactdiary.util.mockStringsForContactDiaryExporterTests
 import de.rki.coronawarnapp.eventregistration.checkins.CheckIn
 import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository
+import de.rki.coronawarnapp.presencetracing.risk.TraceLocationCheckInRisk
 import de.rki.coronawarnapp.risk.RiskState
-import de.rki.coronawarnapp.risk.TraceLocationCheckInRisk
 import de.rki.coronawarnapp.risk.result.ExposureWindowDayRisk
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInRepositoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInRepositoryTest.kt
index e869c2869..0298f03ae 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInRepositoryTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInRepositoryTest.kt
@@ -48,7 +48,6 @@ class CheckInRepositoryTest : BaseTest() {
             val checkIn = CheckIn(
                 id = 1L,
                 traceLocationId = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode(),
-                traceLocationIdHash = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode(),
                 version = 1,
                 type = 2,
                 description = "brothers birthday",
@@ -77,7 +76,6 @@ class CheckInRepositoryTest : BaseTest() {
                 CheckIn(
                     id = 0L,
                     traceLocationId = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode(),
-                    traceLocationIdHash = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode(),
                     version = 1,
                     type = 2,
                     description = "brothers birthday",
@@ -98,7 +96,6 @@ class CheckInRepositoryTest : BaseTest() {
                     TraceLocationCheckInEntity(
                         id = 0L,
                         traceLocationIdBase64 = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode().base64(),
-                        traceLocationIdHashBase64 = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode().base64(),
                         version = 1,
                         type = 2,
                         description = "brothers birthday",
@@ -143,7 +140,6 @@ class CheckInRepositoryTest : BaseTest() {
             TraceLocationCheckInEntity(
                 id = 1L,
                 traceLocationIdBase64 = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode().base64(),
-                traceLocationIdHashBase64 = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode().base64(),
                 version = 1,
                 type = 2,
                 description = "sisters birthday",
@@ -164,7 +160,6 @@ class CheckInRepositoryTest : BaseTest() {
                 CheckIn(
                     id = 1L,
                     traceLocationId = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode(),
-                    traceLocationIdHash = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode(),
                     version = 1,
                     type = 2,
                     description = "sisters birthday",
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInTransmissionRiskLevelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInTransmissionRiskLevelTest.kt
index bafd28dd1..6439da3b9 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInTransmissionRiskLevelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInTransmissionRiskLevelTest.kt
@@ -12,7 +12,6 @@ class CheckInTransmissionRiskLevelTest : BaseTest() {
     private val checkIn = CheckIn(
         id = 1L,
         traceLocationId = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode(),
-        traceLocationIdHash = "41da2115-eba2-49bd-bf17-adb3d635ddaf".encode(),
         version = 1,
         type = 2,
         description = "restaurant_1",
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInsTransformerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInsTransformerTest.kt
index 14d50535d..53cf1f3f4 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInsTransformerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/CheckInsTransformerTest.kt
@@ -42,7 +42,6 @@ class CheckInsTransformerTest : BaseTest() {
     private val checkIn1 = CheckIn(
         id = 1L,
         traceLocationId = "traceLocationId1".encode(),
-        traceLocationIdHash = "traceLocationIdHash1".encode(),
         version = 1,
         type = 1,
         description = "restaurant_1",
@@ -67,7 +66,6 @@ class CheckInsTransformerTest : BaseTest() {
     private val checkIn2 = CheckIn(
         id = 2L,
         traceLocationId = "traceLocationId2".encode(),
-        traceLocationIdHash = "traceLocationIdHash2".encode(),
         version = 1,
         type = 2,
         description = "restaurant_2",
@@ -87,7 +85,6 @@ class CheckInsTransformerTest : BaseTest() {
     private val checkIn3 = CheckIn(
         id = 3L,
         traceLocationId = "traceLocationId3".encode(),
-        traceLocationIdHash = "traceLocationIdHash3".encode(),
         version = 1,
         type = 3,
         description = "restaurant_3",
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/split/CheckInSplitterTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/split/CheckInSplitterTest.kt
index dc037d1ca..8c76d64b9 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/split/CheckInSplitterTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/split/CheckInSplitterTest.kt
@@ -16,7 +16,6 @@ class CheckInSplitterTest : BaseTest() {
     private val defaultCheckIn = CheckIn(
         id = 1L,
         traceLocationId = "traceLocationId1".encode(),
-        traceLocationIdHash = "traceLocationIdHash1".encode(),
         version = 1,
         type = 1,
         description = "Restaurant",
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/events/TraceLocationIdTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/events/TraceLocationIdTest.kt
index bc1c62af2..f92345e30 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/events/TraceLocationIdTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/events/TraceLocationIdTest.kt
@@ -1,50 +1,58 @@
 package de.rki.coronawarnapp.eventregistration.events
 
 import de.rki.coronawarnapp.eventregistration.checkins.qrcode.TraceLocation
+import de.rki.coronawarnapp.eventregistration.checkins.qrcode.TraceLocationId
+import de.rki.coronawarnapp.eventregistration.checkins.qrcode.qrCodePayload
+import de.rki.coronawarnapp.eventregistration.checkins.qrcode.toTraceLocationIdHash
 import de.rki.coronawarnapp.eventregistration.checkins.qrcode.traceLocation
 import de.rki.coronawarnapp.server.protocols.internal.pt.TraceLocationOuterClass
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.secondsToInstant
 import io.kotest.matchers.shouldBe
+import okio.ByteString
 import okio.ByteString.Companion.decodeBase64
+import okio.ByteString.Companion.decodeHex
+import okio.ByteString.Companion.toByteString
+import org.joda.time.Instant
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 
+@Suppress("MaxLineLength")
 class TraceLocationIdTest : BaseTest() {
     @Test
     fun `locationId from qrCodePayloadBase64 - 1`() {
         val qrCodePayloadBase64 =
-            "CAESLAgBEhFNeSBCaXJ0aGRheSBQYXJ0eRoLYXQgbXkgcGxhY2Uo04ekATD3h6QBGmUIARJbMFkwEwYHKoZIzj0CAQYIKo" +
-                "ZIzj0DAQcDQgAEc7DEstcUIRcyk35OYDJ95/hTg3UVhsaDXKT0zK7NhHPXoyzipEnOp3GyNXDVpaPi3cAfQmxe" +
-                "uFMZAIX2+6A5XhoEMTIzNCIECAEQAg=="
+            "CAESLAgBEhFNeSBCaXJ0aGRheSBQYXJ0eRoLYXQgbXkgcGxhY2Uo04ekATD3h6QBGmUIARJbMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc7DEstcUIRcyk35OYDJ95/hTg3UVhsaDXKT0zK7NhHPXoyzipEnOp3GyNXDVpaPi3cAfQmxeuFMZAIX2+6A5XhoEMTIzNCIECAEQAg=="
         val qrCodePayload = TraceLocationOuterClass.QRCodePayload.parseFrom(
             qrCodePayloadBase64.decodeBase64()!!.toByteArray()
         )
-        qrCodePayload.traceLocation().locationId.base64() shouldBe "jNcJTCajd9Sen6Tbexl2Yb7O3J7ps47b6k4+QMT4xS0="
+        qrCodePayload.traceLocation().apply {
+            locationId.base64() shouldBe "jNcJTCajd9Sen6Tbexl2Yb7O3J7ps47b6k4+QMT4xS0="
+        }
     }
 
     @Test
     fun `locationId from qrCodePayloadBase64 - 2`() {
         val qrCodePayloadBase64 =
-            "CAESIAgBEg1JY2VjcmVhbSBTaG9wGg1NYWluIFN0cmVldCAxGmUIARJbMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc7DEstcUIR" +
-                "cyk35OYDJ95/hTg3UVhsaDXKT0zK7NhHPXoyzipEnOp3GyNXDVpaPi3cAfQmxeuFMZAIX2+6A5XhoEMTIzNCIGCAEQARgK"
+            "CAESIAgBEg1JY2VjcmVhbSBTaG9wGg1NYWluIFN0cmVldCAxGmUIARJbMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc7DEstcUIRcyk35OYDJ95/hTg3UVhsaDXKT0zK7NhHPXoyzipEnOp3GyNXDVpaPi3cAfQmxeuFMZAIX2+6A5XhoEMTIzNCIGCAEQARgK"
         val qrCodePayload = TraceLocationOuterClass.QRCodePayload.parseFrom(
             qrCodePayloadBase64.decodeBase64()!!.toByteArray()
         )
-        qrCodePayload.traceLocation().locationId.base64() shouldBe "GMuCjqNmOdYyrFhyvFNTVEeLaZh+uShgUoY0LYJo4YQ="
+        qrCodePayload.traceLocation().apply {
+            locationId.base64() shouldBe "GMuCjqNmOdYyrFhyvFNTVEeLaZh+uShgUoY0LYJo4YQ="
+        }
     }
 
     @Test
     fun `locationId from traceLocation - 1`() {
         val traceLocation = TraceLocation(
-            id = 1,
             type = TraceLocationOuterClass.TraceLocationType.LOCATION_TYPE_TEMPORARY_OTHER,
             description = "My Birthday Party",
             address = "at my place",
             startDate = 2687955L.secondsToInstant(),
             endDate = 2687991L.secondsToInstant(),
             defaultCheckInLengthInMinutes = null,
-            cryptographicSeed = CRYPTOGRAPHIC_SEED.decodeBase64()!!,
-            cnPublicKey = PUB_KEY,
+            cryptographicSeed = "MTIzNA==".decodeBase64()!!,
+            cnPublicKey = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc7DEstcUIRcyk35OYDJ95/hTg3UVhsaDXKT0zK7NhHPXoyzipEnOp3GyNXDVpaPi3cAfQmxeuFMZAIX2+6A5Xg==",
             version = TraceLocation.VERSION
         )
         traceLocation.locationId.base64() shouldBe "jNcJTCajd9Sen6Tbexl2Yb7O3J7ps47b6k4+QMT4xS0="
@@ -53,25 +61,185 @@ class TraceLocationIdTest : BaseTest() {
     @Test
     fun `locationId from traceLocation - 2`() {
         val traceLocation = TraceLocation(
-            id = 2,
             type = TraceLocationOuterClass.TraceLocationType.LOCATION_TYPE_PERMANENT_OTHER,
             description = "Icecream Shop",
             address = "Main Street 1",
             startDate = null,
             endDate = null,
             defaultCheckInLengthInMinutes = 10,
-            cryptographicSeed = CRYPTOGRAPHIC_SEED.decodeBase64()!!,
-            cnPublicKey = PUB_KEY,
+            cryptographicSeed = "MTIzNA==".decodeBase64()!!,
+            cnPublicKey = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc7DEstcUIRcyk35OYDJ95/hTg3UVhsaDXKT0zK7NhHPXoyzipEnOp3GyNXDVpaPi3cAfQmxeuFMZAIX2+6A5Xg==",
             version = TraceLocation.VERSION
         )
 
         traceLocation.locationId.base64() shouldBe "GMuCjqNmOdYyrFhyvFNTVEeLaZh+uShgUoY0LYJo4YQ="
     }
 
-    companion object {
-        private const val CRYPTOGRAPHIC_SEED = "MTIzNA=="
-        private const val PUB_KEY =
-            "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc7DEstcUIRcyk35OYDJ95/hTg3UVhsaDXKT0z" +
-                "K7NhHPXoyzipEnOp3GyNXDVpaPi3cAfQmxeuFMZAIX2+6A5Xg=="
+    /**
+     * Match server calculation
+     * https://github.com/corona-warn-app/cwa-server/blob/5ce7d27a74fbf4f2ed560772f97ac17e2189ad33/common/persistence/src/test/java/app/coronawarn/server/common/persistence/service/TraceTimeIntervalWarningServiceTest.java#L141
+     */
+    @Test
+    fun `test trace location hash generation`() {
+        val locationId = "afa27b44d43b02a9fea41d13cedc2e4016cfcf87c5dbf990e593669aa8ce286d"
+        val locationIdByte: ByteString = locationId.decodeHex()
+        val hashedLocationId: ByteString = locationIdByte.sha256()
+
+        val expectedLocationIdHash = "0f37dac11d1b8118ea0b44303400faa5e3b876da9d758058b5ff7dc2e5da8230"
+
+        hashedLocationId.hex() shouldBe expectedLocationIdHash
+        hashedLocationId shouldBe expectedLocationIdHash.decodeHex()
+    }
+
+    @Test
+    fun `test trace location hash generation - 2`() {
+        val id: TraceLocationId = "1b02111da7c0799df6ad67deb7b397bdfb07e63da0fdea30fae335762826e34f".decodeHex()
+        id.toTraceLocationIdHash().hex() shouldBe "394db434a2e9c2ca9f32eed266d30bc037b4314cb3d53249fada68de45450cbb"
+    }
+
+    @Test
+    fun `test trace location hash generation - 3`() {
+        val id: TraceLocationId = "14c7a20ed81ebabdc32c8521382c56b851af15ccd8d13c86cd91a0620e78d664".decodeHex()
+        id.toTraceLocationIdHash().hex() shouldBe "852475fa271a29c67ad85578bb86ff4922dabf9f2b081353e1b5cdf99442889d"
+    }
+
+    @Test
+    fun `turn location ID into location ID hash`() {
+        val traceLocationIdHex = "afa27b44d43b02a9fea41d13cedc2e4016cfcf87c5dbf990e593669aa8ce286d"
+        val expectedLocationIdHashHex = "0f37dac11d1b8118ea0b44303400faa5e3b876da9d758058b5ff7dc2e5da8230"
+
+        val traceLocationId: TraceLocationId = traceLocationIdHex.decodeHex()
+        traceLocationId.toTraceLocationIdHash() shouldBe expectedLocationIdHashHex.decodeHex()
+    }
+
+    @Test
+    fun `mock server test data - 1`() {
+        val qrCodePayloadBase64 =
+            "CAESKAgBEhRBcHBsZSBDb21wdXRlciwgSW5jLhoOMTk1OCBGb2hlIFJvYWQadggBEmA4xNrp5hKJoO_yVbXfF1gS8Yc5nURhOIVLG3nUcSg8IPsI2e8JSIhg-FrHUymQ3RR80KUKb1lZjLQkfTUINUP16r6-jFDURwUlCQQi6NXCgI0rQw0a4MrVrKMbF4NzhQMaENXiVYke5XY0HddkDmj-3HYiBwgBEAQYqQE"
+        val expectedLocationIdHex = "fc925439f45417a14403b25c95fdc6d8711653f8aa08c0d0967bd30a6348c7fc"
+        val expectedLocationIdBase64 = "/JJUOfRUF6FEA7Jclf3G2HEWU/iqCMDQlnvTCmNIx/w="
+
+        val qrCodePayload = TraceLocationOuterClass.QRCodePayload.parseFrom(
+            qrCodePayloadBase64.decodeBase64()!!.toByteArray()
+        )
+
+        // Payload -> locationId (direct from raw)
+        val payloadBytes = qrCodePayloadBase64.decodeBase64()!!.toByteArray()
+        val totalByteSequence = "CWA-GUID".toByteArray() + payloadBytes
+        totalByteSequence.toByteString().sha256().base64() shouldBe expectedLocationIdBase64
+
+        // TraceLocation -> locationId (manual reconstruction)
+        val traceLocation = TraceLocation(
+            type = TraceLocationOuterClass.TraceLocationType.LOCATION_TYPE_PERMANENT_FOOD_SERVICE,
+            description = "Apple Computer, Inc.",
+            address = "1958 Fohe Road",
+            startDate = null,
+            endDate = null,
+            defaultCheckInLengthInMinutes = 169,
+            cryptographicSeed = "1eJViR7ldjQd12QOaP7cdg==".decodeBase64()!!,
+            cnPublicKey = "OMTa6eYSiaDv8lW13xdYEvGHOZ1EYTiFSxt51HEoPCD7CNnvCUiIYPhax1MpkN0UfNClCm9ZWYy0JH01CDVD9eq+voxQ1EcFJQkEIujVwoCNK0MNGuDK1ayjGxeDc4UD",
+            version = TraceLocation.VERSION
+        ).apply {
+            locationId.base64() shouldBe expectedLocationIdBase64
+            locationId.hex() shouldBe expectedLocationIdHex
+        }
+
+        // Full circle: Payload -> tracelocation -> qrpayload -> bytearray -> locationId
+        qrCodePayload.traceLocation().apply {
+            description shouldBe "Apple Computer, Inc."
+            locationId.base64() shouldBe expectedLocationIdBase64
+            locationId.hex() shouldBe expectedLocationIdHex
+        }
+
+        // ByteArrays actually match, we construct the data the same way.
+        qrCodePayload.toByteArray() shouldBe traceLocation.qrCodePayload().toByteArray()
+    }
+
+    @Test
+    fun `mock server test data - 2`() {
+        val qrCodePayloadBase64 =
+            "CAESKQgBEhRXYXN0ZSBNYW5hZ2VtZW50IEluYxoPMzc5IE96Zm9jIE1hbm9yGnYIARJgOMTa6eYSiaDv8lW13xdYEvGHOZ1EYTiFSxt51HEoPCD7CNnvCUiIYPhax1MpkN0UfNClCm9ZWYy0JH01CDVD9eq-voxQ1EcFJQkEIujVwoCNK0MNGuDK1ayjGxeDc4UDGhCUpoAjtgZJPU4suuQZ6dVUIgcIARAIGJ4E"
+        val expectedLocationIdHex = "2c4e7a3be61004ed952cc189e85039e01be65f3d82439c8c7fe0f23b12ffa523"
+        val expectedLocationIdBase64 = "LE56O+YQBO2VLMGJ6FA54BvmXz2CQ5yMf+DyOxL/pSM="
+
+        // Payload -> locationId (direct from raw)
+        val payloadBytes = qrCodePayloadBase64.decodeBase64()!!.toByteArray()
+        val totalByteSequence = "CWA-GUID".toByteArray() + payloadBytes
+        totalByteSequence.toByteString().sha256().base64() shouldBe expectedLocationIdBase64
+
+        // TraceLocation -> locationId (manual reconstruction)
+        val traceLocation = TraceLocation(
+            type = TraceLocationOuterClass.TraceLocationType.LOCATION_TYPE_PERMANENT_PUBLIC_BUILDING,
+            description = "Waste Management Inc",
+            address = "379 Ozfoc Manor",
+            startDate = null,
+            endDate = null,
+            defaultCheckInLengthInMinutes = 542,
+            cryptographicSeed = "lKaAI7YGST1OLLrkGenVVA==".decodeBase64()!!,
+            cnPublicKey = "OMTa6eYSiaDv8lW13xdYEvGHOZ1EYTiFSxt51HEoPCD7CNnvCUiIYPhax1MpkN0UfNClCm9ZWYy0JH01CDVD9eq+voxQ1EcFJQkEIujVwoCNK0MNGuDK1ayjGxeDc4UD",
+            version = TraceLocation.VERSION
+        ).apply {
+            locationId.base64() shouldBe expectedLocationIdBase64
+            locationId.hex() shouldBe expectedLocationIdHex
+        }
+
+        // Full circle: Payload -> tracelocation -> qrpayload -> bytearray -> locationId
+        val qrCodePayload = TraceLocationOuterClass.QRCodePayload.parseFrom(
+            qrCodePayloadBase64.decodeBase64()!!.toByteArray()
+        )
+
+        qrCodePayload.traceLocation().apply {
+            description shouldBe "Waste Management Inc"
+            locationId.base64() shouldBe expectedLocationIdBase64
+            locationId.hex() shouldBe expectedLocationIdHex
+        }
+
+        // ByteArrays actually match, we construct the data the same way.
+        qrCodePayload.toByteArray() shouldBe traceLocation.qrCodePayload().toByteArray()
+    }
+
+    @Test
+    fun `mock server test data - 3`() {
+        val qrCodePayloadBase64 =
+            "CAESMggBEg9NZXRhbHMgVVNBIEluYy4aETk2NiBEaXZ1ZCBIZWlnaHRzKLD6qYMGMNDZtoMGGnYIARJgOMTa6eYSiaDv8lW13xdYEvGHOZ1EYTiFSxt51HEoPCD7CNnvCUiIYPhax1MpkN0UfNClCm9ZWYy0JH01CDVD9eq-voxQ1EcFJQkEIujVwoCNK0MNGuDK1ayjGxeDc4UDGhD_mjsnT4b74d8M1P4cUflpIgcIARAKGOkJ"
+        val expectedLocationIdHex = "7051e04206ef9caf3a3165e82d0fec4cfe7ade770a2e01d9c0e456add760934d"
+        val expectedLocationIdBase64 = "cFHgQgbvnK86MWXoLQ/sTP563ncKLgHZwORWrddgk00="
+
+        // Payload -> locationId (direct from raw)
+        val payloadBytes = qrCodePayloadBase64.decodeBase64()!!.toByteArray()
+        val totalByteSequence = "CWA-GUID".toByteArray() + payloadBytes
+        totalByteSequence.toByteString().sha256().apply {
+            base64() shouldBe expectedLocationIdBase64
+        }
+
+        // TraceLocation -> locationId (manual reconstruction)
+        val traceLocation = TraceLocation(
+            type = TraceLocationOuterClass.TraceLocationType.LOCATION_TYPE_TEMPORARY_CLUB_ACTIVITY,
+            description = "Metals USA Inc.",
+            address = "966 Divud Heights",
+            startDate = Instant.ofEpochSecond(1617591600),
+            endDate = Instant.ofEpochSecond(1617800400),
+            defaultCheckInLengthInMinutes = 1257,
+            cryptographicSeed = "/5o7J0+G++HfDNT+HFH5aQ==".decodeBase64()!!,
+            cnPublicKey = "OMTa6eYSiaDv8lW13xdYEvGHOZ1EYTiFSxt51HEoPCD7CNnvCUiIYPhax1MpkN0UfNClCm9ZWYy0JH01CDVD9eq+voxQ1EcFJQkEIujVwoCNK0MNGuDK1ayjGxeDc4UD",
+            version = TraceLocation.VERSION
+        ).apply {
+            locationId.base64() shouldBe expectedLocationIdBase64
+            locationId.hex() shouldBe expectedLocationIdHex
+        }
+
+        // Full circle: Payload -> tracelocation -> qrpayload -> bytearray -> locationId
+        val qrCodePayload = TraceLocationOuterClass.QRCodePayload.parseFrom(
+            qrCodePayloadBase64.decodeBase64()!!.toByteArray()
+        )
+
+        qrCodePayload.traceLocation().apply {
+            description shouldBe "Metals USA Inc."
+            locationId.base64() shouldBe expectedLocationIdBase64
+            locationId.hex() shouldBe expectedLocationIdHex
+        }
+
+        // ByteArrays actually match, we construct the data the same way.
+        qrCodePayload.toByteArray() shouldBe traceLocation.qrCodePayload().toByteArray()
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/storage/retention/CheckInCleanerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/storage/retention/CheckInCleanerTest.kt
index 0dc18d477..c76619621 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/storage/retention/CheckInCleanerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/storage/retention/CheckInCleanerTest.kt
@@ -71,7 +71,6 @@ internal class CheckInCleanerTest : BaseTest() {
 
     private fun createCheckIn(checkOutDate: Instant) = CheckIn(
         traceLocationId = "traceLocationId1".encode(),
-        traceLocationIdHash = "traceLocationIdHash1".encode(),
         version = 1,
         type = 1,
         description = "",
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/CheckOutHandlerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/CheckOutHandlerTest.kt
index e420e844c..04fbb3a38 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/CheckOutHandlerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/CheckOutHandlerTest.kt
@@ -25,7 +25,6 @@ class CheckOutHandlerTest : BaseTest() {
     private val testCheckIn = CheckIn(
         id = 42L,
         traceLocationId = "traceLocationId1".encode(),
-        traceLocationIdHash = "traceLocationIdHash1".encode(),
         version = 1,
         type = 1,
         description = "Restaurant",
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/auto/AutoCheckOutTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/auto/AutoCheckOutTest.kt
index 41c1f47c9..4958a8cb2 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/auto/AutoCheckOutTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/checkins/checkout/auto/AutoCheckOutTest.kt
@@ -38,7 +38,6 @@ class AutoCheckOutTest : BaseTest() {
     private val baseCheckin = CheckIn(
         id = 0L,
         traceLocationId = "traceLocationId1".encode(),
-        traceLocationIdHash = "traceLocationIdHash1".encode(),
         version = 1,
         type = 2,
         description = "brothers birthday",
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/common/TraceLocationNotificationsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/common/TraceLocationNotificationsTest.kt
index 92c141796..f87f49945 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/common/TraceLocationNotificationsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/common/TraceLocationNotificationsTest.kt
@@ -37,7 +37,7 @@ class TraceLocationNotificationsTest : BaseTest() {
         }
     }
 
-    fun createInstance() = TraceLocationNotifications(
+    fun createInstance() = PresenceTracingNotifications(
         context = context,
         apiLevel = apiLevel,
         notificationManagerCompat = notificationManager
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/CheckInWarningMatcherTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/CheckInWarningMatcherTest.kt
deleted file mode 100644
index 1a3dcc048..000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/CheckInWarningMatcherTest.kt
+++ /dev/null
@@ -1,255 +0,0 @@
-package de.rki.coronawarnapp.presencetracing.risk
-
-import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository
-import de.rki.coronawarnapp.eventregistration.checkins.download.TraceTimeIntervalWarningPackage
-import de.rki.coronawarnapp.eventregistration.checkins.download.TraceTimeIntervalWarningRepository
-import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
-import de.rki.coronawarnapp.util.debug.measureTime
-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.flow.flowOf
-import kotlinx.coroutines.test.runBlockingTest
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
-import testhelpers.BaseTest
-import testhelpers.TestDispatcherProvider
-import timber.log.Timber
-
-class CheckInWarningMatcherTest : BaseTest() {
-
-    @MockK lateinit var checkInsRepository: CheckInRepository
-    @MockK lateinit var traceTimeIntervalWarningRepository: TraceTimeIntervalWarningRepository
-    @MockK lateinit var presenceTracingRiskRepository: PresenceTracingRiskRepository
-
-    @BeforeEach
-    fun setup() {
-        MockKAnnotations.init(this)
-        coEvery { presenceTracingRiskRepository.reportSuccessfulCalculation(any()) } just Runs
-        coEvery { presenceTracingRiskRepository.deleteAllMatches() } just Runs
-        coEvery { presenceTracingRiskRepository.deleteStaleData() } just Runs
-        // TODO tests
-        coEvery { presenceTracingRiskRepository.deleteMatchesOfPackage(any()) } just Runs
-        coEvery { presenceTracingRiskRepository.markPackageProcessed(any()) } just Runs
-    }
-
-    @Test
-    fun `reports new matches`() {
-        val checkIn1 = createCheckIn(
-            id = 2L,
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
-            startDateStr = "2021-03-04T10:15+01:00",
-            endDateStr = "2021-03-04T10:17+01:00"
-        )
-        val checkIn2 = createCheckIn(
-            id = 3L,
-            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
-            startDateStr = "2021-03-04T09:15+01:00",
-            endDateStr = "2021-03-04T10:12+01:00"
-        )
-
-        val warning1 = createWarning(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
-            startIntervalDateStr = "2021-03-04T10:00+01:00",
-            period = 6,
-            transmissionRiskLevel = 8
-        )
-
-        val warning2 = createWarning(
-            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
-            startIntervalDateStr = "2021-03-04T10:00+01:00",
-            period = 6,
-            transmissionRiskLevel = 8
-        )
-
-        every { checkInsRepository.allCheckIns } returns flowOf(listOf(checkIn1, checkIn2))
-
-        val warningPackage = object : TraceTimeIntervalWarningPackage {
-            override suspend fun extractTraceTimeIntervalWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
-                return listOf(warning1, warning2)
-            }
-
-            override val warningPackageId: String
-                get() = "id"
-        }
-
-        every { traceTimeIntervalWarningRepository.allWarningPackages } returns flowOf(listOf(warningPackage))
-
-        runBlockingTest {
-            createInstance().execute()
-            coVerify(exactly = 1) { presenceTracingRiskRepository.reportSuccessfulCalculation(any()) }
-            coVerify(exactly = 0) { presenceTracingRiskRepository.deleteAllMatches() }
-        }
-    }
-
-    @Test
-    fun `report empty list if no matches found`() {
-        val checkIn1 = createCheckIn(
-            id = 2L,
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
-            startDateStr = "2021-03-04T10:15+01:00",
-            endDateStr = "2021-03-04T10:17+01:00"
-        )
-        val checkIn2 = createCheckIn(
-            id = 3L,
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
-            startDateStr = "2021-03-04T09:15+01:00",
-            endDateStr = "2021-03-04T10:12+01:00"
-        )
-
-        val warning1 = createWarning(
-            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
-            startIntervalDateStr = "2021-03-04T10:00+01:00",
-            period = 6,
-            transmissionRiskLevel = 8
-        )
-
-        val warning2 = createWarning(
-            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
-            startIntervalDateStr = "2021-03-04T10:00+01:00",
-            period = 6,
-            transmissionRiskLevel = 8
-        )
-
-        every { checkInsRepository.allCheckIns } returns flowOf(listOf(checkIn1, checkIn2))
-
-        val warningPackage = object : TraceTimeIntervalWarningPackage {
-            override suspend fun extractTraceTimeIntervalWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
-                return listOf(warning1, warning2)
-            }
-
-            override val warningPackageId: String
-                get() = "id"
-        }
-
-        every { traceTimeIntervalWarningRepository.allWarningPackages } returns flowOf(listOf(warningPackage))
-
-        runBlockingTest {
-            createInstance().execute()
-            coVerify(exactly = 1) { presenceTracingRiskRepository.reportSuccessfulCalculation(emptyList()) }
-            coVerify(exactly = 0) { presenceTracingRiskRepository.deleteAllMatches() }
-        }
-    }
-
-    @Test
-    fun `report empty list if package is empty`() {
-        val checkIn1 = createCheckIn(
-            id = 2L,
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
-            startDateStr = "2021-03-04T10:15+01:00",
-            endDateStr = "2021-03-04T10:17+01:00"
-        )
-        val checkIn2 = createCheckIn(
-            id = 3L,
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
-            startDateStr = "2021-03-04T09:15+01:00",
-            endDateStr = "2021-03-04T10:12+01:00"
-        )
-
-        every { checkInsRepository.allCheckIns } returns flowOf(listOf(checkIn1, checkIn2))
-
-        val warningPackage = object : TraceTimeIntervalWarningPackage {
-            override suspend fun extractTraceTimeIntervalWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
-                return listOf()
-            }
-
-            override val warningPackageId: String
-                get() = "id"
-        }
-
-        every { traceTimeIntervalWarningRepository.allWarningPackages } returns flowOf(listOf(warningPackage))
-
-        runBlockingTest {
-            createInstance().execute()
-            coVerify(exactly = 1) { presenceTracingRiskRepository.reportSuccessfulCalculation(emptyList()) }
-            coVerify(exactly = 0) { presenceTracingRiskRepository.deleteAllMatches() }
-        }
-    }
-
-    @Test
-    fun `deletes all matches if no check-ins`() {
-
-        val warning1 = createWarning(
-            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
-            startIntervalDateStr = "2021-03-04T10:00+01:00",
-            period = 6,
-            transmissionRiskLevel = 8
-        )
-
-        val warning2 = createWarning(
-            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
-            startIntervalDateStr = "2021-03-04T10:00+01:00",
-            period = 6,
-            transmissionRiskLevel = 8
-        )
-
-        every { checkInsRepository.allCheckIns } returns flowOf(listOf())
-
-        val warningPackage = object : TraceTimeIntervalWarningPackage {
-            override suspend fun extractTraceTimeIntervalWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
-                return listOf(warning1, warning2)
-            }
-
-            override val warningPackageId: String
-                get() = "id"
-        }
-
-        every { traceTimeIntervalWarningRepository.allWarningPackages } returns flowOf(listOf(warningPackage))
-
-        runBlockingTest {
-            createInstance().execute()
-            coVerify(exactly = 1) { presenceTracingRiskRepository.reportSuccessfulCalculation(emptyList()) }
-            coVerify(exactly = 1) { presenceTracingRiskRepository.deleteAllMatches() }
-        }
-    }
-
-    @Test
-    fun `test mass data`() {
-        val checkIns = (1L..100L).map {
-            createCheckIn(
-                id = it,
-                traceLocationId = it.toString(),
-                startDateStr = "2021-03-04T09:50+01:00",
-                endDateStr = "2021-03-04T10:05:15+01:00"
-            )
-        }
-        val warnings = (1L..1000L).map {
-            createWarning(
-                traceLocationId = it.toString(),
-                startIntervalDateStr = "2021-03-04T10:00+01:00",
-                period = 6,
-                transmissionRiskLevel = 8
-            )
-        }
-
-        val warningPackage = object : TraceTimeIntervalWarningPackage {
-            override suspend fun extractTraceTimeIntervalWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
-                return warnings
-            }
-
-            override val warningPackageId: String
-                get() = "id"
-        }
-
-        every { checkInsRepository.allCheckIns } returns flowOf(checkIns)
-        every { traceTimeIntervalWarningRepository.allWarningPackages } returns flowOf(listOf(warningPackage))
-
-        runBlockingTest {
-            measureTime(
-                { Timber.d("Time to compare 200 checkIns with 1000 warnings: $it millis") },
-                { createInstance().execute() }
-            )
-        }
-    }
-
-    private fun createInstance() = CheckInWarningMatcher(
-        checkInsRepository,
-        traceTimeIntervalWarningRepository,
-        presenceTracingRiskRepository,
-        TestDispatcherProvider()
-    )
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/CheckInWarningMatcherTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/CheckInWarningMatcherTest.kt
new file mode 100644
index 000000000..b8beb8b11
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/CheckInWarningMatcherTest.kt
@@ -0,0 +1,255 @@
+package de.rki.coronawarnapp.presencetracing.risk.calculation
+
+import de.rki.coronawarnapp.presencetracing.warning.WarningPackageId
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningPackage
+import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.TestDispatcherProvider
+
+class CheckInWarningMatcherTest : BaseTest() {
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+    }
+
+    private fun createInstance() = CheckInWarningMatcher(
+        TestDispatcherProvider()
+    )
+
+    @Test
+    fun `reports new matches`() {
+        val checkIn1 = createCheckIn(
+            id = 2L,
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startDateStr = "2021-03-04T10:15+01:00",
+            endDateStr = "2021-03-04T10:17+01:00"
+        )
+        val checkIn2 = createCheckIn(
+            id = 3L,
+            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+            startDateStr = "2021-03-04T09:15+01:00",
+            endDateStr = "2021-03-04T10:12+01:00"
+        )
+
+        val warning1 = createWarning(
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startIntervalDateStr = "2021-03-04T10:00+01:00",
+            period = 6,
+            transmissionRiskLevel = 8
+        )
+
+        val warning2 = createWarning(
+            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+            startIntervalDateStr = "2021-03-04T10:00+01:00",
+            period = 6,
+            transmissionRiskLevel = 8
+        )
+
+        val warningPackage = object : TraceWarningPackage {
+            override suspend fun extractWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
+                return listOf(warning1, warning2)
+            }
+
+            override val packageId: WarningPackageId
+                get() = "id"
+        }
+
+        runBlockingTest {
+            val result = createInstance().process(
+                checkIns = listOf(checkIn1, checkIn2),
+                warningPackages = listOf(warningPackage)
+            )
+            result.apply {
+                successful shouldBe true
+                processedPackages.single().warningPackage shouldBe warningPackage
+                processedPackages.single().apply {
+                    overlaps.size shouldBe 2
+                    overlaps.any { it.checkInId == 2L } shouldBe true
+                    overlaps.any { it.checkInId == 3L } shouldBe true
+                }
+            }
+        }
+    }
+
+    @Test
+    fun `report empty list if no matches found`() {
+        val checkIn1 = createCheckIn(
+            id = 2L,
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startDateStr = "2021-03-04T10:15+01:00",
+            endDateStr = "2021-03-04T10:17+01:00"
+        )
+        val checkIn2 = createCheckIn(
+            id = 3L,
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startDateStr = "2021-03-04T09:15+01:00",
+            endDateStr = "2021-03-04T10:12+01:00"
+        )
+
+        val warning1 = createWarning(
+            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+            startIntervalDateStr = "2021-03-04T10:00+01:00",
+            period = 6,
+            transmissionRiskLevel = 8
+        )
+
+        val warning2 = createWarning(
+            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+            startIntervalDateStr = "2021-03-04T10:00+01:00",
+            period = 6,
+            transmissionRiskLevel = 8
+        )
+
+        val warningPackage = object : TraceWarningPackage {
+            override suspend fun extractWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
+                return listOf(warning1, warning2)
+            }
+
+            override val packageId: WarningPackageId
+                get() = "id"
+        }
+
+        runBlockingTest {
+            val result = createInstance().process(
+                checkIns = listOf(checkIn1, checkIn2),
+                warningPackages = listOf(warningPackage),
+            )
+
+            result.apply {
+                successful shouldBe true
+                processedPackages.single().warningPackage shouldBe warningPackage
+                processedPackages.single().overlaps.size shouldBe 0
+            }
+        }
+    }
+
+    @Test
+    fun `report empty list if package is empty`() {
+        val checkIn1 = createCheckIn(
+            id = 2L,
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startDateStr = "2021-03-04T10:15+01:00",
+            endDateStr = "2021-03-04T10:17+01:00"
+        )
+        val checkIn2 = createCheckIn(
+            id = 3L,
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startDateStr = "2021-03-04T09:15+01:00",
+            endDateStr = "2021-03-04T10:12+01:00"
+        )
+
+        val warningPackage = object : TraceWarningPackage {
+            override suspend fun extractWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
+                return listOf()
+            }
+
+            override val packageId: WarningPackageId
+                get() = "id"
+        }
+
+        runBlockingTest {
+            val result = createInstance().process(
+                warningPackages = listOf(warningPackage),
+                checkIns = listOf(checkIn1, checkIn2),
+            )
+
+            result.apply {
+                successful shouldBe true
+                processedPackages.single().warningPackage shouldBe warningPackage
+                processedPackages.single().overlaps.size shouldBe 0
+            }
+        }
+    }
+
+    @Test
+    fun `report failure if matching throws exception`() {
+        val checkIn1 = createCheckIn(
+            id = 2L,
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startDateStr = "2021-03-04T10:15+01:00",
+            endDateStr = "2021-03-04T10:17+01:00"
+        )
+        val checkIn2 = createCheckIn(
+            id = 3L,
+            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+            startDateStr = "2021-03-04T09:15+01:00",
+            endDateStr = "2021-03-04T10:12+01:00"
+        )
+
+        val warningPackage = object : TraceWarningPackage {
+            override suspend fun extractWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
+                throw Exception()
+            }
+
+            override val packageId: String
+                get() = "id"
+        }
+
+        runBlockingTest {
+            val result = createInstance().process(
+                checkIns = listOf(checkIn1, checkIn2),
+                warningPackages = listOf(warningPackage),
+            )
+
+            result.apply {
+                successful shouldBe false
+                processedPackages shouldBe emptyList()
+            }
+        }
+    }
+
+    @Test
+    fun `partial processing is possible on exceptions`() {
+        val checkIn1 = createCheckIn(
+            id = 2L,
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startDateStr = "2021-03-04T10:15+01:00",
+            endDateStr = "2021-03-04T10:17+01:00"
+        )
+        val checkIn2 = createCheckIn(
+            id = 3L,
+            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+            startDateStr = "2021-03-04T09:15+01:00",
+            endDateStr = "2021-03-04T10:12+01:00"
+        )
+
+        val warningPackage1 = object : TraceWarningPackage {
+            override suspend fun extractWarnings() = throw Exception()
+
+            override val packageId: WarningPackageId = "id1"
+        }
+        val warningPackage2 = object : TraceWarningPackage {
+            override suspend fun extractWarnings() = listOf(
+                createWarning(
+                    traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+                    startIntervalDateStr = "2021-03-04T10:00+01:00",
+                    period = 6,
+                    transmissionRiskLevel = 8
+                )
+            )
+
+            override val packageId: WarningPackageId = "id2"
+        }
+
+        runBlockingTest {
+            val result = createInstance().process(
+                checkIns = listOf(checkIn1, checkIn2),
+                warningPackages = listOf(warningPackage1, warningPackage2),
+            )
+
+            result.apply {
+                successful shouldBe false
+                processedPackages.single().apply {
+                    warningPackage shouldBe warningPackage2
+                    overlaps.single().checkInId shouldBe checkIn2.id
+                }
+            }
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/FindMatchesTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/FindMatchesTest.kt
similarity index 79%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/FindMatchesTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/FindMatchesTest.kt
index cc9d5e580..3ddd887cd 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/FindMatchesTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/FindMatchesTest.kt
@@ -1,6 +1,7 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.calculation
 
-import de.rki.coronawarnapp.eventregistration.checkins.download.TraceTimeIntervalWarningPackage
+import de.rki.coronawarnapp.presencetracing.warning.WarningPackageId
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningPackage
 import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
 import io.kotest.matchers.shouldBe
 import kotlinx.coroutines.test.runBlockingTest
@@ -36,11 +37,12 @@ class FindMatchesTest {
             period = 6,
             transmissionRiskLevel = 8
         )
-        val warningPackage = object : TraceTimeIntervalWarningPackage {
-            override suspend fun extractTraceTimeIntervalWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
+        val warningPackage = object : TraceWarningPackage {
+            override suspend fun extractWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
                 return listOf(warning1, warning2)
             }
-            override val warningPackageId: String
+
+            override val packageId: WarningPackageId
                 get() = "id"
         }
         runBlockingTest {
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/OverlapTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/OverlapTest.kt
similarity index 72%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/OverlapTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/OverlapTest.kt
index fa1af775d..67cdb5a05 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/OverlapTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/OverlapTest.kt
@@ -1,24 +1,51 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.calculation
 
-import com.google.protobuf.ByteString.copyFromUtf8
 import de.rki.coronawarnapp.eventregistration.checkins.CheckIn
 import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
-import de.rki.coronawarnapp.util.HashExtensions.toSHA256
+import de.rki.coronawarnapp.util.toOkioByteString
+import de.rki.coronawarnapp.util.toProtoByteString
 import io.kotest.matchers.shouldBe
+import okio.ByteString.Companion.decodeHex
 import okio.ByteString.Companion.encode
 import org.joda.time.Duration
 import org.joda.time.Instant
-import org.junit.Test
+import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 
 class OverlapTest : BaseTest() {
 
-    val id = "id"
+    private val id = "id"
+
+    private val locationId = "afa27b44d43b02a9fea41d13cedc2e4016cfcf87c5dbf990e593669aa8ce286d"
+    private val locationIdHash = "0f37dac11d1b8118ea0b44303400faa5e3b876da9d758058b5ff7dc2e5da8230"
+
+    @Test
+    fun `test helper method createCheckIn`() {
+        val checkIn = createCheckIn(
+            traceLocationId = locationId,
+            startDateStr = "2021-03-04T09:30+01:00",
+            endDateStr = "2021-03-04T09:45+01:00"
+        )
+
+        checkIn.traceLocationId shouldBe locationId.decodeHex()
+        checkIn.traceLocationIdHash shouldBe locationIdHash.decodeHex()
+    }
+
+    @Test
+    fun `test helper method createWarning`() {
+        val warning = createWarning(
+            traceLocationId = locationId,
+            startIntervalDateStr = "2021-03-04T10:00+01:00",
+            period = 6,
+            transmissionRiskLevel = 8
+        )
+        warning.locationIdHash.toOkioByteString().hex() shouldBe locationIdHash
+    }
 
     @Test
     fun `returns null if guids do not match`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T09:30+01:00",
             endDateStr = "2021-03-04T09:45+01:00"
         ).calculateOverlap(
@@ -35,12 +62,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns null if check-in precedes warning`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T09:30+01:00",
             endDateStr = "2021-03-04T09:45+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -52,12 +79,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns null if check-in is preceded by warning`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T11:15+01:00",
             endDateStr = "2021-03-04T11:20+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -69,12 +96,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns null if check-in meets warning at the start`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T09:30+01:00",
             endDateStr = "2021-03-04T10:00+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -86,12 +113,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns null if check-in meets warning at the end`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T11:00+01:00",
             endDateStr = "2021-03-04T11:10+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -103,12 +130,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns overlap if check-in overlaps warning at the start`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T09:30+01:00",
             endDateStr = "2021-03-04T10:12+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -120,12 +147,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns overlap if check-in overlaps warning at the end`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T10:45+01:00",
             endDateStr = "2021-03-04T11:12+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -137,12 +164,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns overlap if check-in starts warning`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T10:00+01:00",
             endDateStr = "2021-03-04T10:13+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -154,12 +181,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns overlap if check-in during warning`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T10:15+01:00",
             endDateStr = "2021-03-04T10:17+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -171,12 +198,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns overlap if check-in finishes warning`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T10:30+01:00",
             endDateStr = "2021-03-04T11:00+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -188,12 +215,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns overlap if check-in equals warning`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T10:00+01:00",
             endDateStr = "2021-03-04T11:00+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -205,12 +232,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns overlap after rounding (up)`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T09:50+01:00",
             endDateStr = "2021-03-04T10:05:45+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -222,12 +249,12 @@ class OverlapTest : BaseTest() {
     @Test
     fun `returns overlap after rounding (down)`() {
         createCheckIn(
-            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            traceLocationId = locationId,
             startDateStr = "2021-03-04T09:50+01:00",
             endDateStr = "2021-03-04T10:05:15+01:00"
         ).calculateOverlap(
             createWarning(
-                traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+                traceLocationId = locationId,
                 startIntervalDateStr = "2021-03-04T10:00+01:00",
                 period = 6,
                 transmissionRiskLevel = 8
@@ -244,8 +271,7 @@ fun createCheckIn(
     endDateStr: String
 ) = CheckIn(
     id = id,
-    traceLocationId = traceLocationId.toSHA256().encode(),
-    traceLocationIdHash = traceLocationId.toSHA256().encode(),
+    traceLocationId = traceLocationId.decodeHex(),
     version = 1,
     type = 2,
     description = "My birthday party",
@@ -266,8 +292,8 @@ fun createWarning(
     startIntervalDateStr: String,
     period: Int,
     transmissionRiskLevel: Int
-) = TraceWarning.TraceTimeIntervalWarning.newBuilder()
-    .setLocationIdHash(copyFromUtf8(traceLocationId.toSHA256()))
+): TraceWarning.TraceTimeIntervalWarning = TraceWarning.TraceTimeIntervalWarning.newBuilder()
+    .setLocationIdHash(traceLocationId.decodeHex().sha256().toProtoByteString())
     .setPeriod(period)
     .setStartIntervalNumber((Duration(Instant.parse(startIntervalDateStr).millis).standardMinutes / 10).toInt())
     .setTransmissionRiskLevel(transmissionRiskLevel)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskCalculatorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskCalculatorTest.kt
similarity index 98%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskCalculatorTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskCalculatorTest.kt
index 17c8b74c3..f59cd1622 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskCalculatorTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskCalculatorTest.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.calculation
 
 import de.rki.coronawarnapp.risk.RiskState
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskMapperTest.kt
similarity index 98%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskMapperTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskMapperTest.kt
index e5334348d..dd07536e3 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/PresenceTracingRiskMapperTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/calculation/PresenceTracingRiskMapperTest.kt
@@ -1,4 +1,4 @@
-package de.rki.coronawarnapp.presencetracing.risk
+package de.rki.coronawarnapp.presencetracing.risk.calculation
 
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.appconfig.ConfigData
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningTaskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningTaskTest.kt
new file mode 100644
index 000000000..e44656741
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningTaskTest.kt
@@ -0,0 +1,223 @@
+package de.rki.coronawarnapp.presencetracing.risk.execution
+
+import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository
+import de.rki.coronawarnapp.presencetracing.risk.calculation.CheckInWarningMatcher
+import de.rki.coronawarnapp.presencetracing.risk.calculation.createCheckIn
+import de.rki.coronawarnapp.presencetracing.risk.calculation.createWarning
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.presencetracing.warning.WarningPackageId
+import de.rki.coronawarnapp.presencetracing.warning.download.TraceWarningPackageSyncTool
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningPackage
+import de.rki.coronawarnapp.presencetracing.warning.storage.TraceWarningRepository
+import de.rki.coronawarnapp.server.protocols.internal.pt.TraceWarning
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.assertions.throwables.shouldThrow
+import io.kotest.matchers.comparables.shouldBeLessThan
+import io.kotest.matchers.shouldNotBe
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.coVerifySequence
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Duration
+import org.joda.time.Instant
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import java.io.IOException
+
+class PresenceTracingWarningTaskTest : BaseTest() {
+
+    @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var syncTool: TraceWarningPackageSyncTool
+    @MockK lateinit var checkInWarningMatcher: CheckInWarningMatcher
+    @MockK lateinit var presenceTracingRiskRepository: PresenceTracingRiskRepository
+    @MockK lateinit var traceWarningRepository: TraceWarningRepository
+    @MockK lateinit var checkInsRepository: CheckInRepository
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { timeStamper.nowUTC } returns Instant.ofEpochMilli(9000)
+        coEvery { syncTool.syncPackages() } returns mockk()
+        coEvery { checkInWarningMatcher.process(any(), any()) } answers {
+            CheckInWarningMatcher.Result(
+                successful = true,
+                processedPackages = listOf(
+                    CheckInWarningMatcher.MatchesPerPackage(
+                        warningPackage = WARNING_PKG,
+                        overlaps = listOf(mockk())
+                    )
+                )
+            )
+        }
+
+        traceWarningRepository.apply {
+            coEvery { unprocessedWarningPackages } returns flowOf(listOf(WARNING_PKG))
+            coEvery { markPackagesProcessed(any()) } just Runs
+        }
+
+        coEvery { checkInsRepository.allCheckIns } returns flowOf(listOf(CHECKIN_1, CHECKIN_2))
+
+        presenceTracingRiskRepository.apply {
+            coEvery { deleteAllMatches() } just Runs
+            coEvery { deleteStaleData() } just Runs
+            coEvery { reportCalculation(any(), any()) } just Runs
+        }
+    }
+
+    private fun createInstance() = PresenceTracingWarningTask(
+        timeStamper = timeStamper,
+        syncTool = syncTool,
+        checkInWarningMatcher = checkInWarningMatcher,
+        presenceTracingRiskRepository = presenceTracingRiskRepository,
+        traceWarningRepository = traceWarningRepository,
+        checkInsRepository = checkInsRepository,
+    )
+
+    @Test
+    fun `happy path, match result is reported successfully`() = runBlockingTest {
+        createInstance().run(mockk()) shouldNotBe null
+
+        coVerifySequence {
+            syncTool.syncPackages()
+            presenceTracingRiskRepository.deleteStaleData()
+            checkInsRepository.allCheckIns
+            traceWarningRepository.unprocessedWarningPackages
+
+            checkInWarningMatcher.process(any(), any())
+
+            presenceTracingRiskRepository.reportCalculation(
+                successful = true,
+                overlaps = any()
+            )
+            traceWarningRepository.markPackagesProcessed(listOf(WARNING_PKG.packageId))
+        }
+    }
+
+    @Test
+    fun `overall task errors lead to a reported failed calculation`() = runBlockingTest {
+        coEvery { syncTool.syncPackages() } throws IOException("Unexpected")
+
+        shouldThrow<IOException> {
+            createInstance().run(mockk())
+        }
+
+        coVerify {
+            presenceTracingRiskRepository.reportCalculation(
+                successful = false,
+                overlaps = emptyList()
+            )
+        }
+    }
+
+    @Test
+    fun `there are no check-ins to match against`() = runBlockingTest {
+        coEvery { checkInsRepository.allCheckIns } returns flowOf(emptyList())
+
+        createInstance().run(mockk()) shouldNotBe null
+
+        coVerifySequence {
+            syncTool.syncPackages()
+            presenceTracingRiskRepository.deleteStaleData()
+            checkInsRepository.allCheckIns
+
+            presenceTracingRiskRepository.deleteAllMatches()
+            presenceTracingRiskRepository.reportCalculation(successful = true)
+        }
+    }
+
+    @Test
+    fun `there are no warning packages to process`() = runBlockingTest {
+        coEvery { traceWarningRepository.unprocessedWarningPackages } returns flowOf(emptyList())
+
+        createInstance().run(mockk()) shouldNotBe null
+
+        coVerifySequence {
+            syncTool.syncPackages()
+            presenceTracingRiskRepository.deleteStaleData()
+            checkInsRepository.allCheckIns
+            traceWarningRepository.unprocessedWarningPackages
+
+            presenceTracingRiskRepository.reportCalculation(successful = true)
+        }
+    }
+
+    @Test
+    fun `report failure if matching throws exception`() = runBlockingTest {
+        coEvery { checkInWarningMatcher.process(any(), any()) } throws IllegalArgumentException()
+        shouldThrow<IllegalArgumentException> {
+            createInstance().run(mockk()) shouldNotBe null
+        }
+
+        coVerifySequence {
+            syncTool.syncPackages()
+            presenceTracingRiskRepository.deleteStaleData()
+            checkInsRepository.allCheckIns
+            traceWarningRepository.unprocessedWarningPackages
+
+            checkInWarningMatcher.process(any(), any())
+
+            presenceTracingRiskRepository.reportCalculation(
+                successful = false,
+                overlaps = any()
+            )
+        }
+
+        coVerify(exactly = 0) {
+            traceWarningRepository.markPackagesProcessed(any())
+        }
+    }
+
+    @Test
+    fun `task timeout is constrained to less than 9min`() {
+        // Worker execution time
+        val maxDuration = Duration.standardMinutes(9).plus(1)
+        PresenceTracingWarningTask.Config().executionTimeout shouldBeLessThan maxDuration
+    }
+
+    companion object {
+        val CHECKIN_1 = createCheckIn(
+            id = 2L,
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startDateStr = "2021-03-04T10:15+01:00",
+            endDateStr = "2021-03-04T10:17+01:00"
+        )
+        val CHECKIN_2 = createCheckIn(
+            id = 3L,
+            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+            startDateStr = "2021-03-04T09:15+01:00",
+            endDateStr = "2021-03-04T10:12+01:00"
+        )
+
+        val WARNING_1 = createWarning(
+            traceLocationId = "fe84394e73838590cc7707aba0350c130f6d0fb6f0f2535f9735f481dee61871",
+            startIntervalDateStr = "2021-03-04T10:00+01:00",
+            period = 6,
+            transmissionRiskLevel = 8
+        )
+
+        val WARNING_2 = createWarning(
+            traceLocationId = "69eb427e1a48133970486244487e31b3f1c5bde47415db9b52cc5a2ece1e0060",
+            startIntervalDateStr = "2021-03-04T10:00+01:00",
+            period = 6,
+            transmissionRiskLevel = 8
+        )
+
+        val WARNING_PKG = object : TraceWarningPackage {
+            override suspend fun extractWarnings(): List<TraceWarning.TraceTimeIntervalWarning> {
+                return listOf(WARNING_1, WARNING_2)
+            }
+
+            override val packageId: WarningPackageId
+                get() = "id"
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorkerTest.kt
new file mode 100644
index 000000000..dee16970f
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/risk/execution/PresenceTracingWarningWorkerTest.kt
@@ -0,0 +1,95 @@
+package de.rki.coronawarnapp.presencetracing.risk.execution
+
+import android.content.Context
+import androidx.work.ListenableWorker
+import androidx.work.WorkerParameters
+import de.rki.coronawarnapp.task.TaskController
+import de.rki.coronawarnapp.task.TaskRequest
+import de.rki.coronawarnapp.task.TaskState
+import de.rki.coronawarnapp.task.common.DefaultTaskRequest
+import de.rki.coronawarnapp.task.submitBlocking
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.impl.annotations.RelaxedMockK
+import io.mockk.mockkStatic
+import io.mockk.slot
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class PresenceTracingWarningWorkerTest : BaseTest() {
+
+    @RelaxedMockK lateinit var workerParams: WorkerParameters
+    @MockK lateinit var context: Context
+    @MockK lateinit var taskController: TaskController
+    @MockK(relaxed = true) lateinit var taskResult: TaskState
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        mockkStatic("de.rki.coronawarnapp.task.TaskControllerExtensionsKt")
+
+        coEvery { taskController.submitBlocking(any()) } returns taskResult
+
+        taskResult.apply {
+            every { isSuccessful } returns true
+            every { error } returns null
+        }
+    }
+
+    private fun createWorker() = PresenceTracingWarningWorker(
+        context = context,
+        workerParams = workerParams,
+        taskController = taskController
+    )
+
+    @Test
+    fun `worker runs task`() = runBlockingTest {
+        val slot = slot<TaskRequest>()
+        coEvery { taskController.submitBlocking(capture(slot)) } returns taskResult
+
+        val worker = createWorker()
+
+        worker.doWork() shouldBe ListenableWorker.Result.success()
+
+        slot.captured shouldBe DefaultTaskRequest(
+            id = slot.captured.id,
+            arguments = slot.captured.arguments,
+            type = PresenceTracingWarningTask::class,
+            originTag = "PresenceTracingWarningWorker",
+        )
+    }
+
+    @Test
+    fun `task errors lead to retry`() = runBlockingTest {
+        every { taskResult.isSuccessful } returns false
+        every { taskResult.error } returns Exception()
+
+        val worker = createWorker()
+
+        worker.doWork() shouldBe ListenableWorker.Result.retry()
+
+        coVerify {
+            taskController.submitBlocking(any())
+        }
+    }
+
+    @Test
+    fun `taskcontroller errors lead to retry`() = runBlockingTest {
+        coEvery { taskController.submitBlocking(any()) } throws Exception()
+
+        val worker = createWorker()
+
+        worker.doWork() shouldBe ListenableWorker.Result.retry()
+
+        coVerify {
+            taskController.submitBlocking(any())
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageDownloaderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageDownloaderTest.kt
new file mode 100644
index 000000000..8bd44b79f
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/presencetracing/warning/download/TraceWarningPackageDownloaderTest.kt
@@ -0,0 +1,12 @@
+package de.rki.coronawarnapp.presencetracing.warning.download
+
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class TraceWarningPackageDownloaderTest : BaseTest() {
+
+    @Test
+    fun `errors during writeProtoBufToFile cause download to be marked as failed`() {
+        // TODO
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/EwRiskLevelResultExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/EwRiskLevelResultExtensionsTest.kt
index f1b801552..18acf7c63 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/EwRiskLevelResultExtensionsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/EwRiskLevelResultExtensionsTest.kt
@@ -2,9 +2,9 @@ package de.rki.coronawarnapp.risk
 
 import com.google.android.gms.nearby.exposurenotification.ExposureWindow
 import de.rki.coronawarnapp.risk.result.EwAggregatedRiskResult
-import io.kotest.matchers.longs.shouldBeInRange
 import io.kotest.matchers.shouldBe
 import io.mockk.mockk
+import org.joda.time.Duration
 import org.joda.time.Instant
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
@@ -31,11 +31,12 @@ class EwRiskLevelResultExtensionsTest : BaseTest() {
         emptyResults.tryLatestEwResultsWithDefaults().apply {
             lastCalculated.apply {
                 riskState shouldBe RiskState.LOW_RISK
-                val now = Instant.now().millis
-                calculatedAt.millis shouldBeInRange ((now - 60 * 1000L)..now + 60 * 1000L)
-            }
-            lastSuccessfullyCalculated.apply {
-                riskState shouldBe RiskState.CALCULATION_FAILED
+
+                calculatedAt.isAfter(Instant.EPOCH) shouldBe true
+                calculatedAt.isBefore(Instant.now().plus(Duration.standardHours(1))) shouldBe true
+                lastSuccessfullyCalculated.apply {
+                    riskState shouldBe RiskState.CALCULATION_FAILED
+                }
             }
         }
     }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetectorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetectorTest.kt
index d391e5cc2..225b325d9 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetectorTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelChangeDetectorTest.kt
@@ -1,11 +1,14 @@
 package de.rki.coronawarnapp.risk
 
+import android.app.Notification
 import android.content.Context
+import androidx.core.app.NotificationCompat
 import androidx.core.app.NotificationManagerCompat
 import com.google.android.gms.nearby.exposurenotification.ExposureWindow
 import de.rki.coronawarnapp.datadonation.analytics.storage.TestResultDonorSettings
 import de.rki.coronawarnapp.datadonation.survey.Surveys
 import de.rki.coronawarnapp.notification.GeneralNotifications
+import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
 import de.rki.coronawarnapp.risk.RiskState.CALCULATION_FAILED
 import de.rki.coronawarnapp.risk.RiskState.INCREASED_RISK
 import de.rki.coronawarnapp.risk.RiskState.LOW_RISK
@@ -15,6 +18,7 @@ import de.rki.coronawarnapp.storage.TracingSettings
 import de.rki.coronawarnapp.submission.SubmissionSettings
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.device.ForegroundState
+import de.rki.coronawarnapp.util.notifications.setContentTextExpandable
 import io.kotest.matchers.shouldBe
 import io.mockk.Called
 import io.mockk.MockKAnnotations
@@ -47,18 +51,24 @@ class RiskLevelChangeDetectorTest : BaseTest() {
     @MockK lateinit var tracingSettings: TracingSettings
     @MockK lateinit var testResultDonorSettings: TestResultDonorSettings
 
+    @MockK lateinit var builder: NotificationCompat.Builder
+    @MockK lateinit var notification: Notification
+
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
 
         every { tracingSettings.isUserToBeNotifiedOfLoweredRiskLevel } returns mockFlowPreference(false)
         every { submissionSettings.isSubmissionSuccessful } returns false
-        every { foregroundState.isInForeground } returns flowOf(true)
+        every { foregroundState.isInForeground } returns flowOf(false)
         every { notificationManagerCompat.areNotificationsEnabled() } returns true
 
         every { riskLevelSettings.lastChangeCheckedRiskLevelTimestamp = any() } just Runs
         every { riskLevelSettings.lastChangeCheckedRiskLevelTimestamp } returns null
 
+        every { riskLevelSettings.lastChangeCheckedRiskLevelCombinedTimestamp = any() } just Runs
+        every { riskLevelSettings.lastChangeCheckedRiskLevelCombinedTimestamp } returns null
+
         every { riskLevelSettings.lastChangeToHighRiskLevelTimestamp = any() } just Runs
         every { riskLevelSettings.lastChangeToHighRiskLevelTimestamp } returns null
 
@@ -66,10 +76,17 @@ class RiskLevelChangeDetectorTest : BaseTest() {
 
         every { testResultDonorSettings.riskLevelTurnedRedTime } returns mockFlowPreference(null)
         every { testResultDonorSettings.mostRecentDateWithHighOrLowRiskLevel } returns mockFlowPreference(null)
-        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns flowOf(listOf())
+
+        every { builder.build() } returns notification
+        every { builder.setContentTitle(any()) } returns builder
+        every { builder.setContentTextExpandable(any()) } returns builder
+        every { builder.setContentText(any()) } returns builder
+        every { builder.setStyle(any()) } returns builder
+        every { notificationHelper.newBaseBuilder() } returns builder
+        every { context.getString(any()) } returns ""
     }
 
-    private fun createRiskLevel(
+    private fun createEwRiskLevel(
         riskState: RiskState,
         calculatedAt: Instant = Instant.EPOCH,
         ewAggregatedRiskResult: EwAggregatedRiskResult? = null
@@ -83,6 +100,23 @@ class RiskLevelChangeDetectorTest : BaseTest() {
         override val daysWithEncounters: Int = 0
     }
 
+    private fun createPtRiskLevel(
+        riskState: RiskState,
+        calculatedAt: Instant = Instant.EPOCH
+    ): PtRiskLevelResult = PtRiskLevelResult(
+        calculatedAt = calculatedAt,
+        riskState = riskState
+    )
+
+    private fun createCombinedRiskLevel(
+        riskState: RiskState,
+        calculatedAt: Instant = Instant.EPOCH,
+        ewAggregatedRiskResult: EwAggregatedRiskResult? = null
+    ): CombinedEwPtRiskLevelResult = CombinedEwPtRiskLevelResult(
+        ewRiskLevelResult = createEwRiskLevel(riskState, calculatedAt, ewAggregatedRiskResult),
+        ptRiskLevelResult = createPtRiskLevel(riskState, calculatedAt)
+    )
+
     private fun createInstance(scope: CoroutineScope) = RiskLevelChangeDetector(
         context = context,
         appScope = scope,
@@ -99,7 +133,9 @@ class RiskLevelChangeDetectorTest : BaseTest() {
 
     @Test
     fun `nothing happens if there is only one result yet`() {
-        every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(listOf(createRiskLevel(LOW_RISK)))
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(listOf(createCombinedRiskLevel(LOW_RISK)))
+        every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(listOf(createEwRiskLevel(LOW_RISK)))
 
         runBlockingTest {
             val instance = createInstance(scope = this)
@@ -110,19 +146,28 @@ class RiskLevelChangeDetectorTest : BaseTest() {
             coVerifySequence {
                 notificationManagerCompat wasNot Called
                 surveys wasNot Called
+                testResultDonorSettings wasNot Called
             }
         }
     }
 
     @Test
-    fun `no risklevel change, nothing should happen`() {
+    fun `no risk level change, nothing should happen`() {
         every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
             listOf(
-                createRiskLevel(LOW_RISK),
-                createRiskLevel(LOW_RISK)
+                createEwRiskLevel(LOW_RISK),
+                createEwRiskLevel(LOW_RISK)
             )
         )
 
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(
+                listOf(
+                    createCombinedRiskLevel(LOW_RISK),
+                    createCombinedRiskLevel(LOW_RISK)
+                )
+            )
+
         runBlockingTest {
             val instance = createInstance(scope = this)
             instance.launch()
@@ -136,17 +181,53 @@ class RiskLevelChangeDetectorTest : BaseTest() {
         }
     }
 
-    // TODO test if risk level change for combined risk triggers notification
+    @Test
+    fun `combined risk state change from HIGH to LOW triggers notification`() {
+        every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
+            listOf(
+                createEwRiskLevel(LOW_RISK)
+            )
+        )
+
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(
+                listOf(
+                    createCombinedRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+                    createCombinedRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH)
+                )
+            )
+
+        runBlockingTest {
+            val instance = createInstance(scope = this)
+            instance.launch()
+
+            advanceUntilIdle()
+
+            coVerifySequence {
+                submissionSettings.isSubmissionSuccessful
+                foregroundState.isInForeground
+                notificationHelper.newBaseBuilder()
+                notificationHelper.sendNotification(any(), any())
+            }
+        }
+    }
 
     @Test
-    fun `risklevel went from HIGH to LOW`() {
+    fun `combined risk state change from LOW to HIGH triggers notification`() {
         every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
             listOf(
-                createRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH.plus(1)),
-                createRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH)
+                createEwRiskLevel(LOW_RISK)
             )
         )
 
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(
+                listOf(
+                    createCombinedRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+                    createCombinedRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
+                )
+            )
+
         runBlockingTest {
             val instance = createInstance(scope = this)
             instance.launch()
@@ -154,21 +235,46 @@ class RiskLevelChangeDetectorTest : BaseTest() {
             advanceUntilIdle()
 
             coVerifySequence {
-                surveys.resetSurvey(Surveys.Type.HIGH_RISK_ENCOUNTER)
+                submissionSettings.isSubmissionSuccessful
+                foregroundState.isInForeground
+                notificationHelper.newBaseBuilder()
+                notificationHelper.sendNotification(any(), any())
             }
         }
     }
 
-    // TODO test if risk level change for combined risk triggers notification
+    @Test
+    fun `risk level went from HIGH to LOW resets survey`() {
+        every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
+            listOf(
+                createEwRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+                createEwRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH)
+            )
+        )
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(listOf(createCombinedRiskLevel(LOW_RISK)))
+        runBlockingTest {
+            val instance = createInstance(scope = this)
+            instance.launch()
+
+            advanceUntilIdle()
+
+            coVerifySequence {
+                surveys.resetSurvey(Surveys.Type.HIGH_RISK_ENCOUNTER)
+            }
+        }
+    }
 
     @Test
-    fun `risklevel went from LOW to HIGH`() {
+    fun `risk level went from LOW to HIGH`() {
         every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
             listOf(
-                createRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH.plus(1)),
-                createRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
+                createEwRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+                createEwRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
             )
         )
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(listOf(createCombinedRiskLevel(LOW_RISK)))
 
         runBlockingTest {
             val instance = createInstance(scope = this)
@@ -183,13 +289,15 @@ class RiskLevelChangeDetectorTest : BaseTest() {
     }
 
     @Test
-    fun `risklevel went from LOW to HIGH but it is has already been processed`() {
+    fun `risk level went from LOW to HIGH but it is has already been processed`() {
         every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
             listOf(
-                createRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH.plus(1)),
-                createRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
+                createEwRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+                createEwRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
             )
         )
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(listOf(createCombinedRiskLevel(LOW_RISK)))
         every { riskLevelSettings.lastChangeCheckedRiskLevelTimestamp } returns Instant.EPOCH.plus(1)
 
         runBlockingTest {
@@ -205,23 +313,57 @@ class RiskLevelChangeDetectorTest : BaseTest() {
         }
     }
 
+    @Test
+    fun `combined risk level went from LOW to HIGH but it is has already been processed`() {
+        every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
+            listOf(
+                createEwRiskLevel(LOW_RISK)
+            )
+        )
+
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(
+                listOf(
+                    createCombinedRiskLevel(INCREASED_RISK, calculatedAt = Instant.EPOCH.plus(1)),
+                    createCombinedRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
+                )
+            )
+
+        every { riskLevelSettings.lastChangeCheckedRiskLevelCombinedTimestamp } returns Instant.EPOCH.plus(1)
+
+        runBlockingTest {
+            val instance = createInstance(scope = this)
+            instance.launch()
+
+            advanceUntilIdle()
+
+            coVerifySequence {
+                notificationManagerCompat wasNot Called
+                surveys wasNot Called
+            }
+        }
+    }
+
     @Test
     fun `riskLevelTurnedRedTime is only set once`() {
         testResultDonorSettings.riskLevelTurnedRedTime.update { Instant.EPOCH.plus(1) }
 
         every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
             listOf(
-                createRiskLevel(
+                createEwRiskLevel(
                     INCREASED_RISK,
                     calculatedAt = Instant.EPOCH.plus(2),
                     ewAggregatedRiskResult = mockk<EwAggregatedRiskResult>().apply {
                         every { isIncreasedRisk() } returns true
                     }
                 ),
-                createRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
+                createEwRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
             )
         )
 
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(listOf(createCombinedRiskLevel(LOW_RISK)))
+
         runBlockingTest {
             val instance = createInstance(scope = this)
             instance.launch()
@@ -245,7 +387,7 @@ class RiskLevelChangeDetectorTest : BaseTest() {
     fun `mostRecentDateWithHighOrLowRiskLevel is updated every time`() {
         every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
             listOf(
-                createRiskLevel(
+                createEwRiskLevel(
                     INCREASED_RISK,
                     calculatedAt = Instant.EPOCH.plus(1),
                     ewAggregatedRiskResult = mockk<EwAggregatedRiskResult>().apply {
@@ -253,9 +395,11 @@ class RiskLevelChangeDetectorTest : BaseTest() {
                         every { isIncreasedRisk() } returns true
                     }
                 ),
-                createRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
+                createEwRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
             )
         )
+        every { riskLevelStorage.latestCombinedEwPtRiskLevelResults } returns
+            flowOf(listOf(createCombinedRiskLevel(LOW_RISK)))
 
         runBlockingTest {
             val instance = createInstance(scope = this)
@@ -267,7 +411,7 @@ class RiskLevelChangeDetectorTest : BaseTest() {
 
         every { riskLevelStorage.latestEwRiskLevelResults } returns flowOf(
             listOf(
-                createRiskLevel(
+                createEwRiskLevel(
                     INCREASED_RISK,
                     calculatedAt = Instant.EPOCH.plus(1),
                     ewAggregatedRiskResult = mockk<EwAggregatedRiskResult>().apply {
@@ -275,7 +419,7 @@ class RiskLevelChangeDetectorTest : BaseTest() {
                         every { isIncreasedRisk() } returns false
                     }
                 ),
-                createRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
+                createEwRiskLevel(LOW_RISK, calculatedAt = Instant.EPOCH)
             )
         )
 
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorageTest.kt
index 099afd385..03c675890 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorageTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/BaseRiskLevelStorageTest.kt
@@ -1,7 +1,11 @@
 package de.rki.coronawarnapp.risk.storage
 
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
+import de.rki.coronawarnapp.presencetracing.risk.calculation.PresenceTracingDayRisk
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
 import de.rki.coronawarnapp.risk.EwRiskLevelResult
+import de.rki.coronawarnapp.risk.RiskState
+import de.rki.coronawarnapp.risk.storage.RiskStorageTestData.ewCalculatedAt
 import de.rki.coronawarnapp.risk.storage.RiskStorageTestData.testAggregatedRiskPerDateResult
 import de.rki.coronawarnapp.risk.storage.RiskStorageTestData.testExposureWindow
 import de.rki.coronawarnapp.risk.storage.RiskStorageTestData.testExposureWindowDaoWrapper
@@ -14,6 +18,9 @@ import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase.AggregatedR
 import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase.ExposureWindowsDao
 import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase.Factory
 import de.rki.coronawarnapp.risk.storage.internal.RiskResultDatabase.RiskResultsDao
+import de.rki.coronawarnapp.risk.storage.internal.riskresults.PersistedRiskLevelResultDao
+import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc
 import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.matchers.shouldBe
 import io.kotest.matchers.shouldNotBe
@@ -32,6 +39,7 @@ import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.test.TestCoroutineScope
 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
@@ -70,8 +78,9 @@ class BaseRiskLevelStorageTest : BaseTest() {
         // TODO proper tests
         coEvery { presenceTracingRiskRepository.traceLocationCheckInRiskStates } returns emptyFlow()
         coEvery { presenceTracingRiskRepository.presenceTracingDayRisk } returns emptyFlow()
-        coEvery { presenceTracingRiskRepository.latestAndLastSuccessful() } returns emptyFlow()
+        coEvery { presenceTracingRiskRepository.allEntries() } returns emptyFlow()
         coEvery { presenceTracingRiskRepository.latestEntries(any()) } returns emptyFlow()
+        coEvery { presenceTracingRiskRepository.clearAllTables() } just Runs
     }
 
     private fun createInstance(
@@ -104,7 +113,9 @@ class BaseRiskLevelStorageTest : BaseTest() {
             val instance = createInstance()
             val allEntries = instance.aggregatedRiskPerDateResultTables.allEntries()
             allEntries shouldBe testPersistedAggregatedRiskPerDateResultFlow
-            allEntries.first().map { it.toAggregatedRiskPerDateResult() } shouldBe listOf(testAggregatedRiskPerDateResult)
+            allEntries.first().map { it.toAggregatedRiskPerDateResult() } shouldBe listOf(
+                testAggregatedRiskPerDateResult
+            )
 
             val aggregatedRiskPerDateResults = instance.ewDayRiskStates.first()
             aggregatedRiskPerDateResults shouldNotBe listOf(testPersistedAggregatedRiskPerDateResult)
@@ -112,6 +123,19 @@ class BaseRiskLevelStorageTest : BaseTest() {
         }
     }
 
+    @Test
+    fun `ptDayRiskStates are returned from database`() {
+        val testPresenceTracingDayRiskFlow = flowOf(listOf(testPresenceTracingDayRisk))
+        every { presenceTracingRiskRepository.presenceTracingDayRisk } returns testPresenceTracingDayRiskFlow
+
+        runBlockingTest {
+            val instance = createInstance()
+
+            val states = instance.ptDayRiskStates.first()
+            states shouldBe listOf(testPresenceTracingDayRisk)
+        }
+    }
+
     @Test
     fun `exposureWindows are returned from database and mapped`() {
         val testDaoWrappers = flowOf(listOf(testExposureWindowDaoWrapper))
@@ -170,6 +194,142 @@ class BaseRiskLevelStorageTest : BaseTest() {
         }
     }
 
+    @Test
+    fun `latestCombinedEwPtRiskLevelResults works when 2 pt result and 1 ew result are available`() {
+        every { riskResultTables.latestEntries(any()) } returns flowOf(listOf(testRiskLevelResultDao))
+        every { exposureWindowTables.getWindowsForResult(any()) } returns flowOf(listOf(testExposureWindowDaoWrapper))
+        val calculatedAt = ewCalculatedAt.plus(6000L)
+        every { presenceTracingRiskRepository.latestEntries(2) } returns flowOf(
+            listOf(
+                PtRiskLevelResult(
+                    calculatedAt = calculatedAt,
+                    presenceTracingDayRisk = null,
+                    riskState = RiskState.CALCULATION_FAILED
+                ),
+                PtRiskLevelResult(
+                    calculatedAt = ewCalculatedAt.minus(1000L),
+                    presenceTracingDayRisk = listOf(testPresenceTracingDayRisk),
+                    riskState = RiskState.INCREASED_RISK
+                )
+            )
+        )
+        runBlockingTest2(ignoreActive = true) {
+            val instance = createInstance(scope = this)
+
+            val riskLevelResults = instance.latestCombinedEwPtRiskLevelResults.first()
+            riskLevelResults.size shouldBe 2
+
+            riskLevelResults[0].calculatedAt shouldBe calculatedAt
+            riskLevelResults[0].riskState shouldBe RiskState.INCREASED_RISK
+            riskLevelResults[1].calculatedAt shouldBe ewCalculatedAt
+            riskLevelResults[1].riskState shouldBe RiskState.INCREASED_RISK
+
+            verify {
+                riskResultTables.latestEntries(2)
+                presenceTracingRiskRepository.latestEntries(2)
+            }
+        }
+    }
+
+    @Test
+    fun `latestCombinedEwPtRiskLevelResults works when only one calc each is available`() {
+        every { riskResultTables.latestEntries(any()) } returns flowOf(listOf(testRiskLevelResultDao))
+        every { exposureWindowTables.getWindowsForResult(any()) } returns flowOf(listOf(testExposureWindowDaoWrapper))
+        val calculatedAt = ewCalculatedAt.minus(400L)
+        every { presenceTracingRiskRepository.latestEntries(2) } returns flowOf(
+            listOf(
+                PtRiskLevelResult(
+                    calculatedAt = calculatedAt,
+                    presenceTracingDayRisk = null,
+                    riskState = RiskState.CALCULATION_FAILED
+                )
+            )
+        )
+        runBlockingTest2(ignoreActive = true) {
+            val instance = createInstance(scope = this)
+
+            val riskLevelResults = instance.latestCombinedEwPtRiskLevelResults.first()
+            riskLevelResults.size shouldBe 2
+
+            riskLevelResults[0].calculatedAt shouldBe ewCalculatedAt
+            riskLevelResults[0].riskState shouldBe RiskState.INCREASED_RISK
+
+            // result from the combination with initial ew low risk result
+            riskLevelResults[1].calculatedAt shouldBe ewCalculatedAt.minus(400L)
+            riskLevelResults[1].riskState shouldBe RiskState.CALCULATION_FAILED
+
+            verify {
+                riskResultTables.latestEntries(2)
+                presenceTracingRiskRepository.latestEntries(2)
+            }
+        }
+    }
+
+    @Test
+    fun `latestCombinedEwPtRiskLevelResults works when two calc each are available`() {
+        val ewResultDao1 = PersistedRiskLevelResultDao(
+            id = "id1",
+            calculatedAt = ewCalculatedAt,
+            failureReason = null,
+            aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+                totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.LOW,
+                totalMinimumDistinctEncountersWithLowRisk = 1,
+                totalMinimumDistinctEncountersWithHighRisk = 2,
+                mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+                mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+                numberOfDaysWithLowRisk = 5,
+                numberOfDaysWithHighRisk = 6
+            )
+        )
+        val ewResultDao2 = PersistedRiskLevelResultDao(
+            id = "id2",
+            calculatedAt = ewCalculatedAt.minus(200L),
+            failureReason = null,
+            aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+                totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+                totalMinimumDistinctEncountersWithLowRisk = 1,
+                totalMinimumDistinctEncountersWithHighRisk = 2,
+                mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+                mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+                numberOfDaysWithLowRisk = 5,
+                numberOfDaysWithHighRisk = 6
+            )
+        )
+        every { riskResultTables.latestEntries(any()) } returns flowOf(listOf(ewResultDao1, ewResultDao2))
+        every { exposureWindowTables.getWindowsForResult(any()) } returns flowOf(listOf(testExposureWindowDaoWrapper))
+        val calculatedAt = ewCalculatedAt.minus(400L)
+        every { presenceTracingRiskRepository.latestEntries(2) } returns flowOf(
+            listOf(
+                PtRiskLevelResult(
+                    calculatedAt = calculatedAt,
+                    presenceTracingDayRisk = null,
+                    riskState = RiskState.CALCULATION_FAILED
+                ),
+                PtRiskLevelResult(
+                    calculatedAt = calculatedAt.minus(400L),
+                    presenceTracingDayRisk = null,
+                    riskState = RiskState.LOW_RISK
+                )
+            )
+        )
+        runBlockingTest2(ignoreActive = true) {
+            val instance = createInstance(scope = this)
+
+            val riskLevelResults = instance.latestCombinedEwPtRiskLevelResults.first()
+            riskLevelResults.size shouldBe 2
+
+            riskLevelResults[0].calculatedAt shouldBe ewCalculatedAt
+            riskLevelResults[0].riskState shouldBe RiskState.LOW_RISK
+            riskLevelResults[1].calculatedAt shouldBe ewCalculatedAt.minus(200L)
+            riskLevelResults[1].riskState shouldBe RiskState.INCREASED_RISK
+
+            verify {
+                riskResultTables.latestEntries(2)
+                presenceTracingRiskRepository.latestEntries(2)
+            }
+        }
+    }
+
     // This just tests the mapping, the correctness of the SQL statement is validated in an instrumentation test
     @Test
     fun `latestAndLastSuccessful with exposure windows are returned from database and mapped`() {
@@ -190,7 +350,214 @@ class BaseRiskLevelStorageTest : BaseTest() {
     }
 
     @Test
-    fun `errors when storing risklevel result are rethrown`() = runBlockingTest {
+    fun `latestAndLastSuccessfulCombinedEwPtRiskLevelResult are combined`() {
+        val calculatedAt = ewCalculatedAt.plus(6000L)
+        every { presenceTracingRiskRepository.allEntries() } returns flowOf(
+            listOf(
+                PtRiskLevelResult(
+                    calculatedAt = calculatedAt,
+                    presenceTracingDayRisk = null,
+                    riskState = RiskState.CALCULATION_FAILED
+                ),
+                PtRiskLevelResult(
+                    calculatedAt = ewCalculatedAt.minus(1000L),
+                    presenceTracingDayRisk = listOf(testPresenceTracingDayRisk),
+                    riskState = RiskState.INCREASED_RISK
+                )
+            )
+        )
+        every { presenceTracingRiskRepository.presenceTracingDayRisk } returns flowOf(listOf(testPresenceTracingDayRisk))
+
+        every { riskResultTables.latestAndLastSuccessful() } returns flowOf(listOf(testRiskLevelResultDao))
+        every { exposureWindowTables.getWindowsForResult(any()) } returns flowOf(listOf(testExposureWindowDaoWrapper))
+
+        runBlockingTest2(ignoreActive = true) {
+            val instance = createInstance(scope = this)
+
+            val riskLevelResult = instance.latestAndLastSuccessfulCombinedEwPtRiskLevelResult.first()
+
+            riskLevelResult.lastCalculated.calculatedAt shouldBe calculatedAt
+            riskLevelResult.lastCalculated.riskState shouldBe RiskState.INCREASED_RISK
+            riskLevelResult.lastSuccessfullyCalculated.calculatedAt shouldBe calculatedAt
+            riskLevelResult.lastSuccessfullyCalculated.riskState shouldBe RiskState.INCREASED_RISK
+
+            verify {
+                presenceTracingRiskRepository.allEntries()
+                presenceTracingRiskRepository.presenceTracingDayRisk
+            }
+        }
+    }
+
+    @Test
+    fun `latestAndLastSuccessfulCombinedEwPtRiskLevelResult are combined 2`() {
+        every { presenceTracingRiskRepository.allEntries() } returns flowOf(
+            listOf(
+                PtRiskLevelResult(
+                    calculatedAt = ewCalculatedAt.plus(6000L),
+                    presenceTracingDayRisk = null,
+                    riskState = RiskState.CALCULATION_FAILED
+                ),
+                PtRiskLevelResult(
+                    calculatedAt = ewCalculatedAt.minus(1000L),
+                    presenceTracingDayRisk = listOf(testPresenceTracingDayRisk),
+                    riskState = RiskState.INCREASED_RISK
+                )
+            )
+        )
+
+        val ewResultDao1 = PersistedRiskLevelResultDao(
+            id = "id1",
+            calculatedAt = ewCalculatedAt,
+            failureReason = EwRiskLevelResult.FailureReason.UNKNOWN,
+            aggregatedRiskResult = null
+        )
+        val ewResultDao2 = PersistedRiskLevelResultDao(
+            id = "id2",
+            calculatedAt = ewCalculatedAt.minus(2000L),
+            failureReason = null,
+            aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+                totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.LOW,
+                totalMinimumDistinctEncountersWithLowRisk = 1,
+                totalMinimumDistinctEncountersWithHighRisk = 0,
+                mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+                mostRecentDateWithHighRisk = Instant.ofEpochMilli(0),
+                numberOfDaysWithLowRisk = 5,
+                numberOfDaysWithHighRisk = 6
+            )
+        )
+        every { presenceTracingRiskRepository.presenceTracingDayRisk } returns flowOf(listOf(testPresenceTracingDayRisk))
+
+        every { riskResultTables.latestAndLastSuccessful() } returns flowOf(listOf(ewResultDao1, ewResultDao2))
+        every { exposureWindowTables.getWindowsForResult(any()) } returns flowOf(listOf(testExposureWindowDaoWrapper))
+
+        runBlockingTest2(ignoreActive = true) {
+            val instance = createInstance(scope = this)
+
+            val riskLevelResult = instance.latestAndLastSuccessfulCombinedEwPtRiskLevelResult.first()
+
+            riskLevelResult.lastCalculated.calculatedAt shouldBe ewCalculatedAt.plus(6000L)
+            riskLevelResult.lastCalculated.riskState shouldBe RiskState.CALCULATION_FAILED
+            riskLevelResult.lastSuccessfullyCalculated.calculatedAt shouldBe ewCalculatedAt
+            riskLevelResult.lastSuccessfullyCalculated.riskState shouldBe RiskState.INCREASED_RISK
+
+            verify {
+                presenceTracingRiskRepository.allEntries()
+                presenceTracingRiskRepository.presenceTracingDayRisk
+            }
+        }
+    }
+
+    @Test
+    fun `latestAndLastSuccessfulCombinedEwPtRiskLevelResult are combined 3`() {
+        every { presenceTracingRiskRepository.allEntries() } returns flowOf(
+            listOf(
+                PtRiskLevelResult(
+                    calculatedAt = ewCalculatedAt.plus(6000L),
+                    presenceTracingDayRisk = null,
+                    riskState = RiskState.CALCULATION_FAILED
+                ),
+                PtRiskLevelResult(
+                    calculatedAt = ewCalculatedAt.minus(100L),
+                    presenceTracingDayRisk = listOf(testPresenceTracingDayRisk),
+                    riskState = RiskState.LOW_RISK
+                )
+            )
+        )
+
+        val ewResultDao1 = PersistedRiskLevelResultDao(
+            id = "id1",
+            calculatedAt = ewCalculatedAt,
+            failureReason = EwRiskLevelResult.FailureReason.UNKNOWN,
+            aggregatedRiskResult = null
+        )
+        val ewResultDao2 = PersistedRiskLevelResultDao(
+            id = "id2",
+            calculatedAt = ewCalculatedAt.minus(200L),
+            failureReason = null,
+            aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+                totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+                totalMinimumDistinctEncountersWithLowRisk = 1,
+                totalMinimumDistinctEncountersWithHighRisk = 2,
+                mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+                mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+                numberOfDaysWithLowRisk = 5,
+                numberOfDaysWithHighRisk = 6
+            )
+        )
+        every { presenceTracingRiskRepository.presenceTracingDayRisk } returns flowOf(listOf(testPresenceTracingDayRisk))
+
+        every { riskResultTables.latestAndLastSuccessful() } returns flowOf(listOf(ewResultDao1, ewResultDao2))
+        every { exposureWindowTables.getWindowsForResult(any()) } returns flowOf(listOf(testExposureWindowDaoWrapper))
+
+        runBlockingTest2(ignoreActive = true) {
+            val instance = createInstance(scope = this)
+
+            val riskLevelResult = instance.latestAndLastSuccessfulCombinedEwPtRiskLevelResult.first()
+
+            riskLevelResult.lastCalculated.calculatedAt shouldBe ewCalculatedAt.plus(6000L)
+            riskLevelResult.lastCalculated.riskState shouldBe RiskState.CALCULATION_FAILED
+            riskLevelResult.lastSuccessfullyCalculated.calculatedAt shouldBe ewCalculatedAt
+            riskLevelResult.lastSuccessfullyCalculated.riskState shouldBe RiskState.LOW_RISK
+
+            verify {
+                presenceTracingRiskRepository.allEntries()
+                presenceTracingRiskRepository.presenceTracingDayRisk
+            }
+        }
+    }
+
+    @Test
+    fun `latestAndLastSuccessfulCombinedEwPtRiskLevelResult works when no pt result yet`() {
+
+        every { presenceTracingRiskRepository.allEntries() } returns flowOf(listOf())
+        every { presenceTracingRiskRepository.presenceTracingDayRisk } returns flowOf(listOf())
+
+        val ewResultDao1 = PersistedRiskLevelResultDao(
+            id = "id1",
+            calculatedAt = ewCalculatedAt,
+            failureReason = null,
+            aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+                totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.LOW,
+                totalMinimumDistinctEncountersWithLowRisk = 1,
+                totalMinimumDistinctEncountersWithHighRisk = 2,
+                mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+                mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+                numberOfDaysWithLowRisk = 5,
+                numberOfDaysWithHighRisk = 6
+            )
+        )
+        val ewResultDao2 = PersistedRiskLevelResultDao(
+            id = "id2",
+            calculatedAt = ewCalculatedAt.minus(200L),
+            failureReason = null,
+            aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
+                totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
+                totalMinimumDistinctEncountersWithLowRisk = 1,
+                totalMinimumDistinctEncountersWithHighRisk = 2,
+                mostRecentDateWithLowRisk = Instant.ofEpochMilli(3),
+                mostRecentDateWithHighRisk = Instant.ofEpochMilli(4),
+                numberOfDaysWithLowRisk = 5,
+                numberOfDaysWithHighRisk = 6
+            )
+        )
+
+        every { riskResultTables.latestAndLastSuccessful() } returns flowOf(listOf(ewResultDao1, ewResultDao2))
+        every { exposureWindowTables.getWindowsForResult(any()) } returns flowOf(listOf(testExposureWindowDaoWrapper))
+
+        runBlockingTest2(ignoreActive = true) {
+            val instance = createInstance(scope = this)
+
+            val riskLevelResult = instance.latestAndLastSuccessfulCombinedEwPtRiskLevelResult.first()
+
+            riskLevelResult.lastCalculated.calculatedAt shouldBe ewCalculatedAt
+            riskLevelResult.lastCalculated.riskState shouldBe RiskState.LOW_RISK
+            riskLevelResult.lastSuccessfullyCalculated.calculatedAt shouldBe ewCalculatedAt
+            riskLevelResult.lastSuccessfullyCalculated.riskState shouldBe RiskState.LOW_RISK
+        }
+    }
+
+    @Test
+    fun `errors when storing risk level result are rethrown`() = runBlockingTest {
         coEvery { riskResultTables.insertEntry(any()) } throws IllegalStateException("No body expects the...")
         val instance = createInstance()
         shouldThrow<java.lang.IllegalStateException> {
@@ -242,6 +609,14 @@ class BaseRiskLevelStorageTest : BaseTest() {
     @Test
     fun `clear works`() = runBlockingTest {
         createInstance().clear()
-        verify { database.clearAllTables() }
+        coVerify {
+            database.clearAllTables()
+            presenceTracingRiskRepository.clearAllTables()
+        }
     }
 }
+
+private val testPresenceTracingDayRisk = PresenceTracingDayRisk(
+    Instant.now().toLocalDateUtc(),
+    RiskState.INCREASED_RISK
+)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/CombineRiskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/CombineRiskTest.kt
index 824be93cc..a1099568a 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/CombineRiskTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/CombineRiskTest.kt
@@ -1,13 +1,17 @@
 package de.rki.coronawarnapp.risk.storage
 
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingDayRisk
+import com.google.android.gms.nearby.exposurenotification.ExposureWindow
+import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
+import de.rki.coronawarnapp.presencetracing.risk.calculation.PresenceTracingDayRisk
+import de.rki.coronawarnapp.risk.EwRiskLevelResult
 import de.rki.coronawarnapp.risk.RiskState
+import de.rki.coronawarnapp.risk.result.EwAggregatedRiskResult
 import de.rki.coronawarnapp.risk.result.ExposureWindowDayRisk
 import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel
 import io.kotest.matchers.shouldBe
+import org.joda.time.Instant
 import org.joda.time.LocalDate
 import org.junit.jupiter.api.Test
-import java.time.Instant
 
 class CombineRiskTest {
 
@@ -26,33 +30,33 @@ class CombineRiskTest {
             riskState = RiskState.CALCULATION_FAILED
         )
         val ewRisk = ExposureWindowDayRisk(
-            dateMillisSinceEpoch = Instant.parse("2021-03-22T14:00:00.000Z").toEpochMilli(),
+            dateMillisSinceEpoch = Instant.parse("2021-03-22T14:00:00.000Z").millis,
             riskLevel = RiskLevel.HIGH,
             0,
             0
         )
         val ewRisk2 = ExposureWindowDayRisk(
-            dateMillisSinceEpoch = Instant.parse("2021-03-19T14:00:00.000Z").toEpochMilli(),
+            dateMillisSinceEpoch = Instant.parse("2021-03-19T14:00:00.000Z").millis,
             riskLevel = RiskLevel.LOW,
             0,
             0
         )
         val ewRisk3 = ExposureWindowDayRisk(
-            dateMillisSinceEpoch = Instant.parse("2021-03-20T14:00:00.000Z").toEpochMilli(),
+            dateMillisSinceEpoch = Instant.parse("2021-03-20T14:00:00.000Z").millis,
             riskLevel = RiskLevel.UNSPECIFIED,
             0,
             0
         )
         val ewRisk4 = ExposureWindowDayRisk(
-            dateMillisSinceEpoch = Instant.parse("2021-03-15T14:00:00.000Z").toEpochMilli(),
+            dateMillisSinceEpoch = Instant.parse("2021-03-15T14:00:00.000Z").millis,
             riskLevel = RiskLevel.UNSPECIFIED,
             0,
             0
         )
 
-        val ptRiskList: List<PresenceTracingDayRisk> = listOf(ptRisk, ptRisk2, ptRisk3)
-        val exposureWindowDayRiskList: List<ExposureWindowDayRisk> = listOf(ewRisk, ewRisk2, ewRisk3, ewRisk4)
-        val result = combineRisk(ptRiskList, exposureWindowDayRiskList)
+        val ptDayRiskList: List<PresenceTracingDayRisk> = listOf(ptRisk, ptRisk2, ptRisk3)
+        val ewDayRiskList: List<ExposureWindowDayRisk> = listOf(ewRisk, ewRisk2, ewRisk3, ewRisk4)
+        val result = combineRisk(ptDayRiskList, ewDayRiskList)
         result.size shouldBe 5
         result.find {
             it.localDate == LocalDate(2021, 3, 15)
@@ -71,15 +75,87 @@ class CombineRiskTest {
         }!!.riskState shouldBe RiskState.INCREASED_RISK
     }
 
+    @Test
+    fun `combineEwPtRiskLevelResults works`() {
+        val startInstant = Instant.ofEpochMilli(10000)
+
+        val ptResult = PtRiskLevelResult(
+            calculatedAt = startInstant.plus(1000L),
+            riskState = RiskState.LOW_RISK
+        )
+        val ptResult2 = PtRiskLevelResult(
+            calculatedAt = startInstant.plus(3000L),
+            riskState = RiskState.CALCULATION_FAILED
+        )
+        val ptResult3 = PtRiskLevelResult(
+            calculatedAt = startInstant.plus(6000L),
+            riskState = RiskState.CALCULATION_FAILED
+        )
+        val ptResult4 = PtRiskLevelResult(
+            calculatedAt = startInstant.plus(7000L),
+            riskState = RiskState.CALCULATION_FAILED
+        )
+
+        val ptResults = listOf(ptResult, ptResult2, ptResult4, ptResult3)
+        val ewResult = createEwRiskLevelResult(
+            calculatedAt = startInstant.plus(2000L),
+            riskState = RiskState.CALCULATION_FAILED
+        )
+        val ewResult2 = createEwRiskLevelResult(
+            calculatedAt = startInstant.plus(4000L),
+            riskState = RiskState.INCREASED_RISK
+        )
+        val ewResult3 = createEwRiskLevelResult(
+            calculatedAt = startInstant.plus(5000L),
+            riskState = RiskState.CALCULATION_FAILED
+        )
+        val ewResult4 = createEwRiskLevelResult(
+            calculatedAt = startInstant.plus(8000L),
+            riskState = RiskState.CALCULATION_FAILED
+        )
+        val ewResults = listOf(ewResult, ewResult4, ewResult2, ewResult3)
+        val result = combineEwPtRiskLevelResults(ptResults, ewResults).sortedByDescending { it.calculatedAt }
+        result.size shouldBe 8
+        result[0].riskState shouldBe RiskState.CALCULATION_FAILED
+        result[0].calculatedAt shouldBe startInstant.plus(8000L)
+        result[1].riskState shouldBe RiskState.CALCULATION_FAILED
+        result[1].calculatedAt shouldBe startInstant.plus(7000L)
+        result[2].riskState shouldBe RiskState.CALCULATION_FAILED
+        result[2].calculatedAt shouldBe startInstant.plus(6000L)
+        result[3].riskState shouldBe RiskState.CALCULATION_FAILED
+        result[3].calculatedAt shouldBe startInstant.plus(5000L)
+        result[4].riskState shouldBe RiskState.INCREASED_RISK
+        result[4].calculatedAt shouldBe startInstant.plus(4000L)
+        result[5].riskState shouldBe RiskState.CALCULATION_FAILED
+        result[5].calculatedAt shouldBe startInstant.plus(3000L)
+        result[6].riskState shouldBe RiskState.LOW_RISK
+        result[6].calculatedAt shouldBe startInstant.plus(2000L)
+        result[7].riskState shouldBe RiskState.LOW_RISK
+        result[7].calculatedAt shouldBe startInstant.plus(1000L)
+    }
+
     @Test
     fun `max RiskState works`() {
-        max(RiskState.INCREASED_RISK, RiskState.INCREASED_RISK) shouldBe RiskState.INCREASED_RISK
-        max(RiskState.INCREASED_RISK, RiskState.LOW_RISK) shouldBe RiskState.INCREASED_RISK
-        max(RiskState.INCREASED_RISK, RiskState.CALCULATION_FAILED) shouldBe RiskState.INCREASED_RISK
-        max(RiskState.LOW_RISK, RiskState.INCREASED_RISK) shouldBe RiskState.INCREASED_RISK
-        max(RiskState.CALCULATION_FAILED, RiskState.INCREASED_RISK) shouldBe RiskState.INCREASED_RISK
-        max(RiskState.LOW_RISK, RiskState.LOW_RISK) shouldBe RiskState.LOW_RISK
-        max(RiskState.CALCULATION_FAILED, RiskState.LOW_RISK) shouldBe RiskState.LOW_RISK
-        max(RiskState.CALCULATION_FAILED, RiskState.CALCULATION_FAILED) shouldBe RiskState.CALCULATION_FAILED
+        combine(RiskState.INCREASED_RISK, RiskState.INCREASED_RISK) shouldBe RiskState.INCREASED_RISK
+        combine(RiskState.INCREASED_RISK, RiskState.LOW_RISK) shouldBe RiskState.INCREASED_RISK
+        combine(RiskState.INCREASED_RISK, RiskState.CALCULATION_FAILED) shouldBe RiskState.INCREASED_RISK
+        combine(RiskState.LOW_RISK, RiskState.INCREASED_RISK) shouldBe RiskState.INCREASED_RISK
+        combine(RiskState.CALCULATION_FAILED, RiskState.INCREASED_RISK) shouldBe RiskState.INCREASED_RISK
+        combine(RiskState.LOW_RISK, RiskState.LOW_RISK) shouldBe RiskState.LOW_RISK
+        combine(RiskState.CALCULATION_FAILED, RiskState.LOW_RISK) shouldBe RiskState.LOW_RISK
+        combine(RiskState.CALCULATION_FAILED, RiskState.CALCULATION_FAILED) shouldBe RiskState.CALCULATION_FAILED
     }
 }
+
+private fun createEwRiskLevelResult(
+    calculatedAt: Instant,
+    riskState: RiskState
+): EwRiskLevelResult = object : EwRiskLevelResult {
+    override val calculatedAt: Instant = calculatedAt
+    override val riskState: RiskState = riskState
+    override val failureReason: EwRiskLevelResult.FailureReason? = null
+    override val ewAggregatedRiskResult: EwAggregatedRiskResult? = null
+    override val exposureWindows: List<ExposureWindow>? = null
+    override val matchedKeyCount: Int = 0
+    override val daysWithEncounters: Int = 0
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/RiskStorageTestData.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/RiskStorageTestData.kt
index 34c79dac1..5cb8e090c 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/RiskStorageTestData.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/storage/RiskStorageTestData.kt
@@ -14,9 +14,11 @@ import org.joda.time.Instant
 
 object RiskStorageTestData {
 
+    val ewCalculatedAt = Instant.ofEpochMilli(9999L)
+
     val testRiskLevelResultDao = PersistedRiskLevelResultDao(
         id = "riskresult-id",
-        calculatedAt = Instant.ofEpochMilli(9999L),
+        calculatedAt = ewCalculatedAt,
         failureReason = null,
         aggregatedRiskResult = PersistedRiskLevelResultDao.PersistedAggregatedRiskResult(
             totalRiskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
@@ -40,7 +42,7 @@ object RiskStorageTestData {
     )
 
     val testRisklevelResult = EwRiskLevelTaskResult(
-        calculatedAt = Instant.ofEpochMilli(9999L),
+        calculatedAt = ewCalculatedAt,
         ewAggregatedRiskResult = testAggregatedRiskResult,
         exposureWindows = null
     )
@@ -76,14 +78,14 @@ object RiskStorageTestData {
     }.build()
 
     val testAggregatedRiskPerDateResult = ExposureWindowDayRisk(
-        dateMillisSinceEpoch = 9999L,
+        dateMillisSinceEpoch = ewCalculatedAt.millis,
         riskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
         minimumDistinctEncountersWithLowRisk = 0,
         minimumDistinctEncountersWithHighRisk = 0
     )
 
     val testPersistedAggregatedRiskPerDateResult = PersistedAggregatedRiskPerDateResult(
-        dateMillisSinceEpoch = 9999L,
+        dateMillisSinceEpoch = ewCalculatedAt.millis,
         riskLevel = RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH,
         minimumDistinctEncountersWithLowRisk = 0,
         minimumDistinctEncountersWithHighRisk = 0
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/SubmissionRepositoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/SubmissionRepositoryTest.kt
index 30de3e989..38282aaf9 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/SubmissionRepositoryTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/SubmissionRepositoryTest.kt
@@ -18,6 +18,7 @@ import de.rki.coronawarnapp.util.di.ApplicationComponent
 import de.rki.coronawarnapp.util.encryptionmigration.EncryptedPreferencesFactory
 import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool
 import de.rki.coronawarnapp.util.formatter.TestResult
+import de.rki.coronawarnapp.worker.BackgroundWorkScheduler
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
 import io.mockk.Runs
@@ -57,6 +58,7 @@ class SubmissionRepositoryTest : BaseTest() {
     @MockK lateinit var analyticsKeySubmissionCollector: AnalyticsKeySubmissionCollector
     @MockK lateinit var tracingSettings: TracingSettings
     @MockK lateinit var testResultDataCollector: TestResultDataCollector
+    @MockK lateinit var backgroundWorkScheduler: BackgroundWorkScheduler
 
     private val guid = "123456-12345678-1234-4DA7-B166-B86D85475064"
     private val tan = "123456-12345678-1234-4DA7-B166-B86D85475064"
@@ -99,6 +101,10 @@ class SubmissionRepositoryTest : BaseTest() {
         every { testResultDataCollector.updatePendingTestResultReceivedTime(any()) } just Runs
         coEvery { testResultDataCollector.saveTestResultAnalyticsSettings(any()) } just Runs
         every { testResultDataCollector.clear() } just Runs
+
+        backgroundWorkScheduler.apply {
+            every { startWorkScheduler() } just Runs
+        }
     }
 
     fun createInstance(scope: CoroutineScope) = SubmissionRepository(
@@ -111,7 +117,8 @@ class SubmissionRepositoryTest : BaseTest() {
         backgroundNoise = backgroundNoise,
         analyticsKeySubmissionCollector = analyticsKeySubmissionCollector,
         tracingSettings = tracingSettings,
-        testResultDataCollector = testResultDataCollector
+        testResultDataCollector = testResultDataCollector,
+        backgroundWorkScheduler = backgroundWorkScheduler
     )
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/task/SubmissionTaskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/task/SubmissionTaskTest.kt
index 0cb6a4ad7..280202997 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/task/SubmissionTaskTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/submission/task/SubmissionTaskTest.kt
@@ -30,7 +30,6 @@ import io.mockk.every
 import io.mockk.impl.annotations.MockK
 import io.mockk.just
 import io.mockk.mockk
-import io.mockk.mockkObject
 import io.mockk.verify
 import kotlinx.coroutines.flow.emptyFlow
 import kotlinx.coroutines.flow.flowOf
@@ -63,6 +62,7 @@ class SubmissionTaskTest : BaseTest() {
     @MockK lateinit var analyticsKeySubmissionCollector: AnalyticsKeySubmissionCollector
     @MockK lateinit var checkInsTransformer: CheckInsTransformer
     @MockK lateinit var checkInRepository: CheckInRepository
+    @MockK lateinit var backgroundWorkScheduler: BackgroundWorkScheduler
 
     private lateinit var settingSymptomsPreference: FlowPreference<Symptoms?>
 
@@ -80,9 +80,8 @@ class SubmissionTaskTest : BaseTest() {
         every { submissionSettings.registrationToken } returns registrationToken
         every { submissionSettings.isSubmissionSuccessful = any() } just Runs
 
-        mockkObject(BackgroundWorkScheduler)
-        every { BackgroundWorkScheduler.stopWorkScheduler() } just Runs
-        every { BackgroundWorkScheduler.startWorkScheduler() } just Runs
+        every { backgroundWorkScheduler.stopWorkScheduler() } just Runs
+        every { backgroundWorkScheduler.startWorkScheduler() } just Runs
 
         every { tekBatch.keys } returns listOf(tek)
         every { tekHistoryStorage.tekData } returns flowOf(listOf(tekBatch))
@@ -131,7 +130,8 @@ class SubmissionTaskTest : BaseTest() {
         testResultAvailableNotificationService = testResultAvailableNotificationService,
         analyticsKeySubmissionCollector = analyticsKeySubmissionCollector,
         checkInsRepository = checkInRepository,
-        checkInsTransformer = checkInsTransformer
+        checkInsTransformer = checkInsTransformer,
+        backgroundWorkScheduler = backgroundWorkScheduler,
     )
 
     @Test
@@ -183,9 +183,9 @@ class SubmissionTaskTest : BaseTest() {
 
             autoSubmission.updateMode(AutoSubmission.Mode.DISABLED)
 
-            BackgroundWorkScheduler.stopWorkScheduler()
+            backgroundWorkScheduler.stopWorkScheduler()
             submissionSettings.isSubmissionSuccessful = true
-            BackgroundWorkScheduler.startWorkScheduler()
+            backgroundWorkScheduler.startWorkScheduler()
 
             shareTestResultNotificationService.cancelSharePositiveTestResultNotification()
             testResultAvailableNotificationService.cancelTestResultAvailableNotification()
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsItemProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsItemProviderTest.kt
index 27175e36d..daf12a721 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsItemProviderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/ui/details/TracingDetailsItemProviderTest.kt
@@ -5,12 +5,11 @@ import android.content.res.Resources
 import com.google.android.gms.nearby.exposurenotification.ExposureWindow
 import de.rki.coronawarnapp.datadonation.survey.Surveys
 import de.rki.coronawarnapp.installTime.InstallTimeProvider
-import de.rki.coronawarnapp.presencetracing.risk.PtRiskLevelResult
+import de.rki.coronawarnapp.risk.CombinedEwPtRiskLevelResult
 import de.rki.coronawarnapp.risk.EwRiskLevelTaskResult
-import de.rki.coronawarnapp.risk.ProtoRiskLevel
+import de.rki.coronawarnapp.risk.LastCombinedRiskResults
 import de.rki.coronawarnapp.risk.RiskState
 import de.rki.coronawarnapp.risk.result.EwAggregatedRiskResult
-import de.rki.coronawarnapp.risk.storage.CombinedEwPtRiskLevelResult
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.tracing.ui.details.items.additionalinfos.AdditionalInfoLowRiskBox
@@ -46,6 +45,8 @@ class TracingDetailsItemProviderTest : BaseTest() {
     @MockK lateinit var installTimeProvider: InstallTimeProvider
     @MockK lateinit var surveys: Surveys
 
+    @MockK(relaxed = true) lateinit var combinedResult: CombinedEwPtRiskLevelResult
+
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
@@ -61,22 +62,23 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
     private fun prepare(
         status: GeneralTracingStatus.Status,
-        riskLevel: ProtoRiskLevel,
+        riskState: RiskState,
         matchedKeyCount: Int,
         daysSinceInstallation: Long,
         availableSurveys: List<Surveys.Type> = emptyList()
     ) {
         every { tracingStatus.generalStatus } returns flowOf(status)
-        every { ewAggregatedRiskResult.totalRiskLevel } returns riskLevel
         every { installTimeProvider.daysSinceInstallation } returns daysSinceInstallation
         every { surveys.availableSurveys } returns flowOf(availableSurveys)
 
-        if (riskLevel == ProtoRiskLevel.LOW) {
+        if (riskState == RiskState.LOW_RISK) {
             every { ewAggregatedRiskResult.isLowRisk() } returns true
-        } else if (riskLevel == ProtoRiskLevel.HIGH) {
+        } else if (riskState == RiskState.INCREASED_RISK) {
             every { ewAggregatedRiskResult.isIncreasedRisk() } returns true
         }
 
+        every { combinedResult.riskState } returns riskState
+
         val exposureWindow: ExposureWindow = mockk()
 
         val ewRiskLevelTaskResult = EwRiskLevelTaskResult(
@@ -85,18 +87,16 @@ class TracingDetailsItemProviderTest : BaseTest() {
             exposureWindows = listOf(exposureWindow)
         )
 
-        val ptRiskLevelResult = PtRiskLevelResult(
-            calculatedAt = Instant.EPOCH,
-            riskState = RiskState.CALCULATION_FAILED
-        )
-        val combined = CombinedEwPtRiskLevelResult(
-            ewRiskLevelResult = ewRiskLevelTaskResult,
-            ptRiskLevelResult = ptRiskLevelResult
+        every { combinedResult.ewRiskLevelResult } returns ewRiskLevelTaskResult
+
+        val lastCombined = LastCombinedRiskResults(
+            lastCalculated = combinedResult,
+            lastSuccessfullyCalculated = combinedResult
         )
         every { ewRiskLevelTaskResult.matchedKeyCount } returns matchedKeyCount
-        every { riskLevelStorage.latestAndLastSuccessfulEwRiskLevelResult } returns flowOf(listOf(ewRiskLevelTaskResult))
-        // TODO tests
-        every { riskLevelStorage.latestAndLastSuccessfulCombinedEwPtRiskLevelResult } returns flowOf(listOf(combined))
+        every { riskLevelStorage.latestAndLastSuccessfulEwRiskLevelResult } returns
+            flowOf(listOf(ewRiskLevelTaskResult))
+        every { riskLevelStorage.latestAndLastSuccessfulCombinedEwPtRiskLevelResult } returns flowOf(lastCombined)
     }
 
     @Test
@@ -104,7 +104,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.LOW,
+            riskState = RiskState.LOW_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 1
         )
@@ -121,7 +121,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.LOW,
+            riskState = RiskState.LOW_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -138,7 +138,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.HIGH,
+            riskState = RiskState.INCREASED_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -155,7 +155,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.HIGH,
+            riskState = RiskState.INCREASED_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -174,7 +174,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.LOW,
+            riskState = RiskState.LOW_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -193,7 +193,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.LOW,
+            riskState = RiskState.LOW_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0,
         )
@@ -213,7 +213,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.HIGH,
+            riskState = RiskState.INCREASED_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -233,7 +233,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.UNRECOGNIZED,
+            riskState = RiskState.CALCULATION_FAILED,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -253,7 +253,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_INACTIVE,
-            riskLevel = ProtoRiskLevel.LOW,
+            riskState = RiskState.LOW_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -273,7 +273,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_INACTIVE,
-            riskLevel = ProtoRiskLevel.LOW,
+            riskState = RiskState.LOW_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -296,7 +296,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.UNRECOGNIZED,
+            riskState = RiskState.CALCULATION_FAILED,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -319,7 +319,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.LOW,
+            riskState = RiskState.LOW_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -342,7 +342,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.HIGH,
+            riskState = RiskState.INCREASED_RISK,
             daysSinceInstallation = 4,
             matchedKeyCount = 0
         )
@@ -365,7 +365,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.LOW,
+            riskState = RiskState.LOW_RISK,
             matchedKeyCount = 0,
             daysSinceInstallation = 4,
             availableSurveys = listOf(Surveys.Type.HIGH_RISK_ENCOUNTER)
@@ -386,7 +386,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.HIGH,
+            riskState = RiskState.INCREASED_RISK,
             matchedKeyCount = 0,
             daysSinceInstallation = 4,
             availableSurveys = emptyList()
@@ -407,7 +407,7 @@ class TracingDetailsItemProviderTest : BaseTest() {
 
         prepare(
             status = GeneralTracingStatus.Status.TRACING_ACTIVE,
-            riskLevel = ProtoRiskLevel.HIGH,
+            riskState = RiskState.INCREASED_RISK,
             matchedKeyCount = 0,
             daysSinceInstallation = 4,
             availableSurveys = listOf(Surveys.Type.HIGH_RISK_ENCOUNTER)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/eventregistration/organizer/poster/QrCodePosterViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/eventregistration/organizer/poster/QrCodePosterViewModelTest.kt
index 78793ae28..7ead8a9e2 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/eventregistration/organizer/poster/QrCodePosterViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/eventregistration/organizer/poster/QrCodePosterViewModelTest.kt
@@ -13,8 +13,8 @@ import io.mockk.MockKAnnotations
 import io.mockk.coEvery
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
-import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
 import org.junit.jupiter.api.extension.ExtendWith
 import testhelpers.BaseTest
 import testhelpers.TestDispatcherProvider
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/TimeAndDateExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/TimeAndDateExtensionsTest.kt
index a02df66dd..4c7411bca 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/TimeAndDateExtensionsTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/TimeAndDateExtensionsTest.kt
@@ -4,6 +4,7 @@ import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.ageInDays
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.calculateDays
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.derive10MinutesInterval
+import de.rki.coronawarnapp.util.TimeAndDateExtensions.deriveHourInterval
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.getCurrentHourUTC
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.seconds
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.secondsToInstant
@@ -99,4 +100,16 @@ class TimeAndDateExtensionsTest : BaseTest() {
         Instant.parse("1970-01-01T00:00:00.000Z")
             .derive10MinutesInterval() shouldBe 0
     }
+
+    @Test
+    fun `derive 1 hour interval should be 0`() {
+        Instant.parse("1970-01-01T00:00:00.000Z")
+            .deriveHourInterval() shouldBe 0
+    }
+
+    @Test
+    fun `derive 1 hour interval`() {
+        Instant.parse("2021-02-15T13:52:05+00:00")
+            .deriveHourInterval() shouldBe 448165
+    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/formatter/FormatterStatisticsHelperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/formatter/FormatterStatisticsHelperTest.kt
index 32de83877..9a6302454 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/formatter/FormatterStatisticsHelperTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/formatter/FormatterStatisticsHelperTest.kt
@@ -13,10 +13,10 @@ import io.mockk.every
 import io.mockk.impl.annotations.MockK
 import io.mockk.mockkStatic
 import io.mockk.slot
-import org.joda.time.DateTime
+import org.joda.time.Duration
 import org.joda.time.Instant
-import org.junit.Before
-import org.junit.Test
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
 import java.util.Locale
 
@@ -26,9 +26,9 @@ class FormatterStatisticsHelperTest : BaseTest() {
     private lateinit var context: Context
 
     private val today = Instant()
-    private val yesterday = DateTime().minusDays(1).toInstant()
+    private val yesterday = today.minus(Duration.standardDays(1))
 
-    @Before
+    @BeforeEach
     fun setUp() {
         val slot = slot<String>()
         MockKAnnotations.init(this)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactoryTest.kt
index 4fdcc4800..ef18b7fd6 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactoryTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/CWAWorkerFactoryTest.kt
@@ -4,7 +4,7 @@ import android.content.Context
 import androidx.work.ListenableWorker
 import androidx.work.WorkerParameters
 import androidx.work.impl.workers.DiagnosticsWorker
-import de.rki.coronawarnapp.worker.DiagnosisKeyRetrievalOneTimeWorker
+import de.rki.coronawarnapp.diagnosiskeys.execution.DiagnosisKeyRetrievalWorker
 import io.kotest.matchers.shouldBe
 import io.kotest.matchers.shouldNotBe
 import io.mockk.MockKAnnotations
@@ -22,15 +22,15 @@ class CWAWorkerFactoryTest : BaseTest() {
 
     @MockK lateinit var context: Context
     @MockK lateinit var workerParameters: WorkerParameters
-    @MockK lateinit var ourWorker: DiagnosisKeyRetrievalOneTimeWorker
-    @MockK lateinit var ourFactory: DiagnosisKeyRetrievalOneTimeWorker.Factory
+    @MockK lateinit var ourWorker: DiagnosisKeyRetrievalWorker
+    @MockK lateinit var ourFactory: DiagnosisKeyRetrievalWorker.Factory
 
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
 
         every { ourFactory.create(context, workerParameters) } returns ourWorker
-        workerFactories[DiagnosisKeyRetrievalOneTimeWorker::class.java] = Provider { ourFactory }
+        workerFactories[DiagnosisKeyRetrievalWorker::class.java] = Provider { ourFactory }
     }
 
     fun createInstance() = CWAWorkerFactory(
@@ -42,7 +42,7 @@ class CWAWorkerFactoryTest : BaseTest() {
         val instance = createInstance()
         instance.createWorker(
             context,
-            DiagnosisKeyRetrievalOneTimeWorker::class.qualifiedName!!,
+            DiagnosisKeyRetrievalWorker::class.qualifiedName!!,
             workerParameters
         ) shouldBe ourWorker
     }
@@ -55,4 +55,20 @@ class CWAWorkerFactoryTest : BaseTest() {
         val worker2 = instance.createWorker(context, DiagnosticsWorker::class.qualifiedName!!, workerParameters)
         worker1 shouldNotBe worker2
     }
+
+    /**
+     * Workers are initialized based on their class name.
+     * That class name is stored as part of the worker arguments.
+     * If we refactor a worker, the previously used classname will be unknown.
+     * Returning null will cause the periodic worker to be dequeued.
+     */
+    @Test
+    fun `class names that can not be instantiated are treated like an unknown worker`() {
+        val instance = createInstance()
+        instance.createWorker(
+            context,
+            "abc.im.a.ghost.def",
+            workerParameters
+        ) shouldBe null
+    }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt
index 4c7049e9f..67bdaf665 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt
@@ -18,6 +18,7 @@ import de.rki.coronawarnapp.notification.TestResultAvailableNotificationService
 import de.rki.coronawarnapp.playbook.Playbook
 import de.rki.coronawarnapp.presencetracing.checkins.checkout.CheckOutNotification
 import de.rki.coronawarnapp.presencetracing.checkins.checkout.auto.AutoCheckOut
+import de.rki.coronawarnapp.risk.execution.RiskWorkScheduler
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.task.TaskController
 import de.rki.coronawarnapp.util.di.AppContext
@@ -143,4 +144,7 @@ class MockProvider {
 
     @Provides
     fun checkOutNotification(): CheckOutNotification = mockk()
+
+    @Provides
+    fun riskWorkScheduler(): RiskWorkScheduler = mockk()
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt
deleted file mode 100644
index f77fd6a7e..000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt
+++ /dev/null
@@ -1,16 +0,0 @@
-package de.rki.coronawarnapp.worker
-
-import io.kotest.matchers.shouldBe
-import org.joda.time.Duration
-import org.junit.jupiter.api.Test
-import testhelpers.BaseTest
-
-class BackgroundWorkBuilderTest : BaseTest() {
-
-    @Test
-    fun `worker interval for key retrieval is 60 minutes, once every hour`() {
-        buildDiagnosisKeyRetrievalPeriodicWork().apply {
-            workSpec.intervalDuration shouldBe Duration.standardMinutes(60).millis
-        }
-    }
-}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorkerTest.kt
index 499dada21..a59702bd5 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorkerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorkerTest.kt
@@ -2,7 +2,6 @@ package de.rki.coronawarnapp.worker
 
 import android.content.Context
 import androidx.work.ListenableWorker
-import androidx.work.Operation
 import androidx.work.WorkRequest
 import androidx.work.WorkerParameters
 import de.rki.coronawarnapp.notification.GeneralNotifications
@@ -18,7 +17,6 @@ import de.rki.coronawarnapp.util.di.ApplicationComponent
 import de.rki.coronawarnapp.util.encryptionmigration.EncryptedPreferencesFactory
 import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool
 import de.rki.coronawarnapp.util.formatter.TestResult
-import de.rki.coronawarnapp.worker.BackgroundWorkScheduler.stop
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
 import io.mockk.Runs
@@ -48,9 +46,10 @@ class DiagnosisTestResultRetrievalPeriodicWorkerTest : BaseTest() {
     @MockK lateinit var appComponent: ApplicationComponent
     @MockK lateinit var encryptedPreferencesFactory: EncryptedPreferencesFactory
     @MockK lateinit var encryptionErrorResetTool: EncryptionErrorResetTool
-    @MockK lateinit var operation: Operation
     @MockK lateinit var timeStamper: TimeStamper
     @MockK lateinit var tracingSettings: TracingSettings
+    @MockK lateinit var backgroundWorkScheduler: BackgroundWorkScheduler
+
     @RelaxedMockK lateinit var workerParams: WorkerParameters
     private val currentInstant = Instant.ofEpochSecond(1611764225)
     private val registrationToken = "test token"
@@ -72,8 +71,7 @@ class DiagnosisTestResultRetrievalPeriodicWorkerTest : BaseTest() {
 
         every { submissionSettings.registrationToken } returns mockFlowPreference(registrationToken)
 
-        mockkObject(BackgroundWorkScheduler)
-        every { BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop() } returns operation
+        every { backgroundWorkScheduler.stopDiagnosisTestResultPeriodicWork() } just Runs
     }
 
     @Test
@@ -83,7 +81,7 @@ class DiagnosisTestResultRetrievalPeriodicWorkerTest : BaseTest() {
             val worker = createWorker()
             val result = worker.doWork()
             coVerify(exactly = 0) { submissionService.asyncRequestTestResult(any()) }
-            verify(exactly = 1) { BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop() }
+            verify(exactly = 1) { backgroundWorkScheduler.stopDiagnosisTestResultPeriodicWork() }
             result shouldBe ListenableWorker.Result.success()
         }
     }
@@ -95,7 +93,7 @@ class DiagnosisTestResultRetrievalPeriodicWorkerTest : BaseTest() {
             val worker = createWorker()
             val result = worker.doWork()
             coVerify(exactly = 0) { submissionService.asyncRequestTestResult(any()) }
-            verify(exactly = 1) { BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop() }
+            verify(exactly = 1) { backgroundWorkScheduler.stopDiagnosisTestResultPeriodicWork() }
             result shouldBe ListenableWorker.Result.success()
         }
     }
@@ -109,7 +107,7 @@ class DiagnosisTestResultRetrievalPeriodicWorkerTest : BaseTest() {
             val worker = createWorker()
             val result = worker.doWork()
             coVerify(exactly = 0) { submissionService.asyncRequestTestResult(any()) }
-            verify(exactly = 1) { BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop() }
+            verify(exactly = 1) { backgroundWorkScheduler.stopDiagnosisTestResultPeriodicWork() }
             result shouldBe ListenableWorker.Result.success()
         }
     }
@@ -228,7 +226,7 @@ class DiagnosisTestResultRetrievalPeriodicWorkerTest : BaseTest() {
                     NotificationConstants.NEW_MESSAGE_RISK_LEVEL_SCORE_NOTIFICATION_ID
                 )
             }
-            coVerify(exactly = 0) { BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop() }
+            coVerify(exactly = 0) { backgroundWorkScheduler.stopDiagnosisTestResultPeriodicWork() }
             result shouldBe ListenableWorker.Result.success()
         }
     }
@@ -240,7 +238,7 @@ class DiagnosisTestResultRetrievalPeriodicWorkerTest : BaseTest() {
             val worker = createWorker()
             val result = worker.doWork()
             coVerify(exactly = 1) { submissionService.asyncRequestTestResult(any()) }
-            coVerify(exactly = 0) { BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop() }
+            coVerify(exactly = 0) { backgroundWorkScheduler.stopDiagnosisTestResultPeriodicWork() }
             result shouldBe ListenableWorker.Result.retry()
         }
     }
@@ -253,6 +251,7 @@ class DiagnosisTestResultRetrievalPeriodicWorkerTest : BaseTest() {
         submissionSettings,
         submissionService,
         timeStamper,
-        tracingSettings
+        tracingSettings,
+        backgroundWorkScheduler,
     )
 }
diff --git a/Corona-Warn-App/src/testDevice/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt b/Corona-Warn-App/src/testDevice/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
index 8c676d6ab..274a5e5f9 100644
--- a/Corona-Warn-App/src/testDevice/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
+++ b/Corona-Warn-App/src/testDevice/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
@@ -1,7 +1,7 @@
 package de.rki.coronawarnapp.test.risk.storage
 
 import com.google.android.gms.nearby.exposurenotification.ExposureWindow
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
 import de.rki.coronawarnapp.risk.EwRiskLevelTaskResult
 import de.rki.coronawarnapp.risk.result.EwAggregatedRiskResult
 import de.rki.coronawarnapp.risk.storage.DefaultRiskLevelStorage
@@ -88,7 +88,7 @@ class DefaultRiskLevelStorageTest : BaseTest() {
 
         every { presenceTracingRiskRepository.traceLocationCheckInRiskStates } returns emptyFlow()
         every { presenceTracingRiskRepository.presenceTracingDayRisk } returns emptyFlow()
-        every { presenceTracingRiskRepository.latestAndLastSuccessful() } returns emptyFlow()
+        every { presenceTracingRiskRepository.allEntries() } returns emptyFlow()
         every { presenceTracingRiskRepository.latestEntries(any()) } returns emptyFlow()
     }
 
diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
index 24478cb80..72eae2991 100644
--- a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
+++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/risk/storage/DefaultRiskLevelStorageTest.kt
@@ -1,7 +1,7 @@
 package de.rki.coronawarnapp.test.risk.storage
 
 import com.google.android.gms.nearby.exposurenotification.ExposureWindow
-import de.rki.coronawarnapp.presencetracing.risk.PresenceTracingRiskRepository
+import de.rki.coronawarnapp.presencetracing.risk.storage.PresenceTracingRiskRepository
 import de.rki.coronawarnapp.risk.EwRiskLevelTaskResult
 import de.rki.coronawarnapp.risk.result.EwAggregatedRiskResult
 import de.rki.coronawarnapp.risk.storage.DefaultRiskLevelStorage
@@ -87,7 +87,7 @@ class DefaultRiskLevelStorageTest : BaseTestInstrumentation() {
 
         every { presenceTracingRiskRepository.traceLocationCheckInRiskStates } returns emptyFlow()
         every { presenceTracingRiskRepository.presenceTracingDayRisk } returns emptyFlow()
-        every { presenceTracingRiskRepository.latestAndLastSuccessful() } returns emptyFlow()
+        every { presenceTracingRiskRepository.allEntries() } returns emptyFlow()
         every { presenceTracingRiskRepository.latestEntries(any()) } returns emptyFlow()
     }
 
-- 
GitLab