From dc5d765933eca10895e5d391e481145e1e2aefc7 Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <matthias.urhahn@sap.com>
Date: Tue, 1 Dec 2020 16:12:18 +0100
Subject: [PATCH] Fix refresh check and improve test menu (EXPOSUREAPP-4049)
 (#1772)

* Remove "lastTimeDiagnosisKeysFromServerFetch" and replace it with less missleading data.
While it was called "lastTimeDiagnosisKeysFromServerFetch" it was actually "last time we submitted keys to google".

* To decide whether to refresh in "onResume", we now use "has there been any submission to the ENF?"
* To display a timestamp on the risk card, we take the last successful submission to the ENF as the risk card displays the calculation results based on the latest submission. While we could use the last calculated risk level result timestamp, we currently also trigger risk calculations if there are no new submissions to the ENF, which would mean the timestamp is updated even though the result is not based on new data.

I've also fixed the test fragment button behavior and added descriptions, the "Reset risk level" button surfaced the initial issue because it behaved like a "do a 75% data reset" button.

* Tests and LINTs

Co-authored-by: harambasicluka <64483219+harambasicluka@users.noreply.github.com>
Co-authored-by: Ralf Gehrer <ralfgehrer@users.noreply.github.com>
---
 .../ui/TestRiskLevelCalculationFragment.kt    | 11 +--
 ...iskLevelCalculationFragmentCWAViewModel.kt | 55 +++++------
 .../fragment_test_risk_level_calculation.xml  | 52 +++++++---
 .../download/DownloadDiagnosisKeysTask.kt     | 19 ----
 .../ExposureDetectionTrackerExtensions.kt     | 22 +++++
 .../de/rki/coronawarnapp/storage/LocalData.kt | 28 +-----
 .../storage/TracingRepository.kt              | 16 ++--
 .../ui/tracing/card/TracingCardState.kt       | 19 ++--
 .../tracing/card/TracingCardStateProvider.kt  | 13 ++-
 .../ExposureDetectionTrackerExtensionsTest.kt | 94 +++++++++++++++++++
 .../ui/tracing/card/TracingCardStateTest.kt   | 27 +++---
 11 files changed, 220 insertions(+), 136 deletions(-)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerExtensions.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerExtensionsTest.kt

diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
index e3b014dea..d6c542fe2 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt
@@ -5,7 +5,6 @@ import android.os.Bundle
 import android.view.View
 import android.widget.RadioButton
 import android.widget.RadioGroup
-import android.widget.Toast
 import androidx.core.app.ShareCompat
 import androidx.core.content.FileProvider
 import androidx.core.view.ViewCompat
@@ -13,6 +12,7 @@ import androidx.core.view.children
 import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
 import androidx.navigation.fragment.navArgs
+import com.google.android.material.snackbar.Snackbar
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.databinding.FragmentTestRiskLevelCalculationBinding
 import de.rki.coronawarnapp.storage.TestSettings
@@ -59,12 +59,9 @@ class TestRiskLevelCalculationFragment : Fragment(R.layout.fragment_test_risk_le
         binding.buttonClearDiagnosisKeyCache.setOnClickListener { vm.clearKeyCache() }
         binding.buttonResetRiskLevel.setOnClickListener { vm.resetRiskLevel() }
         binding.buttonExposureWindowsShare.setOnClickListener { vm.shareExposureWindows() }
-        vm.riskLevelResetEvent.observe2(this) {
-            Toast.makeText(
-                requireContext(), "Reset done, please fetch diagnosis keys from server again",
-                Toast.LENGTH_SHORT
-            ).show()
-        }
+
+        vm.dataResetEvent.observe2(this) { Snackbar.make(requireView(), it, Snackbar.LENGTH_SHORT).show() }
+
         vm.additionalRiskCalcInfo.observe2(this) {
             binding.labelRiskAdditionalInfo.text = it
         }
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
index ca4ed7c60..f69b6f889 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt
@@ -10,16 +10,15 @@ import com.squareup.inject.assisted.AssistedInject
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.appconfig.ConfigData
 import de.rki.coronawarnapp.diagnosiskeys.download.DownloadDiagnosisKeysTask
+import de.rki.coronawarnapp.diagnosiskeys.download.KeyPackageSyncSettings
 import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository
-import de.rki.coronawarnapp.exception.ExceptionCategory
-import de.rki.coronawarnapp.exception.reporting.report
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.latestSubmission
 import de.rki.coronawarnapp.risk.RiskLevelTask
 import de.rki.coronawarnapp.risk.RiskState
 import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.risk.result.AggregatedRiskResult
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
-import de.rki.coronawarnapp.storage.AppDatabase
-import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.SubmissionRepository
 import de.rki.coronawarnapp.storage.TestSettings
 import de.rki.coronawarnapp.task.TaskController
@@ -32,21 +31,17 @@ import de.rki.coronawarnapp.util.NetworkRequestWrapper.Companion.withSuccess
 import de.rki.coronawarnapp.util.TimeStamper
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.di.AppContext
-import de.rki.coronawarnapp.util.security.SecurityHelper
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory
-import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.firstOrNull
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.sample
-import kotlinx.coroutines.withContext
 import org.joda.time.Instant
 import org.joda.time.format.DateTimeFormat
 import timber.log.Timber
 import java.io.File
-import java.util.Date
 import java.util.concurrent.TimeUnit
 
 class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
@@ -60,7 +55,9 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
     tracingCardStateProvider: TracingCardStateProvider,
     private val riskLevelStorage: RiskLevelStorage,
     private val testSettings: TestSettings,
-    private val timeStamper: TimeStamper
+    private val timeStamper: TimeStamper,
+    private val exposureDetectionTracker: ExposureDetectionTracker,
+    private val keyPackageSyncSettings: KeyPackageSyncSettings
 ) : CWAViewModel(
     dispatcherProvider = dispatcherProvider
 ) {
@@ -78,7 +75,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
         Timber.d("Example arg: %s", exampleArg)
     }
 
-    val riskLevelResetEvent = SingleLiveEvent<Unit>()
+    val dataResetEvent = SingleLiveEvent<String>()
     val shareFileEvent = SingleLiveEvent<File>()
 
     val showRiskStatusCard = SubmissionRepository.deviceUIStateFlow.map {
@@ -151,8 +148,8 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
 
     val additionalRiskCalcInfo = combine(
         riskLevelStorage.riskLevelResults,
-        LocalData.lastTimeDiagnosisKeysFromServerFetchFlow()
-    ) { riskLevelResults, lastTimeDiagnosisKeysFromServerFetch ->
+        exposureDetectionTracker.latestSubmission()
+    ) { riskLevelResults, latestSubmission ->
 
         val (latestCalc, latestSuccessfulCalc) = riskLevelResults.tryLatestResultsWithDefaults()
 
@@ -162,7 +159,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
             riskLevelLastSuccessfulCalculated = latestSuccessfulCalc.riskState,
             matchedKeyCount = latestCalc.matchedKeyCount,
             daysSinceLastExposure = latestCalc.daysWithEncounters,
-            lastTimeDiagnosisKeysFromServerFetch = lastTimeDiagnosisKeysFromServerFetch
+            lastKeySubmission = latestSubmission?.startedAt
         )
     }.asLiveData()
 
@@ -172,13 +169,13 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
         riskLevelLastSuccessfulCalculated: RiskState,
         matchedKeyCount: Int,
         daysSinceLastExposure: Int,
-        lastTimeDiagnosisKeysFromServerFetch: Date?
+        lastKeySubmission: Instant?
     ): String = StringBuilder()
         .appendLine("Risk Level: $riskLevel")
         .appendLine("Last successful Risk Level: $riskLevelLastSuccessfulCalculated")
         .appendLine("Matched key count: $matchedKeyCount")
         .appendLine("Days since last Exposure: $daysSinceLastExposure days")
