From 383cd8eafb73a99a03c637b1c09c59b2c48596c8 Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <matthias.urhahn@sap.com>
Date: Mon, 26 Oct 2020 20:05:27 +0100
Subject: [PATCH] Monitor ENF calculation more closely for better progress
 feedback (EXPOSUREAPP-2743) (#1473)

* Add a `CalcluationTracker` class that can tell us about the ENF's calcluations.

* Introduces new reactive data provider "HotData" that allows lazy init and safe updates.

* Add calculation time out enforcer (60min), check every 5min.

* Show `isRefreshing` when we are either downloading keys or the ENF is still calculating.

* Rename "token" to "identifier", we'll likely keep using this class for ENF Window Mode,
which has no more "tokens".

* Remove "state", we can only know whether it's running and that can be based on the `finishedAt` timestamp.
Also improve test readability a bit and added more edge cases when checking timeouts.

* Additional test cases for ENF calculation edge cases

* We are fine with tracking the last 5 calculations.

* Exclude timeouts from `latestFinishedCalculation`

* HotData should be named HotDataFlow

* Clean up coroutine/flow packages.

* Remove additional combineTransform instances, to prevent accidental casting errors in the future.

* We should only forward calls to the provider and the calculation tracker if the list of key files is non-empty.

* Add unit test to check for GSON data class restoration behavior with transient fields.
GSON sets it to false, and does not eval the properties to set them.

* Lints, Lints, Lints

* Only check the calculation status of the newest submission.
The chance for overlapping calculations is rare with batched key submission,
and if gives us a lower chance of actually being affected by timeouts.

* Reduce timeout for tracked calculations to 15 minutes after discussion with Maximilian.
In worst case scenarios no calculation exceeded 7 minutes.

* Reduce timeout check interval to 3 minutes, due to lowered overall timeout limit.

* Add additional test case that checks that a late result, past timeout, overwrites the timeout.

* Create `BaseGson` instance within the DI graph use it.
Allows use to later set global settings (pretty print for testers?),
or hook up custom serializers app-wide.
---
 Corona-Warn-App/src/main/AndroidManifest.xml  |   1 +
 .../environment/EnvironmentSetup.kt           |   7 +-
 ...eption.kt => UnknownBroadcastException.kt} |  12 +-
 .../de/rki/coronawarnapp/nearby/ENFClient.kt  |  30 +-
 .../de/rki/coronawarnapp/nearby/ENFModule.kt  |   7 +
 .../modules/calculationtracker/Calculation.kt |  31 ++
 .../calculationtracker/CalculationTracker.kt  |  11 +
 .../CalculationTrackerStorage.kt              |  60 ++++
 .../DefaultCalculationTracker.kt              | 143 ++++++++++
 .../receiver/ExposureStateUpdateReceiver.kt   | 114 +++++---
 .../coronawarnapp/receiver/ReceiverBinder.kt  |   7 +-
 .../storage/TracingRepository.kt              |  12 +-
 .../ui/tracing/card/TracingCardState.kt       |   8 +-
 .../tracing/card/TracingCardStateProvider.kt  |  66 +++--
 .../ui/tracing/common/BaseTracingState.kt     |   2 +-
 .../ui/tracing/details/TracingDetailsState.kt |   2 +-
 .../details/TracingDetailsStateProvider.kt    |  26 +-
 .../SettingsTracingFragmentViewModel.kt       |   2 +-
 .../util/BackgroundModeStatus.kt              |   2 +-
 .../rki/coronawarnapp/util/MapExtensions.kt   |   5 +
 .../util/bluetooth/BluetoothProvider.kt       |   2 +-
 .../util/di/ApplicationComponent.kt           |   4 +-
 .../{coroutine => flow}/FlowExtensions.kt     |  35 ++-
 .../coronawarnapp/util/flow/HotDataFlow.kt    |  68 +++++
 .../coronawarnapp/util/gson/GsonExtensions.kt |  18 ++
 .../util/location/LocationProvider.kt         |   2 +-
 .../util/serialization/BaseGson.kt            |   8 +
 .../util/serialization/SerializationModule.kt |  16 ++
 .../environment/EnvironmentSetupTest.kt       |   7 +-
 .../rki/coronawarnapp/nearby/ENFClientTest.kt | 163 ++++++++++-
 .../calculationtracker/CalculationTest.kt     |  35 +++
 .../CalculationTrackerStorageTest.kt          | 133 +++++++++
 .../DefaultCalculationTrackerTest.kt          | 265 ++++++++++++++++++
 .../ExposureStateUpdateReceiverTest.kt        |  88 ++++--
 .../coronawarnapp/task/TaskControllerTest.kt  |   4 +-
 .../tracing/GeneralTracingStatusTest.kt       |   4 +-
 .../SubmissionCountrySelectViewModelTest.kt   |   3 +-
 .../ui/tracing/card/TracingCardStateTest.kt   |   2 +-
 .../ui/tracing/common/BaseTracingStateTest.kt |   2 +-
 .../details/TracingDetailsStateTest.kt        |   2 +-
 .../util/bluetooth/BluetoothProviderTest.kt   |   4 +-
 .../util/flow/HotDataFlowTest.kt              | 158 +++++++++++
 .../util/location/LocationProviderTest.kt     |   4 +-
 .../java/testhelpers/coroutines/FlowTest.kt   |  84 ++++--
 .../testhelpers/coroutines/TestExtensions.kt  |  44 +++
 45 files changed, 1530 insertions(+), 173 deletions(-)
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/{WrongReceiverException.kt => UnknownBroadcastException.kt} (55%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/MapExtensions.kt
 rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/{coroutine => flow}/FlowExtensions.kt (51%)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/BaseGson.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt

diff --git a/Corona-Warn-App/src/main/AndroidManifest.xml b/Corona-Warn-App/src/main/AndroidManifest.xml
index dad22a4eb..543b20f88 100644
--- a/Corona-Warn-App/src/main/AndroidManifest.xml
+++ b/Corona-Warn-App/src/main/AndroidManifest.xml
@@ -43,6 +43,7 @@
             android:permission="com.google.android.gms.nearby.exposurenotification.EXPOSURE_CALLBACK">
             <intent-filter>
                 <action android:name="com.google.android.gms.exposurenotification.ACTION_EXPOSURE_STATE_UPDATED" />
+                <action android:name="com.google.android.gms.exposurenotification.ACTION_EXPOSURE_NOT_FOUND" />
             </intent-filter>
         </receiver>
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt
index f8937d9b9..1acfb328e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt
@@ -2,7 +2,7 @@ package de.rki.coronawarnapp.environment
 
 import android.content.Context
 import androidx.core.content.edit
-import com.google.gson.GsonBuilder
+import com.google.gson.Gson
 import com.google.gson.JsonObject
 import com.google.gson.JsonPrimitive
 import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.DOWNLOAD
@@ -13,13 +13,15 @@ import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.VERIFICATION_KEY
 import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType
 import de.rki.coronawarnapp.util.CWADebug
 import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.serialization.BaseGson
 import timber.log.Timber
 import javax.inject.Inject
 import javax.inject.Singleton
 
 @Singleton
 class EnvironmentSetup @Inject constructor(
-    @AppContext private val context: Context
+    @AppContext private val context: Context,
+    @BaseGson private val gson: Gson
 ) {
 
     enum class EnvKey(val rawKey: String) {
@@ -51,7 +53,6 @@ class EnvironmentSetup @Inject constructor(
     }
 
     private val environmentJson: JsonObject by lazy {
-        val gson = GsonBuilder().create()
         gson.fromJson(BuildConfigWrap.ENVIRONMENT_JSONDATA, JsonObject::class.java).also {
             Timber.d("Parsed test environment: %s", it)
         }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/WrongReceiverException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/UnknownBroadcastException.kt
similarity index 55%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/WrongReceiverException.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/UnknownBroadcastException.kt
index 53026910f..4c5874f2d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/WrongReceiverException.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/UnknownBroadcastException.kt
@@ -7,18 +7,10 @@ import de.rki.coronawarnapp.exception.reporting.ReportedException
  * An Exception thrown when an error occurs inside the Rollback of a Transaction.
  *
  * @param action the received action in the BroadcastReceiver
- * @param expected the expected action
- * @param cause the cause of the error
  *
  * @see de.rki.coronawarnapp.receiver.ExposureStateUpdateReceiver
  */
-class WrongReceiverException(
-    action: String?,
-    expected: String,
-    cause: Throwable
-) : ReportedException(
+class UnknownBroadcastException(action: String?) : ReportedException(
     ErrorCodes.WRONG_RECEIVER_PROBLEM.code,
-    "An error occurred during BroadcastReceiver onReceive function. " +
-            "Received action was $action, expected action was $expected",
-    cause
+    "Our exposure state update receiver received an unknown '$action' type."
 )
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
index 427d236a5..98914070b 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
@@ -4,10 +4,14 @@ package de.rki.coronawarnapp.nearby
 
 import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
 import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
 import de.rki.coronawarnapp.nearby.modules.locationless.ScanningSupport
 import de.rki.coronawarnapp.nearby.modules.tracing.TracingStatus
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import org.joda.time.Instant
 import timber.log.Timber
 import java.io.File
 import javax.inject.Inject
@@ -18,7 +22,8 @@ class ENFClient @Inject constructor(
     private val googleENFClient: ExposureNotificationClient,
     private val diagnosisKeyProvider: DiagnosisKeyProvider,
     private val tracingStatus: TracingStatus,
-    private val scanningSupport: ScanningSupport
+    private val scanningSupport: ScanningSupport,
+    private val calculationTracker: CalculationTracker
 ) : DiagnosisKeyProvider, TracingStatus, ScanningSupport {
 
     // TODO Remove this once we no longer need direct access to the ENF Client,
@@ -35,7 +40,15 @@ class ENFClient @Inject constructor(
             "asyncProvideDiagnosisKeys(keyFiles=%s, configuration=%s, token=%s)",
             keyFiles, configuration, token
         )
-        return diagnosisKeyProvider.provideDiagnosisKeys(keyFiles, configuration, token)
+
+        return if (keyFiles.isEmpty()) {
+            Timber.d("No key files submitted, returning early.")
+            true
+        } else {
+            Timber.d("Forwarding %d key files to our DiagnosisKeyProvider.", keyFiles.size)
+            calculationTracker.trackNewCalaculation(token)
+            diagnosisKeyProvider.provideDiagnosisKeys(keyFiles, configuration, token)
+        }
     }
 
     override val isLocationLessScanningSupported: Flow<Boolean>
@@ -43,4 +56,17 @@ class ENFClient @Inject constructor(
 
     override val isTracingEnabled: Flow<Boolean>
         get() = tracingStatus.isTracingEnabled
+
+    fun isCurrentlyCalculating(): Flow<Boolean> = calculationTracker.calculations
+        .map { it.values }
+        .map { values ->
+            values.maxBy { it.startedAt }?.isCalculating == true
+        }
+
+    fun latestFinishedCalculation(): Flow<Calculation?> =
+        calculationTracker.calculations.map { snapshot ->
+            snapshot.values
+                .filter { !it.isCalculating && it.isSuccessful }
+                .maxByOrNull { it.finishedAt ?: Instant.EPOCH }
+        }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
index ac21df87e..1bf6f5dd7 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
@@ -5,6 +5,8 @@ import com.google.android.gms.nearby.Nearby
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
 import dagger.Module
 import dagger.Provides
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.DefaultCalculationTracker
 import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DefaultDiagnosisKeyProvider
 import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
 import de.rki.coronawarnapp.nearby.modules.locationless.DefaultScanningSupport
@@ -36,4 +38,9 @@ class ENFModule {
     @Provides
     fun scanningSupport(scanningSupport: DefaultScanningSupport): ScanningSupport =
         scanningSupport
+
+    @Singleton
+    @Provides
+    fun calculationTracker(calculationTracker: DefaultCalculationTracker): CalculationTracker =
+        calculationTracker
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt
new file mode 100644
index 000000000..13779d6ba
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt
@@ -0,0 +1,31 @@
+package de.rki.coronawarnapp.nearby.modules.calculationtracker
+
+import androidx.annotation.Keep
+import com.google.gson.annotations.SerializedName
+import org.joda.time.Instant
+
+@Keep
+data class Calculation(
+    @SerializedName("identifier") val identifier: String,
+    @SerializedName("startedAt") val startedAt: Instant,
+    @SerializedName("result") val result: Result? = null,
+    @SerializedName("finishedAt") val finishedAt: Instant? = null
+) {
+
+    val isCalculating: Boolean
+        get() = finishedAt == null
+    val isSuccessful: Boolean
+        get() = (result == Result.NO_MATCHES || result == Result.UPDATED_STATE)
+
+    @Keep
+    enum class Result {
+        @SerializedName("NO_MATCHES")
+        NO_MATCHES,
+
+        @SerializedName("UPDATED_STATE")
+        UPDATED_STATE,
+
+        @SerializedName("TIMEOUT")
+        TIMEOUT
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt
new file mode 100644
index 000000000..30bbc1b25
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt
@@ -0,0 +1,11 @@
+package de.rki.coronawarnapp.nearby.modules.calculationtracker
+
+import kotlinx.coroutines.flow.Flow
+
+interface CalculationTracker {
+    val calculations: Flow<Map<String, Calculation>>
+
+    fun trackNewCalaculation(identifier: String)
+
+    fun finishCalculation(identifier: String, result: Calculation.Result)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt
new file mode 100644
index 000000000..1aaf4d63b
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt
@@ -0,0 +1,60 @@
+package de.rki.coronawarnapp.nearby.modules.calculationtracker
+
+import android.content.Context
+import com.google.gson.Gson
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.gson.fromJson
+import de.rki.coronawarnapp.util.gson.toJson
+import de.rki.coronawarnapp.util.serialization.BaseGson
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class CalculationTrackerStorage @Inject constructor(
+    @AppContext private val context: Context,
+    @BaseGson private val gson: Gson
+) {
+    private val mutex = Mutex()
+    private val storageDir by lazy {
+        File(context.filesDir, "calcuation_tracker").apply {
+            if (mkdirs()) Timber.v("Created %s", this)
+        }
+    }
+    private val storageFile by lazy { File(storageDir, "calculations.json") }
+    private var lastCalcuationData: Map<String, Calculation>? = null
+
+    init {
+        Timber.v("init()")
+    }
+
+    suspend fun load(): Map<String, Calculation> = mutex.withLock {
+        return@withLock try {
+            if (!storageFile.exists()) return@withLock emptyMap()
+
+            gson.fromJson<Map<String, Calculation>>(storageFile).also {
+                Timber.v("Loaded calculation data: %s", it)
+                lastCalcuationData = it
+            }
+        } catch (e: Exception) {
+            Timber.e(e, "Failed to load tracked calculations.")
+            emptyMap()
+        }
+    }
+
+    suspend fun save(data: Map<String, Calculation>) = mutex.withLock {
+        if (lastCalcuationData == data) {
+            Timber.v("Data didn't change, skipping save.")
+            return@withLock
+        }
+        Timber.v("Storing calculation data: %s", data)
+        try {
+            gson.toJson(data, storageFile)
+        } catch (e: Exception) {
+            Timber.e(e, "Failed to save tracked calculations.")
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt
new file mode 100644
index 000000000..97e1eb95d
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt
@@ -0,0 +1,143 @@
+package de.rki.coronawarnapp.nearby.modules.calculationtracker
+
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation.Result
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import de.rki.coronawarnapp.util.flow.HotDataFlow
+import de.rki.coronawarnapp.util.mutate
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.plus
+import org.joda.time.Duration
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.math.min
+
+@Singleton
+class DefaultCalculationTracker @Inject constructor(
+    @AppScope private val scope: CoroutineScope,
+    private val dispatcherProvider: DispatcherProvider,
+    private val storage: CalculationTrackerStorage,
+    private val timeStamper: TimeStamper
+) : CalculationTracker {
+
+    init {
+        Timber.v("init()")
+    }
+
+    private val calculationStates: HotDataFlow<Map<String, Calculation>> by lazy {
+        val setupAutoSave: (HotDataFlow<Map<String, Calculation>>) -> Unit = { hd ->
+            hd.data
+                .onStart { Timber.v("Observing calculation changes.") }
+                .onEach { storage.save(it) }
+                .launchIn(scope = scope + dispatcherProvider.Default)
+        }
+
+        val setupTimeoutEnforcer: (HotDataFlow<Map<String, Calculation>>) -> Unit = { hd ->
+            flow<Unit> {
+                while (true) {
+                    hd.updateSafely {
+                        Timber.v("Running timeout check on: %s", values)
+
+                        val timeNow = timeStamper.nowUTC
+                        Timber.v("Time now: %s", timeNow)
+
+                        mutate {
+                            values.filter { it.isCalculating }.toList().forEach {
+                                if (timeNow.isAfter(it.startedAt.plus(TIMEOUT_LIMIT))) {
+                                    Timber.w("Calculation timeout on %s", it)
+                                    remove(it.identifier)
+                                    this[it.identifier] = it.copy(
+                                        finishedAt = timeStamper.nowUTC,
+                                        result = Result.TIMEOUT
+                                    )
+                                }
+                            }
+                        }
+                    }
+
+                    delay(TIMEOUT_CHECK_INTERVALL.millis)
+                }
+            }.launchIn(scope + dispatcherProvider.Default)
+        }
+
+        HotDataFlow(
+            loggingTag = TAG,
+            scope = scope,
+            coroutineContext = dispatcherProvider.Default,
+            startValueProvider = { storage.load() }
+        ).also {
+            setupAutoSave(it)
+            setupTimeoutEnforcer(it)
+        }
+    }
+
+    override val calculations: Flow<Map<String, Calculation>> by lazy { calculationStates.data }
+
+    override fun trackNewCalaculation(identifier: String) {
+        Timber.i("trackNewCalaculation(token=%s)", identifier)
+        calculationStates.updateSafely {
+            mutate {
+                this[identifier] = Calculation(
+                    identifier = identifier,
+                    startedAt = timeStamper.nowUTC
+                )
+            }
+        }
+    }
+
+    override fun finishCalculation(identifier: String, result: Result) {
+        Timber.i("finishCalculation(token=%s, result=%s)", identifier, result)
+        calculationStates.updateSafely {
+            mutate {
+                val existing = this[identifier]
+                if (existing != null) {
+                    if (existing.result == Result.TIMEOUT) {
+                        Timber.w("Calculation is late, already hit timeout, still updating.")
+                    } else if (existing.result != null) {
+                        Timber.e("Duplicate callback. Result is already set for calculation!")
+                    }
+                    this[identifier] = existing.copy(
+                        result = result,
+                        finishedAt = timeStamper.nowUTC
+                    )
+                } else {
+                    Timber.e(
+                        "Unknown calculation finished (token=%s, result=%s)",
+                        identifier,
+                        result
+                    )
+                    this[identifier] = Calculation(
+                        identifier = identifier,
+                        result = result,
+                        startedAt = timeStamper.nowUTC,
+                        finishedAt = timeStamper.nowUTC
+                    )
+                }
+                val toKeep = entries
+                    .sortedByDescending { it.value.startedAt } // Keep newest
+                    .subList(0, min(entries.size, MAX_ENTRY_SIZE))
+                    .map { it.key }
+                entries.removeAll { entry ->
+                    val remove = !toKeep.contains(entry.key)
+                    if (remove) Timber.v("Removing stale entry: %s", entry)
+                    remove
+                }
+            }
+        }
+    }
+
+    companion object {
+        private const val TAG = "DefaultCalculationTracker"
+        private const val MAX_ENTRY_SIZE = 5
+        private val TIMEOUT_CHECK_INTERVALL = Duration.standardMinutes(3)
+        private val TIMEOUT_LIMIT = Duration.standardMinutes(15)
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt
index 65f13604d..314a944a7 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt
@@ -6,12 +6,23 @@ import android.content.Intent
 import androidx.work.Data
 import androidx.work.OneTimeWorkRequest
 import androidx.work.WorkManager
-import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient.ACTION_EXPOSURE_NOT_FOUND
+import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED
+import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient.EXTRA_TOKEN
+import dagger.android.AndroidInjection
 import de.rki.coronawarnapp.exception.ExceptionCategory.INTERNAL
 import de.rki.coronawarnapp.exception.NoTokenException
-import de.rki.coronawarnapp.exception.WrongReceiverException
+import de.rki.coronawarnapp.exception.UnknownBroadcastException
 import de.rki.coronawarnapp.exception.reporting.report
 import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
+import de.rki.coronawarnapp.util.coroutine.AppScope
+import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import javax.inject.Inject
 
 /**
  * Receiver to listen to the Exposure Notification Exposure State Updated event. This event will be triggered from the
@@ -27,42 +38,77 @@ import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker
  *
  */
 class ExposureStateUpdateReceiver : BroadcastReceiver() {
-    companion object {
-        private val TAG: String? = ExposureStateUpdateReceiver::class.simpleName
-    }
+
+    @Inject @AppScope lateinit var scope: CoroutineScope
+    @Inject lateinit var dispatcherProvider: DispatcherProvider
+    @Inject lateinit var calculationTracker: CalculationTracker
+    lateinit var context: Context
 
     override fun onReceive(context: Context, intent: Intent) {
+        Timber.tag(TAG).d("onReceive(context=%s, intent=%s)", context, intent)
+        AndroidInjection.inject(this, context)
+        this.context = context
+
         val action = intent.action
-        val expectedAction = ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED
-        try {
-            if (expectedAction != action) {
-                throw WrongReceiverException(
-                    action,
-                    expectedAction,
-                    IllegalArgumentException("wrong action was received")
-                )
-            }
+        Timber.tag(TAG).v("Looking up action: %s", action)
 
-            val token =
-                intent.getStringExtra(ExposureNotificationClient.EXTRA_TOKEN)
-                    ?: throw NoTokenException(
-                        IllegalArgumentException("no token was found in the intent")
-                    )
-
-            val workManager = WorkManager.getInstance(context)
-            workManager.enqueue(
-                OneTimeWorkRequest.Builder(ExposureStateUpdateWorker::class.java)
-                    .setInputData(
-                        Data.Builder()
-                            .putString(ExposureNotificationClient.EXTRA_TOKEN, token)
-                            .build()
-                    )
-                    .build()
-            )
-        } catch (e: WrongReceiverException) {
-            e.report(INTERNAL)
-        } catch (e: NoTokenException) {
-            e.report(INTERNAL)
+        val async = goAsync()
+        scope.launch(context = dispatcherProvider.Default) {
+            try {
+                when (action) {
+                    ACTION_EXPOSURE_STATE_UPDATED -> processStateUpdates(intent)
+                    ACTION_EXPOSURE_NOT_FOUND -> processNotFound(intent)
+                    else -> throw UnknownBroadcastException(action)
+                }
+            } catch (e: Exception) {
+                e.report(INTERNAL)
+            } finally {
+                Timber.tag(TAG).i("Finished processing broadcast.")
+                async.finish()
+            }
         }
     }
+
+    private fun processStateUpdates(intent: Intent) {
+        Timber.tag(TAG).i("Processing ACTION_EXPOSURE_STATE_UPDATED")
+
+        val workManager = WorkManager.getInstance(context)
+
+        val token = intent.requireToken()
+
+        val data = Data
+            .Builder()
+            .putString(EXTRA_TOKEN, token)
+            .build()
+
+        OneTimeWorkRequest
+            .Builder(ExposureStateUpdateWorker::class.java)
+            .setInputData(data)
+            .build()
+            .let { workManager.enqueue(it) }
+
+        calculationTracker.finishCalculation(
+            token,
+            Calculation.Result.UPDATED_STATE
+        )
+    }
+
+    private fun processNotFound(intent: Intent) {
+        Timber.tag(TAG).i("Processing ACTION_EXPOSURE_NOT_FOUND")
+
+        val token = intent.requireToken()
+
+        calculationTracker.finishCalculation(
+            token,
+            Calculation.Result.NO_MATCHES
+        )
+    }
+
+    private fun Intent.requireToken(): String = getStringExtra(EXTRA_TOKEN).also {
+        Timber.tag(TAG).v("Extracted token: %s", it)
+    } ?: throw NoTokenException(IllegalArgumentException("no token was found in the intent"))
+
+    companion object {
+        private val TAG: String? = ExposureStateUpdateReceiver::class.simpleName
+    }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt
index 8d8cf82f4..676692bdb 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt
@@ -1,6 +1,11 @@
 package de.rki.coronawarnapp.receiver
 
 import dagger.Module
+import dagger.android.ContributesAndroidInjector
 
 @Module
-internal abstract class ReceiverBinder
+internal abstract class ReceiverBinder {
+
+    @ContributesAndroidInjector
+    internal abstract fun exposureUpdateReceiver(): ExposureStateUpdateReceiver
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
index 674f90c35..131d74757 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt
@@ -4,6 +4,7 @@ import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.TransactionException
 import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
 import de.rki.coronawarnapp.risk.TimeVariables.getActiveTracingDaysInRetentionPeriod
 import de.rki.coronawarnapp.timer.TimerHelper
@@ -14,6 +15,7 @@ import de.rki.coronawarnapp.util.coroutine.AppScope
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.launch
 import org.joda.time.DateTime
 import org.joda.time.DateTimeZone
@@ -34,7 +36,8 @@ import javax.inject.Singleton
  */
 @Singleton
 class TracingRepository @Inject constructor(
-    @AppScope private val scope: CoroutineScope
+    @AppScope private val scope: CoroutineScope,
+    enfClient: ENFClient
 ) {
 
     private val internalLastTimeDiagnosisKeysFetched = MutableStateFlow<Date?>(null)
@@ -55,7 +58,12 @@ class TracingRepository @Inject constructor(
 
     // TODO shouldn't access this directly
     val internalIsRefreshing = MutableStateFlow(false)
-    val isRefreshing: Flow<Boolean> = internalIsRefreshing
+    val isRefreshing: Flow<Boolean> = combine(
+        internalIsRefreshing,
+        enfClient.isCurrentlyCalculating()
+    ) { isRefreshing, isCalculating ->
+        isRefreshing || isCalculating
+    }
 
     /**
      * Refresh the diagnosis keys. For that isRefreshing is set to true which is displayed in the ui.
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt
index 58ff7a9e9..aa993a6b3 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt
@@ -14,7 +14,7 @@ data class TracingCardState(
     override val tracingStatus: GeneralTracingStatus.Status,
     override val riskLevelScore: Int,
     override val isRefreshing: Boolean,
-    override val riskLevelLastSuccessfulCalculation: Int,
+    override val lastRiskLevelScoreCalculated: Int,
     override val matchedKeyCount: Int,
     override val daysSinceLastExposure: Int,
     override val activeTracingDaysInRetentionPeriod: Long,
@@ -58,11 +58,11 @@ data class TracingCardState(
             riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS ||
             riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL
         ) {
-            when (riskLevelLastSuccessfulCalculation) {
+            when (lastRiskLevelScoreCalculated) {
                 RiskLevelConstants.LOW_LEVEL_RISK,
                 RiskLevelConstants.INCREASED_RISK,
                 RiskLevelConstants.UNKNOWN_RISK_INITIAL -> {
-                    val arg = formatRiskLevelHeadline(c, riskLevelLastSuccessfulCalculation, false)
+                    val arg = formatRiskLevelHeadline(c, lastRiskLevelScoreCalculated, false)
                     c.getString(R.string.risk_card_no_calculation_possible_body_saved_risk)
                         .format(arg)
                 }
@@ -202,7 +202,7 @@ data class TracingCardState(
             RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF,
             RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS,
             RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> {
-                when (riskLevelLastSuccessfulCalculation) {
+                when (lastRiskLevelScoreCalculated) {
                     RiskLevelConstants.LOW_LEVEL_RISK,
                     RiskLevelConstants.INCREASED_RISK,
                     RiskLevelConstants.UNKNOWN_RISK_INITIAL -> {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt
index 2bc7ba289..91406137d 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateProvider.kt
@@ -7,13 +7,12 @@ import de.rki.coronawarnapp.storage.SettingsRepository
 import de.rki.coronawarnapp.storage.TracingRepository
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.util.BackgroundModeStatus
+import de.rki.coronawarnapp.util.flow.combine
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onStart
 import timber.log.Timber
-import java.util.Date
 import javax.inject.Inject
 
 @Reusable
@@ -26,43 +25,60 @@ class TracingCardStateProvider @Inject constructor(
 
     // TODO Refactor these singletons away
     val state: Flow<TracingCardState> = combine(
-        tracingStatus.generalStatus.onEach { Timber.v("tracingStatus: $it") },
-        RiskLevelRepository.riskLevelScore.onEach { Timber.v("riskLevelScore: $it") },
+        tracingStatus.generalStatus.onEach {
+            Timber.v("tracingStatus: $it")
+        },
+        RiskLevelRepository.riskLevelScore.onEach {
+            Timber.v("riskLevelScore: $it")
+        },
         RiskLevelRepository.riskLevelScoreLastSuccessfulCalculated.onEach {
             Timber.v("riskLevelScoreLastSuccessfulCalculated: $it")
         },
-        tracingRepository.isRefreshing.onEach { Timber.v("isRefreshing: $it") },
-        ExposureSummaryRepository.matchedKeyCount.onEach { Timber.v("matchedKeyCount: $it") },
-        ExposureSummaryRepository.daysSinceLastExposure.onEach { Timber.v("daysSinceLastExposure: $it") },
+        tracingRepository.isRefreshing.onEach {
+            Timber.v("isRefreshing: $it")
+        },
+        ExposureSummaryRepository.matchedKeyCount.onEach {
+            Timber.v("matchedKeyCount: $it")
+        },
+        ExposureSummaryRepository.daysSinceLastExposure.onEach {
+            Timber.v("daysSinceLastExposure: $it")
+        },
         tracingRepository.activeTracingDaysInRetentionPeriod.onEach {
             Timber.v("activeTracingDaysInRetentionPeriod: $it")
         },
-        tracingRepository.lastTimeDiagnosisKeysFetched.onEach { Timber.v("lastTimeDiagnosisKeysFetched: $it") },
-        backgroundModeStatus.isAutoModeEnabled.onEach { Timber.v("isAutoModeEnabled: $it") },
-        settingsRepository.isManualKeyRetrievalEnabledFlow.onEach { Timber.v("isManualKeyRetrievalEnabledFlow: $it") },
-        settingsRepository.manualKeyRetrievalTimeFlow.onEach { Timber.v("manualKeyRetrievalTimeFlow: $it") }
-    ) { sources: Array<Any?> ->
-        val status = sources[0] as GeneralTracingStatus.Status
-        val riskLevelScore = sources[1] as Int
-        val riskLevelScoreLastSuccessfulCalculated = sources[2] as Int
-        val isRefreshing = sources[3] as Boolean
-        val matchedKeyCount = sources[4] as Int
-        val daysSinceLastExposure = sources[5] as Int
-        val activeTracingDaysInRetentionPeriod = sources[6] as Long
-        val lastTimeDiagnosisKeysFetched = sources[7] as Date?
-        val isBackgroundJobEnabled = sources[8] as Boolean
-        val isManualKeyRetrievalEnabled = sources[9] as Boolean
-        val manualKeyRetrievalTime = sources[10] as Long
+        tracingRepository.lastTimeDiagnosisKeysFetched.onEach {
+            Timber.v("lastTimeDiagnosisKeysFetched: $it")
+        },
+        backgroundModeStatus.isAutoModeEnabled.onEach {
+            Timber.v("isAutoModeEnabled: $it")
+        },
+        settingsRepository.isManualKeyRetrievalEnabledFlow.onEach {
+            Timber.v("isManualKeyRetrievalEnabledFlow: $it")
+        },
+        settingsRepository.manualKeyRetrievalTimeFlow.onEach {
+            Timber.v("manualKeyRetrievalTimeFlow: $it")
+        }
+    ) { status,
+        riskLevelScore,
+        riskLevelScoreLastSuccessfulCalculated,
+        isRefreshing,
+        matchedKeyCount,
+        daysSinceLastExposure,
+        activeTracingDaysInRetentionPeriod,
+        lastTimeDiagnosisKeysFetched,
+        isBackgroundJobEnabled,
+        isManualKeyRetrievalEnabled,
+        manualKeyRetrievalTime ->
 
         TracingCardState(
             tracingStatus = status,
             riskLevelScore = riskLevelScore,
             isRefreshing = isRefreshing,
-            riskLevelLastSuccessfulCalculation = riskLevelScoreLastSuccessfulCalculated,
+            lastRiskLevelScoreCalculated = riskLevelScoreLastSuccessfulCalculated,
+            lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched,
             matchedKeyCount = matchedKeyCount,
             daysSinceLastExposure = daysSinceLastExposure,
             activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod,
-            lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched,
             isBackgroundJobEnabled = isBackgroundJobEnabled,
             isManualKeyRetrievalEnabled = isManualKeyRetrievalEnabled,
             manualKeyRetrievalTime = manualKeyRetrievalTime
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt
index 9c420aa27..6ba53ca9f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt
@@ -13,7 +13,7 @@ abstract class BaseTracingState {
     abstract val tracingStatus: GeneralTracingStatus.Status
     abstract val riskLevelScore: Int
     abstract val isRefreshing: Boolean
-    abstract val riskLevelLastSuccessfulCalculation: Int
+    abstract val lastRiskLevelScoreCalculated: Int
     abstract val matchedKeyCount: Int
     abstract val daysSinceLastExposure: Int
     abstract val activeTracingDaysInRetentionPeriod: Long
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt
index 2348a0e71..a32b4f8ec 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsState.kt
@@ -11,7 +11,7 @@ data class TracingDetailsState(
     override val tracingStatus: GeneralTracingStatus.Status,
     override val riskLevelScore: Int,
     override val isRefreshing: Boolean,
-    override val riskLevelLastSuccessfulCalculation: Int,
+    override val lastRiskLevelScoreCalculated: Int,
     override val matchedKeyCount: Int,
     override val daysSinceLastExposure: Int,
     override val activeTracingDaysInRetentionPeriod: Long,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt
index 5d2ab8b1d..61627a9a7 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateProvider.kt
@@ -7,13 +7,12 @@ import de.rki.coronawarnapp.storage.SettingsRepository
 import de.rki.coronawarnapp.storage.TracingRepository
 import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.util.BackgroundModeStatus
+import de.rki.coronawarnapp.util.flow.combine
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onStart
 import timber.log.Timber
-import java.util.Date
 import javax.inject.Inject
 
 @Reusable
@@ -38,18 +37,15 @@ class TracingDetailsStateProvider @Inject constructor(
         backgroundModeStatus.isAutoModeEnabled,
         settingsRepository.isManualKeyRetrievalEnabledFlow,
         settingsRepository.manualKeyRetrievalTimeFlow
-    ) { sources: Array<Any?> ->
-        val status = sources[0] as GeneralTracingStatus.Status
-        val riskLevelScore = sources[1] as Int
-        val riskLevelScoreLastSuccessfulCalculated = sources[2] as Int
-        val isRefreshing = sources[3] as Boolean
-        val matchedKeyCount = sources[4] as Int
-        val daysSinceLastExposure = sources[5] as Int
-        val activeTracingDaysInRetentionPeriod = sources[6] as Long
-        val lastTimeDiagnosisKeysFetched = sources[7] as Date?
-        val isBackgroundJobEnabled = sources[8] as Boolean
-        val isManualKeyRetrievalEnabled = sources[9] as Boolean
-        val manualKeyRetrievalTime = sources[10] as Long
+    ) { status,
+        riskLevelScore,
+        riskLevelScoreLastSuccessfulCalculated,
+        isRefreshing, matchedKeyCount,
+        daysSinceLastExposure, activeTracingDaysInRetentionPeriod,
+        lastTimeDiagnosisKeysFetched,
+        isBackgroundJobEnabled,
+        isManualKeyRetrievalEnabled,
+        manualKeyRetrievalTime ->
 
         val isAdditionalInformationVisible = riskDetailPresenter.isAdditionalInfoVisible(
             riskLevelScore, matchedKeyCount
@@ -63,7 +59,7 @@ class TracingDetailsStateProvider @Inject constructor(
             tracingStatus = status,
             riskLevelScore = riskLevelScore,
             isRefreshing = isRefreshing,
-            riskLevelLastSuccessfulCalculation = riskLevelScoreLastSuccessfulCalculated,
+            lastRiskLevelScoreCalculated = riskLevelScoreLastSuccessfulCalculated,
             matchedKeyCount = matchedKeyCount,
             daysSinceLastExposure = daysSinceLastExposure,
             activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod,
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragmentViewModel.kt
index 2aaa447c2..c9a546293 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragmentViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/settings/SettingsTracingFragmentViewModel.kt
@@ -8,7 +8,7 @@ import de.rki.coronawarnapp.tracing.GeneralTracingStatus
 import de.rki.coronawarnapp.ui.tracing.details.TracingDetailsState
 import de.rki.coronawarnapp.ui.tracing.details.TracingDetailsStateProvider
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
-import de.rki.coronawarnapp.util.coroutine.shareLatest
+import de.rki.coronawarnapp.util.flow.shareLatest
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
 import kotlinx.coroutines.flow.SharingStarted
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/BackgroundModeStatus.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/BackgroundModeStatus.kt
index a2a5954cd..13ea08dfd 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/BackgroundModeStatus.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/BackgroundModeStatus.kt
@@ -2,8 +2,8 @@ package de.rki.coronawarnapp.util
 
 import android.content.Context
 import de.rki.coronawarnapp.util.coroutine.AppScope
-import de.rki.coronawarnapp.util.coroutine.shareLatest
 import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.flow.shareLatest
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.channels.awaitClose
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/MapExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/MapExtensions.kt
new file mode 100644
index 000000000..14721ecb6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/MapExtensions.kt
@@ -0,0 +1,5 @@
+package de.rki.coronawarnapp.util
+
+inline fun <K, V> Map<K, V>.mutate(block: MutableMap<K, V>.() -> Unit): Map<K, V> {
+    return toMutableMap().apply(block).toMap()
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProvider.kt
index 1826556f4..cc38c6f39 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProvider.kt
@@ -6,8 +6,8 @@ import android.content.Context
 import android.content.Intent
 import android.content.IntentFilter
 import de.rki.coronawarnapp.util.coroutine.AppScope
-import de.rki.coronawarnapp.util.coroutine.shareLatest
 import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.flow.shareLatest
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
index 307d74ca8..bfb0644bf 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt
@@ -36,6 +36,7 @@ import de.rki.coronawarnapp.util.coroutine.CoroutineModule
 import de.rki.coronawarnapp.util.device.DeviceModule
 import de.rki.coronawarnapp.util.security.EncryptedPreferencesFactory
 import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool
+import de.rki.coronawarnapp.util.serialization.SerializationModule
 import de.rki.coronawarnapp.verification.VerificationModule
 import javax.inject.Singleton
 
@@ -61,7 +62,8 @@ import javax.inject.Singleton
         VerificationModule::class,
         PlaybookModule::class,
         TaskModule::class,
-        DeviceForTestersModule::class
+        DeviceForTestersModule::class,
+        SerializationModule::class
     ]
 )
 interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/FlowExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt
similarity index 51%
rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/FlowExtensions.kt
rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt
index 389764028..25b36bc91 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/coroutine/FlowExtensions.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt
@@ -1,8 +1,9 @@
-package de.rki.coronawarnapp.util.coroutine
+package de.rki.coronawarnapp.util.flow
 
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.mapNotNull
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onEach
@@ -28,3 +29,35 @@ fun <T> Flow<T>.shareLatest(
         initialValue = null
     )
     .mapNotNull { it }
+
+@Suppress("UNCHECKED_CAST", "MagicNumber", "LongParameterList")
+inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, R> combine(
+    flow: Flow<T1>,
+    flow2: Flow<T2>,
+    flow3: Flow<T3>,
+    flow4: Flow<T4>,
+    flow5: Flow<T5>,
+    flow6: Flow<T6>,
+    flow7: Flow<T7>,
+    flow8: Flow<T8>,
+    flow9: Flow<T9>,
+    flow10: Flow<T10>,
+    flow11: Flow<T11>,
+    crossinline transform: suspend (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11) -> R
+): Flow<R> = combine(
+    flow, flow2, flow3, flow4, flow5, flow6, flow7, flow8, flow9, flow10, flow11
+) { args: Array<*> ->
+    transform(
+        args[0] as T1,
+        args[1] as T2,
+        args[2] as T3,
+        args[3] as T4,
+        args[4] as T5,
+        args[5] as T6,
+        args[6] as T7,
+        args[7] as T8,
+        args[8] as T9,
+        args[9] as T10,
+        args[10] as T11
+    )
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
new file mode 100644
index 000000000..217a31bf4
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt
@@ -0,0 +1,68 @@
+package de.rki.coronawarnapp.util.flow
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.flow.onCompletion
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.flow.shareIn
+import kotlinx.coroutines.plus
+import timber.log.Timber
+import kotlin.coroutines.CoroutineContext
+
+class HotDataFlow<T : Any>(
+    loggingTag: String,
+    scope: CoroutineScope,
+    coroutineContext: CoroutineContext = Dispatchers.Default,
+    sharingBehavior: SharingStarted = SharingStarted.WhileSubscribed(),
+    private val startValueProvider: suspend CoroutineScope.() -> T
+) {
+    private val tag = "$loggingTag:HD"
+
+    init {
+        Timber.tag(tag).v("init()")
+    }
+
+    private val updateActions = MutableSharedFlow<suspend (T) -> T>(
+        replay = Int.MAX_VALUE,
+        extraBufferCapacity = Int.MAX_VALUE,
+        onBufferOverflow = BufferOverflow.SUSPEND
+    )
+
+    private val internalFlow = channelFlow {
+        var currentValue = startValueProvider().also {
+            Timber.tag(tag).v("startValue=%s", it)
+            send(it)
+        }
+
+        updateActions.collect { updateAction ->
+            currentValue = updateAction(currentValue).also {
+                currentValue = it
+                send(it)
+            }
+        }
+    }
+
+    val data: Flow<T> = internalFlow
+        .onStart { Timber.tag(tag).v("internal onStart") }
+        .catch {
+            Timber.tag(tag).e(it, "internal Error")
+            throw it
+        }
+        .onCompletion { Timber.tag(tag).v("internal onCompletion") }
+        .shareIn(
+            scope = scope + coroutineContext,
+            replay = 1,
+            started = sharingBehavior
+        )
+        .mapNotNull { it }
+
+    fun updateSafely(update: suspend T.() -> T) = updateActions.tryEmit(update)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt
new file mode 100644
index 000000000..b79e725c5
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt
@@ -0,0 +1,18 @@
+package de.rki.coronawarnapp.util.gson
+
+import com.google.gson.Gson
+import com.google.gson.reflect.TypeToken
+import java.io.File
+
+inline fun <reified T> Gson.fromJson(json: String): T = fromJson(
+    json,
+    object : TypeToken<T>() {}.type
+)
+
+inline fun <reified T> Gson.fromJson(file: File): T = file.reader().use {
+    fromJson(it, object : TypeToken<T>() {}.type)
+}
+
+inline fun <reified T> Gson.toJson(data: T, file: File) = file.writer().use { writer ->
+    toJson(data, writer)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/location/LocationProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/location/LocationProvider.kt
index d589b9073..05a8dd998 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/location/LocationProvider.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/location/LocationProvider.kt
@@ -7,8 +7,8 @@ import android.content.IntentFilter
 import android.location.LocationManager
 import androidx.core.location.LocationManagerCompat
 import de.rki.coronawarnapp.util.coroutine.AppScope
-import de.rki.coronawarnapp.util.coroutine.shareLatest
 import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.flow.shareLatest
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.awaitClose
 import kotlinx.coroutines.flow.Flow
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/BaseGson.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/BaseGson.kt
new file mode 100644
index 000000000..89c0ba514
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/BaseGson.kt
@@ -0,0 +1,8 @@
+package de.rki.coronawarnapp.util.serialization
+
+import javax.inject.Qualifier
+
+@Qualifier
+@MustBeDocumented
+@Retention(AnnotationRetention.RUNTIME)
+annotation class BaseGson
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
new file mode 100644
index 000000000..e638c3811
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt
@@ -0,0 +1,16 @@
+package de.rki.coronawarnapp.util.serialization
+
+import com.google.gson.Gson
+import com.google.gson.GsonBuilder
+import dagger.Module
+import dagger.Provides
+import dagger.Reusable
+
+@Module
+class SerializationModule {
+
+    @BaseGson
+    @Reusable
+    @Provides
+    fun baseGson(): Gson = GsonBuilder().create()
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt
index 0bedabb73..092bfa0fd 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt
@@ -3,6 +3,7 @@ package de.rki.coronawarnapp.environment
 import android.content.Context
 import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType
 import de.rki.coronawarnapp.util.CWADebug
+import de.rki.coronawarnapp.util.serialization.SerializationModule
 import io.kotest.assertions.throwables.shouldThrow
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
@@ -19,7 +20,6 @@ import testhelpers.preferences.MockSharedPreferences
 class EnvironmentSetupTest : BaseTest() {
     @MockK lateinit var context: Context
     private lateinit var mockPreferences: MockSharedPreferences
-    private fun createEnvSetup() = EnvironmentSetup(context)
 
     @BeforeEach
     fun setUp() {
@@ -43,6 +43,11 @@ class EnvironmentSetupTest : BaseTest() {
         clearAllMocks()
     }
 
+    private fun createEnvSetup() = EnvironmentSetup(
+        context = context,
+        gson = SerializationModule().baseGson()
+    )
+
     @Test
     fun `parsing bad json throws an exception in debug builds`() {
         every { BuildConfigWrap.ENVIRONMENT_JSONDATA } returns BAD_JSON
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
index 2e92d5b0c..c9ecb45bc 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
@@ -2,21 +2,27 @@ package de.rki.coronawarnapp.nearby
 
 import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
 import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
 import de.rki.coronawarnapp.nearby.modules.locationless.ScanningSupport
 import de.rki.coronawarnapp.nearby.modules.tracing.TracingStatus
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
+import io.mockk.Runs
 import io.mockk.clearAllMocks
 import io.mockk.coEvery
 import io.mockk.coVerify
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
+import io.mockk.just
 import io.mockk.mockk
 import io.mockk.verifySequence
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.flowOf
 import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.runBlocking
+import org.joda.time.Instant
 import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
@@ -31,11 +37,13 @@ class ENFClientTest : BaseTest() {
     @MockK lateinit var diagnosisKeyProvider: DiagnosisKeyProvider
     @MockK lateinit var tracingStatus: TracingStatus
     @MockK lateinit var scanningSupport: ScanningSupport
+    @MockK lateinit var calculationTracker: CalculationTracker
 
     @BeforeEach
     fun setup() {
         MockKAnnotations.init(this)
         coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns true
+        every { calculationTracker.trackNewCalaculation(any()) } just Runs
     }
 
     @AfterEach
@@ -47,7 +55,8 @@ class ENFClientTest : BaseTest() {
         googleENFClient = googleENFClient,
         diagnosisKeyProvider = diagnosisKeyProvider,
         tracingStatus = tracingStatus,
-        scanningSupport = scanningSupport
+        scanningSupport = scanningSupport,
+        calculationTracker = calculationTracker
     )
 
     @Test
@@ -82,6 +91,23 @@ class ENFClientTest : BaseTest() {
         }
     }
 
+    @Test
+    fun `provide diagnosis key call is only forwarded if there are actually key files`() {
+        val client = createClient()
+        val keyFiles = emptyList<File>()
+        val configuration = mockk<ExposureConfiguration>()
+        val token = "123"
+
+        coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns true
+        runBlocking {
+            client.provideDiagnosisKeys(keyFiles, configuration, token) shouldBe true
+        }
+
+        coVerify(exactly = 0) {
+            diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any())
+        }
+    }
+
     @Test
     fun `tracing status check is forwaded to the right module`() = runBlocking {
         every { tracingStatus.isTracingEnabled } returns flowOf(true)
@@ -105,4 +131,139 @@ class ENFClientTest : BaseTest() {
             scanningSupport.isLocationLessScanningSupported
         }
     }
+
+    @Test
+    fun `calculation state depends on the last started calculation`() {
+        runBlocking {
+            every { calculationTracker.calculations } returns flowOf(
+                mapOf(
+                    "1" to Calculation(
+                        identifier = "1",
+                        startedAt = Instant.EPOCH,
+                        finishedAt = Instant.EPOCH
+                    ),
+                    "2" to Calculation(
+                        identifier = "2",
+                        startedAt = Instant.EPOCH,
+                        finishedAt = Instant.EPOCH.plus(1)
+                    )
+                )
+            )
+
+            createClient().isCurrentlyCalculating().first() shouldBe false
+        }
+
+        runBlocking {
+            every { calculationTracker.calculations } returns flowOf(
+                mapOf(
+                    "1" to Calculation(
+                        identifier = "1",
+                        startedAt = Instant.EPOCH.plus(5),
+                    ),
+                    "2" to Calculation(
+                        identifier = "2",
+                        startedAt = Instant.EPOCH.plus(4),
+                        finishedAt = Instant.EPOCH.plus(1)
+                    )
+                )
+            )
+
+            createClient().isCurrentlyCalculating().first() shouldBe true
+        }
+
+        runBlocking {
+            every { calculationTracker.calculations } returns flowOf(
+                mapOf(
+                    "1" to Calculation(
+                        identifier = "1",
+                        startedAt = Instant.EPOCH,
+                    ),
+                    "2" to Calculation(
+                        identifier = "2",
+                        startedAt = Instant.EPOCH,
+                        finishedAt = Instant.EPOCH.plus(2)
+                    ),
+                    "3" to Calculation(
+                        identifier = "3",
+                        startedAt = Instant.EPOCH.plus(1)
+                    )
+                )
+            )
+
+            createClient().isCurrentlyCalculating().first() shouldBe true
+        }
+    }
+
+    @Test
+    fun `validate that we only get the last finished calcluation`() {
+        runBlocking {
+            every { calculationTracker.calculations } returns flowOf(
+                mapOf(
+                    "1" to Calculation(
+                        identifier = "1",
+                        startedAt = Instant.EPOCH,
+                        result = Calculation.Result.UPDATED_STATE,
+                        finishedAt = Instant.EPOCH
+                    ),
+                    "2" to Calculation(
+                        identifier = "2",
+                        startedAt = Instant.EPOCH,
+                        result = Calculation.Result.UPDATED_STATE,
+                        finishedAt = Instant.EPOCH.plus(1)
+                    ),
+                    "2-timeout" to Calculation(
+                        identifier = "2-timeout",
+                        startedAt = Instant.EPOCH,
+                        result = Calculation.Result.TIMEOUT,
+                        finishedAt = Instant.EPOCH.plus(2)
+                    ),
+                    "3" to Calculation(
+                        identifier = "3",
+                        result = Calculation.Result.UPDATED_STATE,
+                        startedAt = Instant.EPOCH.plus(2)
+                    )
+                )
+            )
+
+            createClient().latestFinishedCalculation().first()!!.identifier shouldBe "2"
+        }
+
+        runBlocking {
+            every { calculationTracker.calculations } returns flowOf(
+                mapOf(
+                    "0" to Calculation(
+                        identifier = "1",
+                        result = Calculation.Result.UPDATED_STATE,
+                        startedAt = Instant.EPOCH.plus(3)
+                    ),
+                    "1-timeout" to Calculation(
+                        identifier = "1-timeout",
+                        startedAt = Instant.EPOCH,
+                        result = Calculation.Result.TIMEOUT,
+                        finishedAt = Instant.EPOCH.plus(3)
+                    ),
+                    "1" to Calculation(
+                        identifier = "1",
+                        startedAt = Instant.EPOCH,
+                        result = Calculation.Result.UPDATED_STATE,
+                        finishedAt = Instant.EPOCH.plus(2)
+                    ),
+                    "2" to Calculation(
+                        identifier = "2",
+                        result = Calculation.Result.UPDATED_STATE,
+                        startedAt = Instant.EPOCH,
+                    ),
+                    "3" to Calculation(
+                        identifier = "3",
+                        startedAt = Instant.EPOCH,
+                        result = Calculation.Result.UPDATED_STATE,
+                        finishedAt = Instant.EPOCH
+                    )
+                )
+            )
+
+            createClient().latestFinishedCalculation().first()!!.identifier shouldBe "1"
+        }
+    }
 }
+
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt
new file mode 100644
index 000000000..7afb7e551
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt
@@ -0,0 +1,35 @@
+package de.rki.coronawarnapp.nearby.modules.calculationtracker
+
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class CalculationTest : BaseTest() {
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    @Test
+    fun `isCalculating flag depends on finishedAt`() {
+        val initial = Calculation(
+            identifier = "123",
+            startedAt = Instant.EPOCH
+        )
+        initial.isCalculating shouldBe true
+
+        val finished = initial.copy(finishedAt = Instant.EPOCH)
+        finished.isCalculating shouldBe false
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt
new file mode 100644
index 000000000..5b1be36e8
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt
@@ -0,0 +1,133 @@
+package de.rki.coronawarnapp.nearby.modules.calculationtracker
+
+import android.content.Context
+import com.google.gson.GsonBuilder
+import de.rki.coronawarnapp.util.gson.fromJson
+import de.rki.coronawarnapp.util.serialization.SerializationModule
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseIOTest
+import java.io.File
+
+class CalculationTrackerStorageTest : BaseIOTest() {
+
+    @MockK private lateinit var context: Context
+
+    private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName)
+    private val privateFiles = File(testDir, "files")
+    private val storageDir = File(privateFiles, "calcuation_tracker")
+    private val storageFile = File(storageDir, "calculations.json")
+
+    private val gson = GsonBuilder().setPrettyPrinting().create()
+
+    private val demoJsonString = """
+            {
+              "b2b98400-058d-43e6-b952-529a5255248b": {
+                "identifier": "b2b98400-058d-43e6-b952-529a5255248b",
+                "startedAt": {
+                  "iMillis": 1234
+                }
+              },
+              "aeb15509-fb34-42ce-8795-7a9ae0c2f389": {
+                "identifier": "aeb15509-fb34-42ce-8795-7a9ae0c2f389",
+                "startedAt": {
+                  "iMillis": 5678
+                },
+                "result": "UPDATED_STATE",
+                "finishedAt": {
+                  "iMillis": 1603473968125
+                }
+              }
+            }
+        """.trimIndent()
+
+    private val demoData = run {
+        val calculation1 = Calculation(
+            identifier = "b2b98400-058d-43e6-b952-529a5255248b",
+            startedAt = Instant.ofEpochMilli(1234)
+        )
+        val calculation2 = Calculation(
+            identifier = "aeb15509-fb34-42ce-8795-7a9ae0c2f389",
+            startedAt = Instant.ofEpochMilli(5678),
+            finishedAt = Instant.ofEpochMilli(1603473968125),
+            result = Calculation.Result.UPDATED_STATE
+        )
+        mapOf(
+            calculation1.identifier to calculation1,
+            calculation2.identifier to calculation2
+        )
+    }
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { context.filesDir } returns privateFiles
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+        testDir.deleteRecursively()
+    }
+
+    private fun createStorage() = CalculationTrackerStorage(
+        context = context,
+        gson = SerializationModule().baseGson()
+    )
+
+    @Test
+    fun `init is sideeffect free`() = runBlockingTest {
+        createStorage()
+        storageDir.exists() shouldBe false
+        createStorage().save(emptyMap())
+        storageDir.exists() shouldBe true
+    }
+
+    @Test
+    fun `load empty on non-existing file`() = runBlockingTest {
+        createStorage().load() shouldBe emptyMap()
+    }
+
+    @Test
+    fun `save on unchanged data does nothing`() = runBlockingTest {
+        storageDir.mkdirs()
+        storageFile.writeText(demoJsonString)
+
+        createStorage().apply {
+            load()
+
+            storageFile.delete()
+
+            save(demoData)
+            storageFile.exists() shouldBe false
+        }
+    }
+
+    @Test
+    fun `saving data creates a json file`() = runBlockingTest {
+
+        createStorage().save(demoData)
+        storageFile.exists() shouldBe true
+
+        val storedData: Map<String, Calculation> = gson.fromJson(storageFile)
+
+        storedData shouldBe demoData
+        gson.toJson(storedData) shouldBe demoJsonString
+    }
+
+    @Test
+    fun `gson does weird things to property initialization`() {
+        // This makes sure we are using val-getters, otherwise gson inits our @Ŧransient properties to false
+        val storedData: Map<String, Calculation> = gson.fromJson(demoJsonString)
+        storedData.getValue("b2b98400-058d-43e6-b952-529a5255248b").isCalculating shouldBe true
+        storedData.getValue("aeb15509-fb34-42ce-8795-7a9ae0c2f389").isCalculating shouldBe false
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt
new file mode 100644
index 000000000..5ae6cad6a
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt
@@ -0,0 +1,265 @@
+package de.rki.coronawarnapp.nearby.modules.calculationtracker
+
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.mutate
+import io.kotest.matchers.shouldBe
+import io.mockk.Called
+import io.mockk.MockKAnnotations
+import io.mockk.Ordering
+import io.mockk.Runs
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.verify
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Duration
+import org.joda.time.Instant
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.TestDispatcherProvider
+import testhelpers.coroutines.runBlockingTest2
+import java.util.UUID
+
+class DefaultCalculationTrackerTest : BaseTest() {
+
+    @MockK lateinit var storage: CalculationTrackerStorage
+    @MockK lateinit var timeStamper: TimeStamper
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { timeStamper.nowUTC } returns Instant.EPOCH
+        coEvery { storage.load() } returns emptyMap()
+        coEvery { storage.save(any()) } just Runs
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createInstance(scope: CoroutineScope) = DefaultCalculationTracker(
+        scope = scope,
+        dispatcherProvider = TestDispatcherProvider,
+        storage = storage,
+        timeStamper = timeStamper
+    )
+
+    @Test
+    fun `side effect free init`() = runBlockingTest {
+        createInstance(scope = this)
+        verify { storage wasNot Called }
+        verify { timeStamper wasNot Called }
+    }
+
+    @Test
+    fun `data is restored from storage`() = runBlockingTest2(permanentJobs = true) {
+        val calcData = Calculation(
+            identifier = UUID.randomUUID().toString(),
+            startedAt = Instant.EPOCH
+        )
+        val initialData = mapOf(calcData.identifier to calcData)
+        coEvery { storage.load() } returns initialData
+
+        createInstance(scope = this).calculations.first() shouldBe initialData
+    }
+
+    @Test
+    fun `tracking a new calculation`() = runBlockingTest2(permanentJobs = true) {
+        createInstance(scope = this).apply {
+            val expectedIdentifier = UUID.randomUUID().toString()
+            trackNewCalaculation(expectedIdentifier)
+
+            advanceUntilIdle()
+
+            val calculationData = calculations.first()
+
+            calculationData.entries.single().apply {
+                key shouldBe expectedIdentifier
+                value shouldBe Calculation(
+                    identifier = expectedIdentifier,
+                    startedAt = Instant.EPOCH
+                )
+            }
+
+            coVerify(ordering = Ordering.ORDERED) {
+                storage.load()
+                storage.save(emptyMap())
+                timeStamper.nowUTC
+                storage.save(calculationData)
+            }
+            advanceUntilIdle()
+        }
+    }
+
+    @Test
+    fun `finish an existing calcluation`() = runBlockingTest2(permanentJobs = true) {
+        val calcData = Calculation(
+            identifier = UUID.randomUUID().toString(),
+            startedAt = Instant.EPOCH
+        )
+        val initialData = mapOf(calcData.identifier to calcData)
+        coEvery { storage.load() } returns initialData
+
+        val expectedData = initialData.mutate {
+            this[calcData.identifier] = this[calcData.identifier]!!.copy(
+                finishedAt = Instant.EPOCH.plus(1),
+                result = Calculation.Result.UPDATED_STATE
+            )
+        }
+
+
+        every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1)
+
+        createInstance(scope = this).apply {
+            finishCalculation(calcData.identifier, Calculation.Result.UPDATED_STATE)
+
+            advanceUntilIdle()
+
+            calculations.first() shouldBe expectedData
+
+            coVerify(ordering = Ordering.ORDERED) {
+                storage.load()
+                storage.save(any())
+                timeStamper.nowUTC
+                storage.save(expectedData)
+            }
+            advanceUntilIdle()
+        }
+    }
+
+    @Test
+    fun `a late calculation overwrites timeout state`() = runBlockingTest2(permanentJobs = true) {
+        val calcData = Calculation(
+            identifier = UUID.randomUUID().toString(),
+            startedAt = Instant.EPOCH,
+            finishedAt = Instant.EPOCH.plus(1),
+            result = Calculation.Result.TIMEOUT
+        )
+        val initialData = mapOf(calcData.identifier to calcData)
+        coEvery { storage.load() } returns initialData
+
+        every { timeStamper.nowUTC } returns Instant.EPOCH.plus(2)
+
+        val expectedData = initialData.mutate {
+            this[calcData.identifier] = this[calcData.identifier]!!.copy(
+                finishedAt = Instant.EPOCH.plus(2),
+                result = Calculation.Result.UPDATED_STATE
+            )
+        }
+
+        createInstance(scope = this).apply {
+            finishCalculation(calcData.identifier, Calculation.Result.UPDATED_STATE)
+
+            advanceUntilIdle()
+
+            calculations.first() shouldBe expectedData
+        }
+    }
+
+    @Test
+    fun `no more than 10 calcluations are tracked`() = runBlockingTest2(permanentJobs = true) {
+        val calcData = (1..15L).map {
+            val calcData = Calculation(
+                identifier = "$it",
+                startedAt = Instant.EPOCH.plus(it)
+            )
+            calcData.identifier to calcData
+        }.toMap()
+
+        coEvery { storage.load() } returns calcData
+
+        every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1)
+        createInstance(scope = this).apply {
+            finishCalculation("7", Calculation.Result.UPDATED_STATE)
+
+            advanceUntilIdle()
+
+            val data = calculations.first()
+            data.size shouldBe 5
+            data.values.map { it.identifier }.toList() shouldBe (11..15).map { "$it" }.toList()
+        }
+    }
+
+    @Test
+    fun `15 minute timeout on ongoing calcs`() = runBlockingTest2(permanentJobs = true) {
+        every { timeStamper.nowUTC } returns Instant.EPOCH
+            .plus(Duration.standardMinutes(15))
+            .plus(2)
+
+        // First half will be in the timeout, last half will be ok
+        val timeoutOnRunningCalc = Calculation(
+            identifier = "0",
+            startedAt = Instant.EPOCH
+        )
+        val timeoutonRunningCalc2 = Calculation(
+            identifier = "1",
+            startedAt = Instant.EPOCH.plus(1)
+        )
+        // We shouldn't care for timeouts on finished calculations
+        val timeoutIgnoresFinishedCalcs = Calculation(
+            identifier = "2",
+            startedAt = Instant.EPOCH.plus(1),
+            finishedAt = Instant.EPOCH.plus(15)
+        )
+
+        // This one is right on the edge, testing <= behavior
+        val timeoutRunningOnEdge = Calculation(
+            identifier = "3",
+            startedAt = Instant.EPOCH.plus(2)
+        )
+
+        val noTimeoutCalcRunning = Calculation(
+            identifier = "4",
+            startedAt = Instant.EPOCH.plus(4)
+        )
+        val noTimeOutCalcFinished = Calculation(
+            identifier = "5",
+            startedAt = Instant.EPOCH.plus(5),
+            finishedAt = Instant.EPOCH.plus(15)
+        )
+
+        val calcData = mapOf(
+            timeoutOnRunningCalc.identifier to timeoutOnRunningCalc,
+            timeoutonRunningCalc2.identifier to timeoutonRunningCalc2,
+            timeoutIgnoresFinishedCalcs.identifier to timeoutIgnoresFinishedCalcs,
+            timeoutRunningOnEdge.identifier to timeoutRunningOnEdge,
+            noTimeoutCalcRunning.identifier to noTimeoutCalcRunning,
+            noTimeOutCalcFinished.identifier to noTimeOutCalcFinished,
+        )
+
+        coEvery { storage.load() } returns calcData
+
+        createInstance(scope = this).apply {
+            advanceUntilIdle()
+
+            calculations.first().apply {
+                size shouldBe 6
+
+                this["0"] shouldBe timeoutOnRunningCalc.copy(
+                    finishedAt = timeStamper.nowUTC,
+                    result = Calculation.Result.TIMEOUT
+                )
+                this["1"] shouldBe timeoutonRunningCalc2.copy(
+                    finishedAt = timeStamper.nowUTC,
+                    result = Calculation.Result.TIMEOUT
+                )
+                this["2"] shouldBe timeoutIgnoresFinishedCalcs
+
+                this["3"] shouldBe timeoutRunningOnEdge
+
+
+                this["4"] shouldBe noTimeoutCalcRunning
+                this["5"] shouldBe noTimeOutCalcFinished
+            }
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt
index 6ad52b84c..f0802e4c3 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt
@@ -1,57 +1,95 @@
 package de.rki.coronawarnapp.receiver
 
+import android.app.Application
 import android.content.Context
 import android.content.Intent
 import androidx.work.WorkManager
 import androidx.work.WorkRequest
 import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import dagger.android.AndroidInjector
+import dagger.android.HasAndroidInjector
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation
+import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker
+import de.rki.coronawarnapp.util.di.AppInjector
 import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
 import io.mockk.mockk
+import io.mockk.mockkObject
 import io.mockk.mockkStatic
-import io.mockk.unmockkAll
-import io.mockk.verify
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
+import io.mockk.verifySequence
+import kotlinx.coroutines.test.TestCoroutineScope
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.TestDispatcherProvider
 
 /**
  * ExposureStateUpdateReceiver test.
  */
-class ExposureStateUpdateReceiverTest {
-    @MockK
-    private lateinit var context: Context
+class ExposureStateUpdateReceiverTest : BaseTest() {
 
-    @MockK
-    private lateinit var intent: Intent
+    @MockK private lateinit var context: Context
 
-    @Before
+    @MockK private lateinit var intent: Intent
+    @MockK private lateinit var workManager: WorkManager
+    @MockK private lateinit var calculationTracker: CalculationTracker
+    private val scope = TestCoroutineScope()
+
+    class TestApp : Application(), HasAndroidInjector {
+        override fun androidInjector(): AndroidInjector<Any> {
+            // NOOP
+            return mockk()
+        }
+    }
+
+    @BeforeEach
     fun setUp() {
         MockKAnnotations.init(this)
         mockkStatic(WorkManager::class)
-        every { intent.action } returns ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED
         every { intent.getStringExtra(ExposureNotificationClient.EXTRA_TOKEN) } returns "token"
+
+        mockkObject(AppInjector)
+
+        val application = mockk<TestApp>()
+        every { context.applicationContext } returns application
+        val broadcastReceiverInjector = AndroidInjector<Any> {
+            it as ExposureStateUpdateReceiver
+            it.calculationTracker = calculationTracker
+            it.dispatcherProvider = TestDispatcherProvider
+            it.scope = scope
+        }
+        every { application.androidInjector() } returns broadcastReceiverInjector
+
+        every { WorkManager.getInstance(context) } returns workManager
+        every { workManager.enqueue(any<WorkRequest>()) } answers { mockk() }
     }
 
-    /**
-     * Test ExposureStateUpdateReceiver.
-     */
-    @Test
-    fun testExposureStateUpdateReceiver() {
-        val wm = mockk<WorkManager>()
-        every { WorkManager.getInstance(context) } returns wm
-        every { wm.enqueue(any<WorkRequest>()) } answers { mockk() }
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
 
+    @Test
+    fun `updated state intent`() {
+        every { intent.action } returns ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED
         ExposureStateUpdateReceiver().onReceive(context, intent)
 
-        verify {
-            wm.enqueue(any<WorkRequest>())
+        verifySequence {
+            workManager.enqueue(any<WorkRequest>())
+            calculationTracker.finishCalculation("token", Calculation.Result.UPDATED_STATE)
         }
     }
 
-    @After
-    fun cleanUp() {
-        unmockkAll()
+    @Test
+    fun `no matches intent`() {
+        every { intent.action } returns ExposureNotificationClient.ACTION_EXPOSURE_NOT_FOUND
+        ExposureStateUpdateReceiver().onReceive(context, intent)
+
+        verifySequence {
+            calculationTracker.finishCalculation("token", Calculation.Result.NO_MATCHES)
+        }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
index e823164d0..c2609308b 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt
@@ -143,7 +143,7 @@ class TaskControllerTest : BaseIOTest() {
                 taskState.resultOrThrow shouldBe null
             }
         }
-        val progressCollector = infoRunning.progress.test(scope = this)
+        val progressCollector = infoRunning.progress.test(startOnScope = this)
 
         this.advanceUntilIdle()
 
@@ -153,7 +153,7 @@ class TaskControllerTest : BaseIOTest() {
 
         arguments.path.exists() shouldBe true
 
-        val lastProgressMessage = progressCollector.values().last().primaryMessage.get(mockk())
+        val lastProgressMessage = progressCollector.latestValue!!.primaryMessage.get(mockk())
         lastProgressMessage shouldBe arguments.values.last()
 
         infoFinished.apply {
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/GeneralTracingStatusTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/GeneralTracingStatusTest.kt
index c78e820d6..f7f0ad930 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/GeneralTracingStatusTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/tracing/GeneralTracingStatusTest.kt
@@ -55,14 +55,14 @@ class GeneralTracingStatusTest : BaseTest() {
 
     @Test
     fun `flow updates work`() = runBlockingTest {
-        val testCollector = createInstance().generalStatus.test(scope = this)
+        val testCollector = createInstance().generalStatus.test(startOnScope = this)
 
         isBluetoothEnabled.emit(false)
         isBluetoothEnabled.emit(true)
 
         advanceUntilIdle()
 
-        testCollector.values() shouldBe listOf(
+        testCollector.latestValues shouldBe listOf(
             GeneralTracingStatus.Status.TRACING_ACTIVE,
             GeneralTracingStatus.Status.BLUETOOTH_DISABLED,
             GeneralTracingStatus.Status.TRACING_ACTIVE
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionCountrySelectViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionCountrySelectViewModelTest.kt
index 6fc0545ed..dc2a0ec37 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionCountrySelectViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/viewmodel/SubmissionCountrySelectViewModelTest.kt
@@ -5,7 +5,6 @@ import de.rki.coronawarnapp.ui.submission.SubmissionCountry
 import io.kotest.inspectors.forAll
 import io.kotest.inspectors.forAtLeastOne
 import io.kotest.inspectors.forAtMostOne
-import io.kotest.matchers.collections.shouldHaveSize
 import io.kotest.matchers.shouldBe
 import org.junit.Rule
 import org.junit.Test
@@ -20,7 +19,7 @@ class SubmissionCountrySelectViewModelTest {
 
         viewModel.fetchCountries()
         // TODO: implement proper test one backend is merged
-        viewModel.countries.value!!.shouldHaveSize(2)
+        viewModel.countries.value!!.size shouldBe 2
     }
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt
index a5a0bf6d8..aba526c2f 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt
@@ -51,7 +51,7 @@ class TracingCardStateTest : BaseTest() {
         tracingStatus = tracingStatus,
         riskLevelScore = riskLevel,
         isRefreshing = isRefreshing,
-        riskLevelLastSuccessfulCalculation = riskLevelLastSuccessfulCalculation,
+        lastRiskLevelScoreCalculated = riskLevelLastSuccessfulCalculation,
         matchedKeyCount = matchedKeyCount,
         daysSinceLastExposure = daysSinceLastExposure,
         activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod,
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt
index ce64eba22..7c1f6d463 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingStateTest.kt
@@ -58,7 +58,7 @@ class BaseTracingStateTest : BaseTest() {
         override val tracingStatus: GeneralTracingStatus.Status = tracingStatus
         override val riskLevelScore: Int = riskLevelScore
         override val isRefreshing: Boolean = isRefreshing
-        override val riskLevelLastSuccessfulCalculation: Int = riskLevelLastSuccessfulCalculation
+        override val lastRiskLevelScoreCalculated: Int = riskLevelLastSuccessfulCalculation
         override val matchedKeyCount: Int = matchedKeyCount
         override val daysSinceLastExposure: Int = daysSinceLastExposure
         override val activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
index 3cf9e6593..140638120 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/details/TracingDetailsStateTest.kt
@@ -53,7 +53,7 @@ class TracingDetailsStateTest : BaseTest() {
         tracingStatus = tracingStatus,
         riskLevelScore = riskLevelScore,
         isRefreshing = isRefreshing,
-        riskLevelLastSuccessfulCalculation = riskLevelLastSuccessfulCalculation,
+        lastRiskLevelScoreCalculated = riskLevelLastSuccessfulCalculation,
         matchedKeyCount = matchedKeyCount,
         daysSinceLastExposure = daysSinceLastExposure,
         activeTracingDaysInRetentionPeriod = activeTracingDaysInRetentionPeriod,
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProviderTest.kt
index fc0039c4e..8ada1306d 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProviderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothProviderTest.kt
@@ -93,7 +93,7 @@ class BluetoothProviderTest : BaseTest() {
     fun `system callbacks lead to new emissions with an updated state`() = runBlockingTest {
         val instance = createInstance()
 
-        val testCollector = instance.isBluetoothEnabled.test(scope = this)
+        val testCollector = instance.isBluetoothEnabled.test(startOnScope = this)
 
         lastFilter!!.hasAction(BluetoothAdapter.ACTION_STATE_CHANGED)
 
@@ -103,7 +103,7 @@ class BluetoothProviderTest : BaseTest() {
             onReceive(mockk(), mockBluetoothIntent(enabled = null))
         }
 
-        testCollector.values() shouldBe listOf(true, false, true)
+        testCollector.latestValues shouldBe listOf(true, false, true)
 
         instance.isBluetoothEnabled.first() shouldBe true
 
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
new file mode 100644
index 000000000..f50fa2763
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt
@@ -0,0 +1,158 @@
+package de.rki.coronawarnapp.util.flow
+
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.types.instanceOf
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockk
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.TestCoroutineScope
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import testhelpers.coroutines.runBlockingTest2
+import testhelpers.coroutines.test
+import java.io.IOException
+import java.lang.Thread.sleep
+import kotlin.concurrent.thread
+
+class HotDataFlowTest : BaseTest() {
+
+    @Test
+    fun `init call only happens on first collection`() {
+        val testScope = TestCoroutineScope()
+        val hotData = HotDataFlow<String>(
+            loggingTag = "tag",
+            scope = testScope,
+            coroutineContext = Dispatchers.Unconfined,
+            startValueProvider = {
+                throw IOException()
+            }
+        )
+
+        testScope.apply {
+            runBlockingTest2(permanentJobs = true) {
+                hotData.data.first()
+            }
+            uncaughtExceptions.single() shouldBe instanceOf(IOException::class)
+        }
+    }
+
+    @Test
+    fun `subscription ends when no subscriber is collecting, mode WhileSubscribed`() {
+        val testScope = TestCoroutineScope()
+        val valueProvider = mockk<suspend CoroutineScope.() -> String>()
+        coEvery { valueProvider.invoke(any()) } returns "Test"
+
+        val hotData = HotDataFlow(
+            loggingTag = "tag",
+            scope = testScope,
+            coroutineContext = Dispatchers.Unconfined,
+            startValueProvider = valueProvider,
+            sharingBehavior = SharingStarted.WhileSubscribed()
+        )
+
+        testScope.apply {
+            runBlockingTest2(permanentJobs = true) {
+                hotData.data.first() shouldBe "Test"
+                hotData.data.first() shouldBe "Test"
+            }
+            coVerify(exactly = 2) { valueProvider.invoke(any()) }
+        }
+    }
+
+    @Test
+    fun `subscription doesn't end when no subscriber is collecting, mode Lazily`() {
+        val testScope = TestCoroutineScope()
+        val valueProvider = mockk<suspend CoroutineScope.() -> String>()
+        coEvery { valueProvider.invoke(any()) } returns "Test"
+
+        val hotData = HotDataFlow(
+            loggingTag = "tag",
+            scope = testScope,
+            coroutineContext = Dispatchers.Unconfined,
+            startValueProvider = valueProvider,
+            sharingBehavior = SharingStarted.Lazily
+        )
+
+        testScope.apply {
+            runBlockingTest2(permanentJobs = true) {
+                hotData.data.first() shouldBe "Test"
+                hotData.data.first() shouldBe "Test"
+            }
+            coVerify(exactly = 1) { valueProvider.invoke(any()) }
+        }
+    }
+
+    @Test
+    fun `value updates`() {
+        val testScope = TestCoroutineScope()
+        val valueProvider = mockk<suspend CoroutineScope.() -> Long>()
+        coEvery { valueProvider.invoke(any()) } returns 1
+
+        val hotData = HotDataFlow(
+            loggingTag = "tag",
+            scope = testScope,
+            startValueProvider = valueProvider,
+            sharingBehavior = SharingStarted.Lazily
+        )
+
+        val testCollector = hotData.data.test(startOnScope = testScope)
+        testCollector.silent = true
+
+        (1..16).forEach { _ ->
+            thread {
+                (1..200).forEach { _ ->
+                    sleep(10)
+                    hotData.updateSafely {
+                        this + 1L
+                    }
+                }
+            }
+        }
+
+        runBlocking {
+            testCollector.await { list, l -> list.size == 3201 }
+            testCollector.latestValues shouldBe (1..3201).toList()
+        }
+
+        coVerify(exactly = 1) { valueProvider.invoke(any()) }
+    }
+
+    @Test
+    fun `multiple subscribers share the flow`() {
+        val testScope = TestCoroutineScope()
+        val valueProvider = mockk<suspend CoroutineScope.() -> String>()
+        coEvery { valueProvider.invoke(any()) } returns "Test"
+
+        val hotData = HotDataFlow(
+            loggingTag = "tag",
+            scope = testScope,
+            startValueProvider = valueProvider,
+            sharingBehavior = SharingStarted.Lazily
+        )
+
+
+        testScope.runBlockingTest2(permanentJobs = true) {
+            val sub1 = hotData.data.test().start(scope = this)
+            val sub2 = hotData.data.test().start(scope = this)
+            val sub3 = hotData.data.test().start(scope = this)
+
+            hotData.updateSafely { "A" }
+            hotData.updateSafely { "B" }
+            hotData.updateSafely { "C" }
+
+            listOf(sub1, sub2, sub3).forEach {
+                it.await { list, s -> list.size == 4 }
+                it.latestValues shouldBe listOf("Test", "A", "B", "C")
+                it.cancel()
+            }
+
+            hotData.data.first() shouldBe "C"
+        }
+        coVerify(exactly = 1) { valueProvider.invoke(any()) }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/location/LocationProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/location/LocationProviderTest.kt
index 32c48e901..ed98e17e1 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/location/LocationProviderTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/location/LocationProviderTest.kt
@@ -95,7 +95,7 @@ class LocationProviderTest : BaseTest() {
 
         mockLocationStatus(enabled = true)
 
-        val testCollector = instance.isLocationEnabled.test(scope = this)
+        val testCollector = instance.isLocationEnabled.test(startOnScope = this)
 
         lastFilter!!.hasAction(BluetoothAdapter.ACTION_STATE_CHANGED)
 
@@ -105,7 +105,7 @@ class LocationProviderTest : BaseTest() {
             onReceive(mockk(), mockLocationChange(enabled = null))
         }
 
-        testCollector.values() shouldBe listOf(true, false, true)
+        testCollector.latestValues shouldBe listOf(true, false, true)
 
         instance.isLocationEnabled.first() shouldBe true
 
diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt
index 8ca8366ca..975a8c48d 100644
--- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt
+++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt
@@ -3,46 +3,60 @@ package testhelpers.coroutines
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.buffer
 import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.onStart
+import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import timber.log.Timber
 
-suspend fun <T> Flow<T>.test(
+fun <T> Flow<T>.test(
     tag: String? = null,
-    scope: CoroutineScope
-): TestCollector<T> = TestCollector(this, scope, tag ?: "FlowTest").apply {
-    ensureSetup()
-}
+    startOnScope: CoroutineScope
+): TestCollector<T> = test(tag ?: "FlowTest").start(scope = startOnScope)
+
+fun <T> Flow<T>.test(
+    tag: String? = null
+): TestCollector<T> = TestCollector(this, tag ?: "FlowTest")
 
 class TestCollector<T>(
     private val flow: Flow<T>,
-    private val scope: CoroutineScope,
     private val tag: String
 
 ) {
-    private val mutex = Mutex()
-    private var isSetupDone = false
-    private val collectedValues = mutableListOf<T>()
     private var error: Throwable? = null
     private lateinit var job: Job
+    private val cache = MutableSharedFlow<T>(
+        replay = Int.MAX_VALUE,
+        extraBufferCapacity = Int.MAX_VALUE,
+        onBufferOverflow = BufferOverflow.SUSPEND
+    )
+    private var latestInternal: T? = null
+    private val collectedValuesMutex = Mutex()
+    private val collectedValues = mutableListOf<T>()
 
-    suspend fun ensureSetup() = mutex.withLock {
-        if (isSetupDone) return@withLock
-        isSetupDone = true
+    var silent = false
 
-        Timber.tag(tag).v("Setting up.")
+    fun start(scope: CoroutineScope) = apply {
         flow
+            .buffer(capacity = Int.MAX_VALUE)
+            .onStart { Timber.tag(tag).v("Setting up.") }
+            .onCompletion { Timber.tag(tag).d("Final.") }
             .onEach {
-                Timber.tag(tag).v("Collecting: %s", it)
-                collectedValues.add(it)
-            }
-            .onCompletion {
-                Timber.tag(tag).d("Final.")
+                collectedValuesMutex.withLock {
+                    if (!silent) Timber.tag(tag).v("Collecting: %s", it)
+                    latestInternal = it
+                    collectedValues.add(it)
+                    cache.emit(it)
+                }
             }
             .catch { e ->
                 Timber.tag(tag).w(e, "Caught error.")
@@ -52,24 +66,36 @@ class TestCollector<T>(
             .also { job = it }
     }
 
+    fun emissions(): Flow<T> = cache
+
+    val latestValue: T?
+        get() = collectedValues.last()
+
+    val latestValues: List<T>
+        get() = collectedValues
+
+    fun await(condition: (List<T>, T) -> Boolean): T = runBlocking {
+        emissions().first {
+            condition(collectedValues, it)
+        }
+    }
+
     suspend fun awaitFinal() = apply {
-        ensureSetup()
-        job.join()
+        try {
+            job.join()
+        } catch (e: Exception) {
+            error = e
+        }
     }
 
     suspend fun assertNoErrors() = apply {
-        ensureSetup()
         awaitFinal()
         require(error == null) { "Error was not null: $error" }
     }
 
-    suspend fun values(): List<T> {
-        ensureSetup()
-        return collectedValues
-    }
-
-    suspend fun cancel() {
-        ensureSetup()
-        job.cancelAndJoin()
+    fun cancel() {
+        runBlocking {
+            job.cancelAndJoin()
+        }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
new file mode 100644
index 000000000..bcf45781d
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt
@@ -0,0 +1,44 @@
+package testhelpers.coroutines
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.TestCoroutineScope
+import kotlinx.coroutines.test.UncompletedCoroutinesError
+import kotlinx.coroutines.test.runBlockingTest
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+@ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0
+fun TestCoroutineScope.runBlockingTest2(
+    permanentJobs: Boolean = false,
+    block: suspend TestCoroutineScope.() -> Unit
+): Unit = runBlockingTest2(
+    permanentJobs = permanentJobs,
+    context = coroutineContext,
+    testBody = block
+)
+
+fun runBlockingTest2(
+    permanentJobs: Boolean = false,
+    context: CoroutineContext = EmptyCoroutineContext,
+    testBody: suspend TestCoroutineScope.() -> Unit
+) {
+    try {
+        runBlocking {
+            try {
+                runBlockingTest(
+                    context = context,
+                    testBody = testBody
+                )
+            } catch (e: UncompletedCoroutinesError) {
+                if (!permanentJobs) throw e
+            }
+        }
+    } catch (e: Exception) {
+        if (!permanentJobs || (e.message != "This job has not completed yet")) {
+            throw e
+        }
+    }
+}
+
+
-- 
GitLab