-        .appendLine("Last Time Server Fetch: ${lastTimeDiagnosisKeysFromServerFetch?.time?.let { Instant.ofEpochMilli(it) }}")
+        .appendLine("Last key submission: $lastKeySubmission")
         .appendLine("Tracing Duration: ${TimeUnit.MILLISECONDS.toDays(TimeVariables.getTimeActiveTracingDuration())} days")
         .appendLine("Tracing Duration in last 14 days: ${TimeVariables.getActiveTracingDaysInRetentionPeriod()} days")
         .appendLine("Last time risk level calculation $lastTimeRiskLevelCalculation")
@@ -210,24 +207,8 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
     fun resetRiskLevel() {
         Timber.d("Resetting risk level")
         launch {
-            withContext(Dispatchers.IO) {
-                try {
-                    // Preference reset
-                    SecurityHelper.resetSharedPrefs()
-                    // Database Reset
-                    AppDatabase.reset(context)
-                    // Export File Reset
-                    keyCacheRepository.clear()
-
-                    riskLevelStorage.clear()
-
-                    LocalData.lastTimeDiagnosisKeysFromServerFetch(null)
-                } catch (e: Exception) {
-                    e.report(ExceptionCategory.INTERNAL)
-                }
-            }
-            taskController.submit(DefaultTaskRequest(RiskLevelTask::class))
-            riskLevelResetEvent.postValue(Unit)
+            riskLevelStorage.clear()
+            dataResetEvent.postValue("Risk level calculation related data reset.")
         }
     }
 
@@ -257,7 +238,13 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor(
 
     fun clearKeyCache() {
         Timber.d("Clearing key cache")
-        launch { keyCacheRepository.clear() }
+        launch {
+            keyCacheRepository.clear()
+            keyPackageSyncSettings.clear()
+            exposureDetectionTracker.clear()
+
+            dataResetEvent.postValue("Download & Submission related data reset.")
+        }
     }
 
     fun selectFakeExposureWindowMode(newMode: TestSettings.FakeExposureWindowTypes) {
diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
index 1e6d0bf1b..b11dc97e7 100644
--- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
+++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml
@@ -34,13 +34,13 @@
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
             android:orientation="vertical">
-            
+
             <LinearLayout
                 android:id="@+id/environment_container"
                 style="@style/card"
                 android:layout_width="match_parent"
-                android:orientation="vertical"
-                android:layout_height="wrap_content">
+                android:layout_height="wrap_content"
+                android:orientation="vertical">
 
                 <TextView
                     android:id="@+id/fake_windows_title"
@@ -50,11 +50,11 @@
                     android:text="Fake exposure windows" />
 
                 <TextView
-                    android:layout_width="match_parent"
                     style="@style/TextAppearance.AppCompat.Caption"
+                    android:layout_width="match_parent"
+                    android:layout_height="wrap_content"
                     android:layout_marginTop="@dimen/spacing_tiny"
-                    android:text="Takes effect the next time `ExposureNotificationClient.exposureWindows` is called, i.e. on risk level calculation."
-                    android:layout_height="wrap_content" />
+                    android:text="Takes effect the next time `ExposureNotificationClient.exposureWindows` is called, i.e. on risk level calculation." />
 
                 <RadioGroup
                     android:id="@+id/fake_windows_toggle_group"
@@ -83,28 +83,48 @@
             </FrameLayout>
 
             <Button
-                android:id="@+id/button_retrieve_diagnosis_keys"
+                android:id="@+id/button_calculate_risk_level"
                 style="@style/buttonPrimary"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:text="Retrieve Diagnosis Keys" />
+                android:text="Calculate Risk Level" />
+            <TextView
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/spacing_tiny"
+                android:text="Start the task that gets the latest exposure windows and calculates a current risk state." />
 
             <Button
-                android:id="@+id/button_calculate_risk_level"
+                android:id="@+id/button_reset_risk_level"
                 style="@style/buttonPrimary"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:text="Calculate Risk Level" />
+                android:text="Reset Risk Level" />
+
+            <TextView
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/spacing_tiny"
+                android:text="Delete the all stored calculated risk level results." />
 
             <Button
-                android:id="@+id/button_reset_risk_level"
+                android:id="@+id/button_retrieve_diagnosis_keys"
                 style="@style/buttonPrimary"
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:text="Reset Risk Level" />
+                android:text="Download Diagnosis Keys" />
+
+            <TextView
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/spacing_tiny"
+                android:text="Start the task syncs the local diagnosis key cache with the server and submits them to the exposure notification framework for detection (if constraints allow). " />
 
             <Button
                 android:id="@+id/button_clear_diagnosis_key_cache"
@@ -112,7 +132,13 @@
                 android:layout_width="match_parent"
                 android:layout_height="wrap_content"
                 android:layout_marginTop="@dimen/spacing_normal"
-                android:text="Clear Diagnosis-Key cache" />
+                android:text="Reset Diagnosis-Keys" />
+            <TextView
+                style="@style/TextAppearance.AppCompat.Caption"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:layout_marginTop="@dimen/spacing_tiny"
+                android:text="Restore download task conditions to initial state. Remove cached keys, delete last download logs, reset tracked exposure detections. " />
 
             <TextView
                 android:id="@+id/label_aggregated_risk_result_title"
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt
index 8f34bcc8c..cd5bc298f 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt
@@ -9,7 +9,6 @@ import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
 import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection
 import de.rki.coronawarnapp.risk.RollbackItem
-import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.task.Task
 import de.rki.coronawarnapp.task.TaskCancellationException
 import de.rki.coronawarnapp.task.TaskFactory
@@ -109,12 +108,6 @@ class DownloadDiagnosisKeysTask @Inject constructor(
             )
             Timber.tag(TAG).d("Diagnosis Keys provided (success=%s)", isSubmissionSuccessful)
 
-            // EXPOSUREAPP-3878 write timestamp immediately after submission,
-            // so that progress observers can rely on a clean app state
-            if (isSubmissionSuccessful) {
-                saveTimestamp(currentDate, rollbackItems)
-            }
-
             internalProgress.send(Progress.ApiSubmissionFinished)
 
             return object : Task.Result {}
@@ -159,18 +152,6 @@ class DownloadDiagnosisKeysTask @Inject constructor(
         }
     }
 
-    private fun saveTimestamp(
-        currentDate: Date,
-        rollbackItems: MutableList<RollbackItem>
-    ) {
-        val lastFetchDateForRollback = LocalData.lastTimeDiagnosisKeysFromServerFetch()
-        rollbackItems.add {
-            LocalData.lastTimeDiagnosisKeysFromServerFetch(lastFetchDateForRollback)
-        }
-        Timber.tag(TAG).d("dateUpdate(currentDate=%s)", currentDate)
-        LocalData.lastTimeDiagnosisKeysFromServerFetch(currentDate)
-    }
-
     private fun rollback(rollbackItems: MutableList<RollbackItem>) {
         try {
             Timber.tag(TAG).d("Initiate Rollback")
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerExtensions.kt
new file mode 100644
index 000000000..a05ccdf39
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerExtensions.kt
@@ -0,0 +1,22 @@
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+
+suspend fun ExposureDetectionTracker.lastSubmission(
+    onlyFinished: Boolean = true
+): TrackedExposureDetection? = calculations
+    .first().values
+    .filter { it.isSuccessful || !onlyFinished }
+    .maxByOrNull { it.startedAt }
+
+fun ExposureDetectionTracker.latestSubmission(
+    onlySuccessful: Boolean = true
+): Flow<TrackedExposureDetection?> = calculations
+    .map { entries ->
+        entries.values.filter { it.isSuccessful || !onlySuccessful }
+    }
+    .map { detections ->
+        detections.maxByOrNull { it.startedAt }
+    }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
index 326f05790..1c2adaaf1 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
@@ -4,12 +4,10 @@ import android.content.SharedPreferences
 import androidx.core.content.edit
 import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.R
-import de.rki.coronawarnapp.util.preferences.createFlowPreference
 import de.rki.coronawarnapp.util.security.SecurityHelper.globalEncryptedSharedPreferencesInstance
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.map
-import java.util.Date
+import timber.log.Timber
 
 /**
  * LocalData is responsible for all access to the shared preferences. Each preference is accessible
@@ -306,28 +304,6 @@ object LocalData {
             .edit(commit = true) { putBoolean(PREFERENCE_HAS_RISK_STATUS_LOWERED, value) }
             .also { isUserToBeNotifiedOfLoweredRiskLevelFlowInternal.value = value }
 
-    /****************************************************
-     * SERVER FETCH DATA
-     ****************************************************/
-
-    private val dateMapperForFetchTime: (Long) -> Date? = {
-        if (it != 0L) Date(it) else null
-    }
-
-    private val lastTimeDiagnosisKeysFetchedFlowPref by lazy {
-        getSharedPreferenceInstance()
-            .createFlowPreference<Long>(key = "preference_timestamp_diagnosis_keys_fetch", 0L)
-    }
-
-    fun lastTimeDiagnosisKeysFromServerFetchFlow() = lastTimeDiagnosisKeysFetchedFlowPref.flow
-        .map { dateMapperForFetchTime(it) }
-
-    fun lastTimeDiagnosisKeysFromServerFetch() =
-        dateMapperForFetchTime(lastTimeDiagnosisKeysFetchedFlowPref.value)
-
-    fun lastTimeDiagnosisKeysFromServerFetch(value: Date?) =
-        lastTimeDiagnosisKeysFetchedFlowPref.update { value?.time ?: 0L }
-
     /****************************************************
      * SETTINGS DATA
      ****************************************************/
@@ -583,6 +559,6 @@ object LocalData {
         }
 
     fun clear() {
-        lastTimeDiagnosisKeysFetchedFlowPref.update { 0L }
+        Timber.w("LocalData.clear()")
     }
 }
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 8174877c2..e107f9754 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,8 @@ import android.content.Context
 import de.rki.coronawarnapp.diagnosiskeys.download.DownloadDiagnosisKeysTask
 import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.lastSubmission
 import de.rki.coronawarnapp.risk.RiskLevelTask
 import de.rki.coronawarnapp.risk.TimeVariables.getActiveTracingDaysInRetentionPeriod
 import de.rki.coronawarnapp.task.TaskController
@@ -24,7 +26,6 @@ import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.launch
 import org.joda.time.Duration
 import timber.log.Timber
-import java.util.Date
 import java.util.NoSuchElementException
 import javax.inject.Inject
 import javax.inject.Singleton
@@ -43,11 +44,10 @@ class TracingRepository @Inject constructor(
     @AppScope private val scope: CoroutineScope,
     private val taskController: TaskController,
     enfClient: ENFClient,
-    private val timeStamper: TimeStamper
+    private val timeStamper: TimeStamper,
+    private val exposureDetectionTracker: ExposureDetectionTracker
 ) {
 
-    val lastTimeDiagnosisKeysFetched: Flow<Date?> = LocalData.lastTimeDiagnosisKeysFromServerFetchFlow()
-
     private val internalActiveTracingDaysInRetentionPeriod = MutableStateFlow(0L)
     val activeTracingDaysInRetentionPeriod: Flow<Long> = internalActiveTracingDaysInRetentionPeriod
 
@@ -121,15 +121,15 @@ class TracingRepository @Inject constructor(
         // model the keys are only fetched on button press of the user
         val isBackgroundJobEnabled = ConnectivityHelper.autoModeEnabled(context)
 
-        val wasNotYetFetched = LocalData.lastTimeDiagnosisKeysFromServerFetch() == null
-
         Timber.tag(TAG).v("Network is enabled $isNetworkEnabled")
         Timber.tag(TAG).v("Background jobs are enabled $isBackgroundJobEnabled")
-        Timber.tag(TAG).v("Was not yet fetched from server $wasNotYetFetched")
 
         if (isNetworkEnabled && isBackgroundJobEnabled) {
             scope.launch {
-                if (wasNotYetFetched || downloadDiagnosisKeysTaskDidNotRunRecently()) {
+                val lastSubmission = exposureDetectionTracker.lastSubmission(onlyFinished = false)
+                Timber.tag(TAG).v("Last submission was %s", lastSubmission)
+
+                if (lastSubmission == null || downloadDiagnosisKeysTaskDidNotRunRecently()) {
                     Timber.tag(TAG).v("Start the fetching and submitting of the diagnosis keys")
 
                     taskController.submitBlocking(
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 3737a4526..a2f71aeb1 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
@@ -16,7 +16,6 @@ import de.rki.coronawarnapp.ui.tracing.common.BaseTracingState
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDate
 import org.joda.time.Instant
 import org.joda.time.format.DateTimeFormat
-import java.util.Date
 
 @Suppress("TooManyFunctions")
 data class TracingCardState(
@@ -27,7 +26,7 @@ data class TracingCardState(
     val daysWithEncounters: Int,
     val lastEncounterAt: Instant?,
     val activeTracingDays: Long,
-    val lastTimeDiagnosisKeysFetched: Date?,
+    val lastExposureDetectionTime: Instant?,
     override val isManualKeyRetrievalEnabled: Boolean,
     override val showDetails: Boolean = false
 ) : BaseTracingState() {
@@ -166,10 +165,10 @@ data class TracingCardState(
         else -> ""
     }
 
-    private fun formatRelativeDateTimeString(c: Context, date: Date): CharSequence? =
+    private fun formatRelativeDateTimeString(c: Context, date: Instant): CharSequence? =
         DateUtils.getRelativeDateTimeString(
             c,
-            date.time,
+            date.millis,
             DateUtils.DAY_IN_MILLIS,
             DateUtils.DAY_IN_MILLIS * 2,
             0
@@ -183,10 +182,10 @@ data class TracingCardState(
      */
     fun getTimeFetched(c: Context): String {
         if (isTracingOff()) {
-            return if (lastTimeDiagnosisKeysFetched != null) {
+            return if (lastExposureDetectionTime != null) {
                 c.getString(
                     R.string.risk_card_body_time_fetched,
-                    formatRelativeDateTimeString(c, lastTimeDiagnosisKeysFetched)
+                    formatRelativeDateTimeString(c, lastExposureDetectionTime)
                 )
             } else {
                 c.getString(R.string.risk_card_body_not_yet_fetched)
@@ -194,10 +193,10 @@ data class TracingCardState(
         }
         return when (riskState) {
             LOW_RISK, INCREASED_RISK -> {
-                if (lastTimeDiagnosisKeysFetched != null) {
+                if (lastExposureDetectionTime != null) {
                     c.getString(
                         R.string.risk_card_body_time_fetched,
-                        formatRelativeDateTimeString(c, lastTimeDiagnosisKeysFetched)
+                        formatRelativeDateTimeString(c, lastExposureDetectionTime)
                     )
                 } else {
                     c.getString(R.string.risk_card_body_not_yet_fetched)
@@ -206,10 +205,10 @@ data class TracingCardState(
             CALCULATION_FAILED -> {
                 when (lastSuccessfulRiskState) {
                     LOW_RISK, INCREASED_RISK -> {
-                        if (lastTimeDiagnosisKeysFetched != null) {
+                        if (lastExposureDetectionTime != null) {
                             c.getString(
                                 R.string.risk_card_body_time_fetched,
-                                formatRelativeDateTimeString(c, lastTimeDiagnosisKeysFetched)
+                                formatRelativeDateTimeString(c, lastExposureDetectionTime)
                             )
                         } else {
                             c.getString(R.string.risk_card_body_not_yet_fetched)
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 1f8e51ee7..5fa194430 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
@@ -1,6 +1,8 @@
 package de.rki.coronawarnapp.ui.tracing.card
 
 import dagger.Reusable
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker
+import de.rki.coronawarnapp.nearby.modules.detectiontracker.latestSubmission
 import de.rki.coronawarnapp.risk.RiskState
 import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
 import de.rki.coronawarnapp.storage.TracingRepository
@@ -20,7 +22,8 @@ class TracingCardStateProvider @Inject constructor(
     tracingStatus: GeneralTracingStatus,
     backgroundModeStatus: BackgroundModeStatus,
     tracingRepository: TracingRepository,
-    riskLevelStorage: RiskLevelStorage
+    riskLevelStorage: RiskLevelStorage,
+    exposureDetectionTracker: ExposureDetectionTracker
 ) {
 
     val state: Flow<TracingCardState> = combine(
@@ -36,8 +39,8 @@ class TracingCardStateProvider @Inject constructor(
         tracingRepository.activeTracingDaysInRetentionPeriod.onEach {
             Timber.v("activeTracingDaysInRetentionPeriod: $it")
         },
-        tracingRepository.lastTimeDiagnosisKeysFetched.onEach {
-            Timber.v("lastTimeDiagnosisKeysFetched: $it")
+        exposureDetectionTracker.latestSubmission().onEach {
+            Timber.v("latestSubmission: $it")
         },
         backgroundModeStatus.isAutoModeEnabled.onEach {
             Timber.v("isAutoModeEnabled: $it")
@@ -46,7 +49,7 @@ class TracingCardStateProvider @Inject constructor(
         tracingProgress,
         riskLevelResults,
         activeTracingDaysInRetentionPeriod,
-        lastTimeDiagnosisKeysFetched,
+        latestSubmission,
         isBackgroundJobEnabled ->
 
         val (
@@ -61,7 +64,7 @@ class TracingCardStateProvider @Inject constructor(
             riskState = latestCalc.riskState,
             tracingProgress = tracingProgress,
             lastSuccessfulRiskState = latestSuccessfulCalc.riskState,
-            lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched,
+            lastExposureDetectionTime = latestSubmission?.startedAt,
             daysWithEncounters = latestCalc.daysWithEncounters,
             lastEncounterAt = latestCalc.lastRiskEncounterAt,
             activeTracingDays = activeTracingDaysInRetentionPeriod,
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerExtensionsTest.kt
new file mode 100644
index 000000000..18702738c
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerExtensionsTest.kt
@@ -0,0 +1,94 @@
+package de.rki.coronawarnapp.nearby.modules.detectiontracker
+
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+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.BaseTest
+import java.util.UUID
+
+class ExposureDetectionTrackerExtensionsTest : BaseTest() {
+
+    @MockK lateinit var tracker: ExposureDetectionTracker
+
+    private val fakeCalculations: MutableStateFlow<Map<String, TrackedExposureDetection>> = MutableStateFlow(emptyMap())
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { tracker.calculations } returns fakeCalculations
+    }
+
+    @AfterEach
+    fun teardown() {
+    }
+
+    private fun createFakeCalculation(
+        startedAt: Instant,
+        result: TrackedExposureDetection.Result? = TrackedExposureDetection.Result.NO_MATCHES
+    ) = TrackedExposureDetection(
+        identifier = UUID.randomUUID().toString(),
+        startedAt = startedAt,
+        finishedAt = if (result != null) startedAt.plus(100) else null,
+        result = result
+    )
+
+    @Test
+    fun `last submission`() {
+        val tr1 = createFakeCalculation(startedAt = Instant.EPOCH)
+        val tr2 = createFakeCalculation(startedAt = Instant.EPOCH.plus(1))
+        val tr3 = createFakeCalculation(startedAt = Instant.EPOCH.plus(2), result = null)
+        fakeCalculations.value = mapOf(
+            tr1.identifier to tr1,
+            tr2.identifier to tr2,
+            tr3.identifier to tr3,
+        )
+        runBlockingTest {
+            tracker.lastSubmission(onlyFinished = false) shouldBe tr3
+            tracker.lastSubmission(onlyFinished = true) shouldBe tr2
+        }
+    }
+
+    @Test
+    fun `last submission on empty data`() {
+        runBlockingTest {
+            tracker.lastSubmission(onlyFinished = false) shouldBe null
+            tracker.lastSubmission(onlyFinished = true) shouldBe null
+        }
+    }
+
+    @Test
+    fun `latest submission`() {
+        val tr1 = createFakeCalculation(startedAt = Instant.EPOCH)
+        val tr2 = createFakeCalculation(startedAt = Instant.EPOCH.plus(1))
+        val tr3 = createFakeCalculation(startedAt = Instant.EPOCH.plus(2), result = null)
+        fakeCalculations.value = mapOf(
+            tr1.identifier to tr1,
+            tr2.identifier to tr2,
+            tr3.identifier to tr3,
+        )
+        runBlockingTest {
+            tracker.latestSubmission(onlySuccessful = false).first() shouldBe tr3
+            tracker.latestSubmission(onlySuccessful = true).first() shouldBe tr2
+        }
+    }
+
+    @Test
+    fun `latest submission on empty data`() = runBlockingTest {
+        tracker.latestSubmission(onlySuccessful = false).first() shouldBe null
+        tracker.latestSubmission(onlySuccessful = true).first() shouldBe null
+
+        val tr1 = createFakeCalculation(startedAt = Instant.EPOCH)
+        fakeCalculations.value = mapOf(tr1.identifier to tr1)
+
+        tracker.latestSubmission(onlySuccessful = false).first() shouldBe tr1
+        tracker.latestSubmission(onlySuccessful = true).first() shouldBe tr1
+    }
+}
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 52e718e79..dd06def65 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
@@ -22,7 +22,6 @@ import org.junit.jupiter.api.AfterEach
 import org.junit.jupiter.api.BeforeEach
 import org.junit.jupiter.api.Test
 import testhelpers.BaseTest
-import java.util.Date
 
 class TracingCardStateTest : BaseTest() {
 
@@ -46,7 +45,7 @@ class TracingCardStateTest : BaseTest() {
         daysWithEncounters: Int = 0,
         lastEncounterAt: Instant? = null,
         activeTracingDaysInRetentionPeriod: Long = 0,
-        lastTimeDiagnosisKeysFetched: Date? = mockk(),
+        lastExposureDetectionTime: Instant? = mockk(),
         isBackgroundJobEnabled: Boolean = false
     ) = TracingCardState(
         tracingStatus = tracingStatus,
@@ -56,7 +55,7 @@ class TracingCardStateTest : BaseTest() {
         daysWithEncounters = daysWithEncounters,
         lastEncounterAt = lastEncounterAt,
         activeTracingDays = activeTracingDaysInRetentionPeriod,
-        lastTimeDiagnosisKeysFetched = lastTimeDiagnosisKeysFetched,
+        lastExposureDetectionTime = lastExposureDetectionTime,
         isManualKeyRetrievalEnabled = !isBackgroundJobEnabled
     )
 
@@ -251,11 +250,11 @@ class TracingCardStateTest : BaseTest() {
 
     @Test
     fun `text for last time diagnosis keys were fetched`() {
-        val date = Date()
+        val date = Instant()
         createInstance(
             riskState = INCREASED_RISK,
             lastSuccessfulRiskState = LOW_RISK,
-            lastTimeDiagnosisKeysFetched = date
+            lastExposureDetectionTime = date
         ).apply {
             getTimeFetched(context)
             verify { context.getString(eq(R.string.risk_card_body_time_fetched), any()) }
@@ -264,7 +263,7 @@ class TracingCardStateTest : BaseTest() {
         createInstance(
             riskState = CALCULATION_FAILED,
             lastSuccessfulRiskState = LOW_RISK,
-            lastTimeDiagnosisKeysFetched = date
+            lastExposureDetectionTime = date
         ).apply {
             getTimeFetched(context)
             verify { context.getString(eq(R.string.risk_card_body_time_fetched), any()) }
@@ -273,7 +272,7 @@ class TracingCardStateTest : BaseTest() {
         createInstance(
             riskState = CALCULATION_FAILED,
             lastSuccessfulRiskState = LOW_RISK,
-            lastTimeDiagnosisKeysFetched = date
+            lastExposureDetectionTime = date
         ).apply {
             getTimeFetched(context)
             verify { context.getString(eq(R.string.risk_card_body_time_fetched), any()) }
@@ -282,36 +281,36 @@ class TracingCardStateTest : BaseTest() {
         createInstance(
             riskState = LOW_RISK,
             lastSuccessfulRiskState = LOW_RISK,
-            lastTimeDiagnosisKeysFetched = date
+            lastExposureDetectionTime = date
         ).apply {
             getTimeFetched(context)
             verify { context.getString(eq(R.string.risk_card_body_time_fetched), any()) }
         }
 
-        createInstance(riskState = INCREASED_RISK, lastTimeDiagnosisKeysFetched = date).apply {
+        createInstance(riskState = INCREASED_RISK, lastExposureDetectionTime = date).apply {
             getTimeFetched(context)
             verify { context.getString(eq(R.string.risk_card_body_time_fetched), any()) }
         }
 
-        createInstance(riskState = CALCULATION_FAILED, lastTimeDiagnosisKeysFetched = date).apply {
+        createInstance(riskState = CALCULATION_FAILED, lastExposureDetectionTime = date).apply {
             getTimeFetched(context) shouldBe ""
         }
 
-        createInstance(riskState = LOW_RISK, lastTimeDiagnosisKeysFetched = date).apply {
+        createInstance(riskState = LOW_RISK, lastExposureDetectionTime = date).apply {
             getTimeFetched(context)
             verify { context.getString(eq(R.string.risk_card_body_time_fetched), any()) }
         }
 
-        createInstance(riskState = INCREASED_RISK, lastTimeDiagnosisKeysFetched = null).apply {
+        createInstance(riskState = INCREASED_RISK, lastExposureDetectionTime = null).apply {
             getTimeFetched(context)
             verify { context.getString(R.string.risk_card_body_not_yet_fetched) }
         }
 
-        createInstance(riskState = CALCULATION_FAILED, lastTimeDiagnosisKeysFetched = null).apply {
+        createInstance(riskState = CALCULATION_FAILED, lastExposureDetectionTime = null).apply {
             getTimeFetched(context) shouldBe ""
         }
 
-        createInstance(riskState = LOW_RISK, lastTimeDiagnosisKeysFetched = null).apply {
+        createInstance(riskState = LOW_RISK, lastExposureDetectionTime = null).apply {
             getTimeFetched(context)
             verify { context.getString(R.string.risk_card_body_not_yet_fetched) }
         }
-- 
GitLab