From 8b86e852686427ea35301f278c24b0140f15f551 Mon Sep 17 00:00:00 2001
From: Mohamed <mohamed.metwalli@sap.com>
Date: Tue, 23 Feb 2021 22:14:19 +0100
Subject: [PATCH] Collect test result data (EXPOSUREAPP-4820) (#2372)

* Un-comment TestDonor

* Rename to TestResultDonor

* Implement basic setup

* Handle donation conditions

* Collect test result metadata after user consent

* Move test-metadata to ViewModel and save risk level

* Use RiskLevelStorage

* Map RiskLevel according to specs

* Map some metadata

* klint

* formatting

* Save time at pending result received

* Refactoring

* Add unit tests

* lint

* Calculate hoursDifference based on test type

* Calculate hoursSinceHighRiskWarningAtTestRegistration

* Fix test

* Add more tests

* Calculate hoursSinceHighRiskWarningAtTestRegistration

* Rename

* Add comment

* Save test result at registration time

* Add logs

* Fix detekt

* Refactor testResultAtRegistration

* Add unit tests and refactor clearing the settings

* Add more unit tests

* Reformat

* Use timestamper

* Change flag

* Ignore flaky test

* Ignore flaky test

* Remove view model tests which cause sonar to fail all the time

* Clean on success

* Use config provided through request

* Create test result donor settings

* lint

* Revert test visibility

* Create a separate class for TestResult collection

Co-authored-by: Ralf Gehrer <ralfgehrer@users.noreply.github.com>
Co-authored-by: harambasicluka <64483219+harambasicluka@users.noreply.github.com>
---
 .../datadonation/analytics/AnalyticsModule.kt |   7 +-
 .../registeredtest/RegisteredTestDonor.kt     |  27 ---
 .../registeredtest/TestResultDataCollector.kt |  31 +++
 .../modules/registeredtest/TestResultDonor.kt | 216 ++++++++++++++++++
 .../storage/TestResultDonorSettings.kt        |  92 ++++++++
 .../submission/SubmissionRepository.kt        |   2 +-
 .../scan/SubmissionQRCodeScanViewModel.kt     |  10 +-
 .../TestResultDataCollectorTest.kt            |  69 ++++++
 .../registeredtest/TestResultDonorTest.kt     | 182 +++++++++++++++
 .../scan/SubmissionQRCodeScanViewModelTest.kt |  25 +-
 10 files changed, 627 insertions(+), 34 deletions(-)
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/RegisteredTestDonor.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDataCollector.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDonor.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/storage/TestResultDonorSettings.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDataCollectorTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDonorTest.kt

diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt
index 3c74b3801..254d9b3d9 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/AnalyticsModule.kt
@@ -7,6 +7,7 @@ import dagger.multibindings.IntoSet
 import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule
 import de.rki.coronawarnapp.datadonation.analytics.modules.clientmetadata.ClientMetadataDonor
 import de.rki.coronawarnapp.datadonation.analytics.modules.exposureriskmetadata.ExposureRiskMetadataDonor
+import de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest.TestResultDonor
 import de.rki.coronawarnapp.datadonation.analytics.modules.usermetadata.UserMetadataDonor
 import de.rki.coronawarnapp.datadonation.analytics.server.DataDonationAnalyticsApiV1
 import de.rki.coronawarnapp.datadonation.analytics.storage.DefaultLastAnalyticsSubmissionLogger
@@ -49,9 +50,9 @@ class AnalyticsModule {
 //    @Provides
 //    fun keySubmission(module: KeySubmissionStateDonor): DonorModule = module
 //
-//    @IntoSet
-//    @Provides
-//    fun registeredTest(module: RegisteredTestDonor): DonorModule = module
+    @IntoSet
+    @Provides
+    fun registeredTest(module: TestResultDonor): DonorModule = module
 
     @IntoSet
     @Provides
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/RegisteredTestDonor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/RegisteredTestDonor.kt
deleted file mode 100644
index f7a9be8d2..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/RegisteredTestDonor.kt
+++ /dev/null
@@ -1,27 +0,0 @@
-package de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest
-
-import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule
-import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
-import javax.inject.Inject
-import javax.inject.Singleton
-
-@Singleton
-class RegisteredTestDonor @Inject constructor() : DonorModule {
-
-    override suspend fun beginDonation(request: DonorModule.Request): DonorModule.Contribution {
-        // TODO
-        return object : DonorModule.Contribution {
-            override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) {
-                // TODO
-            }
-
-            override suspend fun finishDonation(successful: Boolean) {
-                // TODO
-            }
-        }
-    }
-
-    override suspend fun deleteData() {
-        // TODO
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDataCollector.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDataCollector.kt
new file mode 100644
index 000000000..914bd7cc0
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDataCollector.kt
@@ -0,0 +1,31 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest
+
+import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings
+import de.rki.coronawarnapp.datadonation.analytics.storage.TestResultDonorSettings
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
+import de.rki.coronawarnapp.risk.tryLatestResultsWithDefaults
+import de.rki.coronawarnapp.util.formatter.TestResult
+import kotlinx.coroutines.flow.first
+import javax.inject.Inject
+
+class TestResultDataCollector @Inject constructor(
+    private val analyticsSettings: AnalyticsSettings,
+    private val testResultDonorSettings: TestResultDonorSettings,
+    private val riskLevelStorage: RiskLevelStorage,
+) {
+
+    /**
+     *  Collect Test result registration only after user has given a consent.
+     *  exclude any registered test result before giving a consent
+     */
+    suspend fun saveTestResultAnalyticsSettings(testResult: TestResult) {
+        if (analyticsSettings.analyticsEnabled.value) {
+            val lastRiskResult = riskLevelStorage
+                .latestAndLastSuccessful
+                .first()
+                .tryLatestResultsWithDefaults()
+                .lastCalculated
+            testResultDonorSettings.saveTestResultDonorDataAtRegistration(testResult, lastRiskResult)
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDonor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDonor.kt
new file mode 100644
index 000000000..c2e834442
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDonor.kt
@@ -0,0 +1,216 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest
+
+import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule
+import de.rki.coronawarnapp.datadonation.analytics.storage.TestResultDonorSettings
+import de.rki.coronawarnapp.risk.RiskLevelSettings
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
+import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.formatter.TestResult
+import kotlinx.coroutines.flow.first
+import org.joda.time.Duration
+import org.joda.time.Instant
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class TestResultDonor @Inject constructor(
+    private val testResultDonorSettings: TestResultDonorSettings,
+    private val riskLevelSettings: RiskLevelSettings,
+    private val riskLevelStorage: RiskLevelStorage,
+    private val timeStamper: TimeStamper,
+) : DonorModule {
+
+    override suspend fun beginDonation(request: DonorModule.Request): DonorModule.Contribution {
+        val scannedAfterConsent = testResultDonorSettings.testScannedAfterConsent.value
+        if (!scannedAfterConsent) {
+            Timber.d("Skipping TestResultMetadata donation (testScannedAfterConsent=%s)", scannedAfterConsent)
+            return TestResultMetadataNoContribution
+        }
+
+        val timestampAtRegistration = LocalData.initialTestResultReceivedTimestamp()
+
+        if (timestampAtRegistration == null) {
+            Timber.d("Skipping TestResultMetadata donation timestampAtRegistration isn't found")
+            return TestResultMetadataNoContribution
+        }
+
+        val configHours = request
+            .currentConfig
+            .analytics
+            .hoursSinceTestRegistrationToSubmitTestResultMetadata
+
+        val registrationTime = Instant.ofEpochMilli(timestampAtRegistration)
+        val hoursSinceTestRegistrationTime = Duration(registrationTime, timeStamper.nowUTC).standardHours.toInt()
+        val isDiffHoursMoreThanConfigHoursForPendingTest = hoursSinceTestRegistrationTime >= configHours
+
+        val testResultAtRegistration =
+            testResultDonorSettings.testResultAtRegistration.value ?: return TestResultMetadataNoContribution
+
+        val daysSinceMostRecentDateAtRiskLevelAtTestRegistration =
+            Duration(
+                riskLevelSettings.lastChangeCheckedRiskLevelTimestamp,
+                registrationTime
+            ).standardDays.toInt()
+
+        val riskLevelAtRegistration = testResultDonorSettings.riskLevelAtTestRegistration.value
+
+        val hoursSinceHighRiskWarningAtTestRegistration =
+            if (riskLevelAtRegistration == PpaData.PPARiskLevel.RISK_LEVEL_LOW) {
+                DEFAULT_HOURS_SINCE_HIGH_RISK_WARNING
+            } else {
+                calculatedHoursSinceHighRiskWarning(registrationTime)
+            }
+
+        return when {
+            /**
+             * If test is pending and
+             * More than <hoursSinceTestRegistration> hours have passed since the test was registered,
+             * it is included in the next submission and removed afterwards.
+             * That means if the test result turns POS or NEG afterwards, this will not submitted
+             */
+            isDiffHoursMoreThanConfigHoursForPendingTest && testResultAtRegistration.isPending ->
+                pendingTestMetadataDonation(
+                    hoursSinceTestRegistrationTime,
+                    testResultAtRegistration,
+                    daysSinceMostRecentDateAtRiskLevelAtTestRegistration,
+                    hoursSinceHighRiskWarningAtTestRegistration
+                )
+
+            /**
+             * If the test result turns POSITIVE or NEGATIVE,
+             * it is included in the next submission. Afterwards,
+             * the collected metric data is removed.
+             */
+            testResultAtRegistration.isFinal ->
+                finalTestMetadataDonation(
+                    registrationTime,
+                    testResultAtRegistration,
+                    daysSinceMostRecentDateAtRiskLevelAtTestRegistration,
+                    hoursSinceHighRiskWarningAtTestRegistration
+                )
+            else -> {
+                Timber.d("Skipping Data donation")
+                TestResultMetadataNoContribution
+            }
+        }
+    }
+
+    override suspend fun deleteData() = cleanUp()
+
+    private fun cleanUp() {
+        Timber.d("Cleaning data")
+        testResultDonorSettings.clear()
+    }
+
+    private fun pendingTestMetadataDonation(
+        hoursSinceTestRegistrationTime: Int,
+        testResult: TestResult,
+        daysSinceMostRecentDateAtRiskLevelAtTestRegistration: Int,
+        hoursSinceHighRiskWarningAtTestRegistration: Int
+    ): DonorModule.Contribution {
+        val testResultMetaData = PpaData.PPATestResultMetadata.newBuilder()
+            .setHoursSinceTestRegistration(hoursSinceTestRegistrationTime)
+            .setHoursSinceHighRiskWarningAtTestRegistration(hoursSinceHighRiskWarningAtTestRegistration)
+            .setDaysSinceMostRecentDateAtRiskLevelAtTestRegistration(
+                daysSinceMostRecentDateAtRiskLevelAtTestRegistration
+            )
+            .setTestResult(testResult.toPPATestResult())
+            .setRiskLevelAtTestRegistration(testResultDonorSettings.riskLevelAtTestRegistration.value)
+            .build()
+
+        Timber.i("Pending test result metadata:%s", formString(testResultMetaData))
+        return TestResultMetadataContribution(testResultMetaData, ::cleanUp)
+    }
+
+    private fun finalTestMetadataDonation(
+        registrationTime: Instant,
+        testResult: TestResult,
+        daysSinceMostRecentDateAtRiskLevelAtTestRegistration: Int,
+        hoursSinceHighRiskWarningAtTestRegistration: Int
+    ): DonorModule.Contribution {
+        val finalTestResultReceivedAt = testResultDonorSettings.finalTestResultReceivedAt.value
+        val hoursSinceTestRegistrationTime = if (finalTestResultReceivedAt != null) {
+            Duration(registrationTime, finalTestResultReceivedAt).standardHours.toInt()
+        } else {
+            DEFAULT_HOURS_SINCE_TEST_REGISTRATION_TIME
+        }
+
+        val testResultMetaData = PpaData.PPATestResultMetadata.newBuilder()
+            .setHoursSinceTestRegistration(hoursSinceTestRegistrationTime)
+            .setHoursSinceHighRiskWarningAtTestRegistration(hoursSinceHighRiskWarningAtTestRegistration)
+            .setDaysSinceMostRecentDateAtRiskLevelAtTestRegistration(
+                daysSinceMostRecentDateAtRiskLevelAtTestRegistration
+            )
+            .setTestResult(testResult.toPPATestResult())
+            .setRiskLevelAtTestRegistration(testResultDonorSettings.riskLevelAtTestRegistration.value)
+            .build()
+
+        Timber.i("Final test result metadata:\n%s", formString(testResultMetaData))
+        return TestResultMetadataContribution(testResultMetaData, ::cleanUp)
+    }
+
+    private suspend fun calculatedHoursSinceHighRiskWarning(registrationTime: Instant): Int {
+        val highRiskResultCalculatedAt = riskLevelStorage
+            .latestAndLastSuccessful
+            .first()
+            .filter { it.isIncreasedRisk }
+            .minByOrNull { it.calculatedAt }
+            ?.calculatedAt ?: return DEFAULT_HOURS_SINCE_HIGH_RISK_WARNING
+
+        return Duration(
+            highRiskResultCalculatedAt,
+            registrationTime
+        ).standardHours.toInt()
+    }
+
+    private inline val TestResult.isFinal: Boolean get() = this in listOf(TestResult.POSITIVE, TestResult.NEGATIVE)
+    private inline val TestResult.isPending get() = this == TestResult.PENDING
+
+    private fun TestResult.toPPATestResult(): PpaData.PPATestResult {
+        return when (this) {
+            TestResult.PENDING -> PpaData.PPATestResult.TEST_RESULT_PENDING
+            TestResult.POSITIVE -> PpaData.PPATestResult.TEST_RESULT_POSITIVE
+            TestResult.NEGATIVE -> PpaData.PPATestResult.TEST_RESULT_NEGATIVE
+            else -> PpaData.PPATestResult.TEST_RESULT_UNKNOWN
+        }
+    }
+
+    private fun formString(testResultMetadata: PpaData.PPATestResultMetadata) =
+        with(testResultMetadata) {
+            """
+             testResult=$testResult
+             riskLevelAtTestRegistration=$riskLevelAtTestRegistration
+             hoursSinceTestRegistration=$hoursSinceTestRegistration
+             hoursSinceHighRiskWarningAtTestRegistration=$hoursSinceHighRiskWarningAtTestRegistration
+             daysSinceMostRecentDateAtRiskLevelAtTestRegistration=$daysSinceMostRecentDateAtRiskLevelAtTestRegistration
+            """.trimIndent()
+        }
+
+    data class TestResultMetadataContribution(
+        val testResultMetadata: PpaData.PPATestResultMetadata,
+        val onFinishDonation: suspend () -> Unit
+    ) : DonorModule.Contribution {
+        override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) {
+            protobufContainer.addTestResultMetadataSet(testResultMetadata)
+        }
+
+        override suspend fun finishDonation(successful: Boolean) {
+            if (successful) {
+                onFinishDonation()
+            } // else Keep data for next submission
+        }
+    }
+
+    object TestResultMetadataNoContribution : DonorModule.Contribution {
+        override suspend fun injectData(protobufContainer: PpaData.PPADataAndroid.Builder) = Unit
+        override suspend fun finishDonation(successful: Boolean) = Unit
+    }
+
+    companion object {
+        private const val DEFAULT_HOURS_SINCE_HIGH_RISK_WARNING = -1
+        private const val DEFAULT_HOURS_SINCE_TEST_REGISTRATION_TIME = 0
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/storage/TestResultDonorSettings.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/storage/TestResultDonorSettings.kt
new file mode 100644
index 000000000..84f9345ae
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/datadonation/analytics/storage/TestResultDonorSettings.kt
@@ -0,0 +1,92 @@
+package de.rki.coronawarnapp.datadonation.analytics.storage
+
+import android.content.Context
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.RiskState
+import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
+import de.rki.coronawarnapp.util.di.AppContext
+import de.rki.coronawarnapp.util.formatter.TestResult
+import de.rki.coronawarnapp.util.preferences.clearAndNotify
+import de.rki.coronawarnapp.util.preferences.createFlowPreference
+import org.joda.time.Instant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class TestResultDonorSettings @Inject constructor(
+    @AppContext private val context: Context
+) {
+    private val prefs by lazy {
+        context.getSharedPreferences("analytics_testResultDonor", Context.MODE_PRIVATE)
+    }
+
+    val testScannedAfterConsent = prefs.createFlowPreference(
+        key = PREFS_KEY_TEST_SCANNED_AFTER_CONSENT,
+        defaultValue = false
+    )
+
+    val riskLevelAtTestRegistration = prefs.createFlowPreference(
+        key = PREFS_KEY_RISK_LEVEL_AT_REGISTRATION,
+        reader = { key ->
+            PpaData.PPARiskLevel.forNumber(getInt(key, PpaData.PPARiskLevel.RISK_LEVEL_LOW.number))
+                ?: PpaData.PPARiskLevel.RISK_LEVEL_LOW
+        },
+        writer = { key, value ->
+            putInt(key, value.number)
+        }
+    )
+
+    val finalTestResultReceivedAt = prefs.createFlowPreference(
+        key = PREFS_KEY_FINAL_TEST_RESULT_RECEIVED_AT,
+        reader = { key ->
+            getLong(key, 0L).let {
+                if (it != 0L) {
+                    Instant.ofEpochMilli(it)
+                } else null
+            }
+        },
+        writer = { key, value ->
+            putLong(key, value?.millis ?: 0L)
+        }
+    )
+
+    val testResultAtRegistration = prefs.createFlowPreference(
+        key = PREFS_KEY_TEST_RESULT_AT_REGISTRATION,
+        reader = { key ->
+            val value = getInt(key, -1)
+            if (value == -1) {
+                null
+            } else {
+                TestResult.fromInt(value)
+            }
+        },
+        writer = { key, result ->
+            putInt(key, result?.value ?: -1)
+        }
+    )
+
+    fun saveTestResultDonorDataAtRegistration(testResult: TestResult, lastRiskResult: RiskLevelResult) {
+        testScannedAfterConsent.update { true }
+        testResultAtRegistration.update { testResult }
+        if (testResult in listOf(TestResult.POSITIVE, TestResult.NEGATIVE)) {
+            finalTestResultReceivedAt.update { Instant.now() }
+        }
+
+        riskLevelAtTestRegistration.update { lastRiskResult.toMetadataRiskLevel() }
+    }
+
+    fun clear() = prefs.clearAndNotify()
+
+    private fun RiskLevelResult.toMetadataRiskLevel(): PpaData.PPARiskLevel =
+        when (riskState) {
+            RiskState.INCREASED_RISK -> PpaData.PPARiskLevel.RISK_LEVEL_HIGH
+            else -> PpaData.PPARiskLevel.RISK_LEVEL_LOW
+        }
+
+    companion object {
+        private const val PREFS_KEY_TEST_SCANNED_AFTER_CONSENT = "testResultDonor.testScannedAfterConsent"
+        private const val PREFS_KEY_TEST_RESULT_AT_REGISTRATION = "testResultDonor.testResultAtRegistration"
+        private const val PREFS_KEY_RISK_LEVEL_AT_REGISTRATION = "testResultDonor.riskLevelAtRegistration"
+        private const val PREFS_KEY_FINAL_TEST_RESULT_RECEIVED_AT = "testResultDonor.finalTestResultReceivedAt"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionRepository.kt
index 2c28c05e2..37dfa4e47 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionRepository.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionRepository.kt
@@ -85,7 +85,7 @@ class SubmissionRepository @Inject constructor(
             return
         }
 
-        if (LocalData.isAllowedToSubmitDiagnosisKeys() == true) {
+        if (LocalData.isAllowedToSubmitDiagnosisKeys()) {
             deviceUIStateFlowInternal.value = NetworkRequestWrapper.RequestSuccessful(DeviceUIState.PAIRED_POSITIVE)
             return
         }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt
index 5c5743d41..e4087abac 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt
@@ -1,9 +1,11 @@
 package de.rki.coronawarnapp.ui.submission.qrcode.scan
 
+import androidx.annotation.VisibleForTesting
 import androidx.lifecycle.MutableLiveData
 import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 import de.rki.coronawarnapp.bugreporting.censors.QRCodeCensor
+import de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest.TestResultDataCollector
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.TransactionException
 import de.rki.coronawarnapp.exception.http.CwaWebException
@@ -20,7 +22,8 @@ import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
 import timber.log.Timber
 
 class SubmissionQRCodeScanViewModel @AssistedInject constructor(
-    private val submissionRepository: SubmissionRepository
+    private val submissionRepository: SubmissionRepository,
+    private val testResultDataCollector: TestResultDataCollector
 ) :
     CWAViewModel() {
     val routeToScreen = SingleLiveEvent<SubmissionNavigationEvents>()
@@ -48,12 +51,15 @@ class SubmissionQRCodeScanViewModel @AssistedInject constructor(
         val testResult: TestResult? = null
     )
 
-    private fun doDeviceRegistration(scanResult: QRScanResult) = launch {
+    @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+    internal fun doDeviceRegistration(scanResult: QRScanResult) = launch {
         try {
             registrationState.postValue(RegistrationState(ApiRequestState.STARTED))
             val testResult = submissionRepository.asyncRegisterDeviceViaGUID(scanResult.guid!!)
             checkTestResult(testResult)
             registrationState.postValue(RegistrationState(ApiRequestState.SUCCESS, testResult))
+            // Order here is important. Save Analytics after SUCCESS
+            testResultDataCollector.saveTestResultAnalyticsSettings(testResult)
         } catch (err: CwaWebException) {
             registrationState.postValue(RegistrationState(ApiRequestState.FAILED))
             registrationError.postValue(err)
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDataCollectorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDataCollectorTest.kt
new file mode 100644
index 000000000..104a2d06d
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDataCollectorTest.kt
@@ -0,0 +1,69 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest
+
+import de.rki.coronawarnapp.datadonation.analytics.storage.AnalyticsSettings
+import de.rki.coronawarnapp.datadonation.analytics.storage.TestResultDonorSettings
+import de.rki.coronawarnapp.risk.RiskLevelResult
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
+import de.rki.coronawarnapp.util.formatter.TestResult
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.verify
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.runBlockingTest
+import org.joda.time.Instant
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+import testhelpers.BaseTest
+import testhelpers.preferences.mockFlowPreference
+
+class TestResultDataCollectorTest : BaseTest() {
+
+    @MockK lateinit var analyticsSettings: AnalyticsSettings
+    @MockK lateinit var testResultDonorSettings: TestResultDonorSettings
+    @MockK lateinit var riskLevelStorage: RiskLevelStorage
+
+    private lateinit var testResultDataCollector: TestResultDataCollector
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        testResultDataCollector = TestResultDataCollector(
+            analyticsSettings,
+            testResultDonorSettings,
+            riskLevelStorage
+        )
+    }
+
+    @Test
+    fun `saveTestResultAnalyticsSettings does not save anything when no user consent`() = runBlockingTest {
+        every { analyticsSettings.analyticsEnabled } returns mockFlowPreference(false)
+        testResultDataCollector.saveTestResultAnalyticsSettings(TestResult.POSITIVE)
+
+        verify(exactly = 0) {
+            testResultDonorSettings.saveTestResultDonorDataAtRegistration(any(), any())
+        }
+    }
+
+    @Test
+    fun `saveTestResultAnalyticsSettings saves data when user gave consent`() = runBlockingTest {
+        every { analyticsSettings.analyticsEnabled } returns mockFlowPreference(true)
+
+        val mockRiskLevelResult = mockk<RiskLevelResult>().apply {
+            every { calculatedAt } returns Instant.now()
+            every { wasSuccessfullyCalculated } returns true
+        }
+        every { riskLevelStorage.latestAndLastSuccessful } returns flowOf(listOf(mockRiskLevelResult))
+        every { testResultDonorSettings.saveTestResultDonorDataAtRegistration(any(), any()) } just Runs
+        testResultDataCollector.saveTestResultAnalyticsSettings(TestResult.POSITIVE)
+
+        verify(exactly = 1) {
+            testResultDonorSettings.saveTestResultDonorDataAtRegistration(any(), any())
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDonorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDonorTest.kt
new file mode 100644
index 000000000..193b4bf5f
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/datadonation/analytics/modules/registeredtest/TestResultDonorTest.kt
@@ -0,0 +1,182 @@
+package de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest
+
+import de.rki.coronawarnapp.appconfig.AnalyticsConfig
+import de.rki.coronawarnapp.appconfig.ConfigData
+import de.rki.coronawarnapp.datadonation.analytics.modules.DonorModule
+import de.rki.coronawarnapp.datadonation.analytics.storage.TestResultDonorSettings
+import de.rki.coronawarnapp.risk.RiskLevelSettings
+import de.rki.coronawarnapp.risk.storage.RiskLevelStorage
+import de.rki.coronawarnapp.server.protocols.internal.ppdd.PpaData
+import de.rki.coronawarnapp.storage.LocalData
+import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.formatter.TestResult
+import io.kotest.matchers.shouldBe
+import io.kotest.matchers.types.shouldBeInstanceOf
+import io.mockk.MockKAnnotations
+import io.mockk.Runs
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+import io.mockk.unmockkAll
+import io.mockk.verify
+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 testhelpers.preferences.mockFlowPreference
+import java.util.concurrent.TimeUnit
+
+class TestResultDonorTest : BaseTest() {
+    @MockK lateinit var testResultDonorSettings: TestResultDonorSettings
+    @MockK lateinit var riskLevelSettings: RiskLevelSettings
+    @MockK lateinit var riskLevelStorage: RiskLevelStorage
+    @MockK lateinit var timeStamper: TimeStamper
+
+    private lateinit var testResultDonor: TestResultDonor
+
+    @BeforeEach
+    fun setUp() {
+        MockKAnnotations.init(this, true)
+        mockkObject(LocalData)
+        every { timeStamper.nowUTC } returns Instant.now()
+        every { riskLevelSettings.lastChangeCheckedRiskLevelTimestamp } returns Instant.now()
+        every { testResultDonorSettings.riskLevelAtTestRegistration } returns
+            mockFlowPreference(PpaData.PPARiskLevel.RISK_LEVEL_LOW)
+        every { LocalData.initialTestResultReceivedTimestamp() } returns System.currentTimeMillis()
+
+        testResultDonor = TestResultDonor(
+            testResultDonorSettings,
+            riskLevelSettings,
+            riskLevelStorage,
+            timeStamper,
+        )
+    }
+
+    @AfterEach
+    fun tearDown() {
+        unmockkAll()
+    }
+
+    @Test
+    fun `No donation when user did not allow consent`() = runBlockingTest {
+        every { testResultDonorSettings.testScannedAfterConsent } returns mockFlowPreference(false)
+        testResultDonor.beginDonation(TestRequest) shouldBe TestResultDonor.TestResultMetadataNoContribution
+    }
+
+    @Test
+    fun `No donation when timestamp at registration is missing`() = runBlockingTest {
+        every { testResultDonorSettings.testScannedAfterConsent } returns mockFlowPreference(true)
+        every { LocalData.initialTestResultReceivedTimestamp() } returns null
+        testResultDonor.beginDonation(TestRequest) shouldBe TestResultDonor.TestResultMetadataNoContribution
+    }
+
+    @Test
+    fun `No donation when test result is INVALID`() = runBlockingTest {
+        every { testResultDonorSettings.testScannedAfterConsent } returns mockFlowPreference(true)
+        every { testResultDonorSettings.testResultAtRegistration } returns mockFlowPreference(TestResult.INVALID)
+        testResultDonor.beginDonation(TestRequest) shouldBe TestResultDonor.TestResultMetadataNoContribution
+    }
+
+    @Test
+    fun `No donation when test result is REDEEMED`() = runBlockingTest {
+        every { testResultDonorSettings.testScannedAfterConsent } returns mockFlowPreference(true)
+        every { testResultDonorSettings.testResultAtRegistration } returns mockFlowPreference(TestResult.REDEEMED)
+        testResultDonor.beginDonation(TestRequest) shouldBe TestResultDonor.TestResultMetadataNoContribution
+    }
+
+    @Test
+    fun `No donation when test result is PENDING and hours isn't greater or equal to config hours`() {
+        runBlockingTest {
+            every { testResultDonorSettings.testScannedAfterConsent } returns mockFlowPreference(true)
+            every { testResultDonorSettings.testResultAtRegistration } returns mockFlowPreference(TestResult.PENDING)
+
+            testResultDonor.beginDonation(TestRequest) shouldBe TestResultDonor.TestResultMetadataNoContribution
+        }
+    }
+
+    @Test
+    fun `Donation is collected when test result is PENDING and hours is greater or equal to config hours`() {
+        runBlockingTest {
+            every { testResultDonorSettings.testScannedAfterConsent } returns mockFlowPreference(true)
+            every { testResultDonorSettings.testResultAtRegistration } returns mockFlowPreference(TestResult.PENDING)
+
+            val timeDayBefore = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(1)
+            every { LocalData.initialTestResultReceivedTimestamp() } returns timeDayBefore
+            every { riskLevelSettings.lastChangeCheckedRiskLevelTimestamp } returns Instant.ofEpochMilli(timeDayBefore)
+
+            val donation = testResultDonor.beginDonation(TestRequest)
+            donation.shouldBeInstanceOf<TestResultDonor.TestResultMetadataContribution>()
+            with(donation.testResultMetadata) {
+                riskLevelAtTestRegistration shouldBe PpaData.PPARiskLevel.RISK_LEVEL_LOW
+                testResult shouldBe PpaData.PPATestResult.TEST_RESULT_PENDING
+                hoursSinceTestRegistration shouldBe 23
+                hoursSinceHighRiskWarningAtTestRegistration shouldBe -1
+                daysSinceMostRecentDateAtRiskLevelAtTestRegistration shouldBe 0
+            }
+        }
+    }
+
+    @Test
+    fun `Donation is collected when test result is POSITIVE`() {
+        runBlockingTest {
+            every { testResultDonorSettings.testScannedAfterConsent } returns mockFlowPreference(true)
+            every { testResultDonorSettings.testResultAtRegistration } returns mockFlowPreference(TestResult.POSITIVE)
+            every { testResultDonorSettings.finalTestResultReceivedAt } returns mockFlowPreference(Instant.now())
+
+            val donation = testResultDonor.beginDonation(TestRequest)
+            donation.shouldBeInstanceOf<TestResultDonor.TestResultMetadataContribution>()
+            with(donation.testResultMetadata) {
+                riskLevelAtTestRegistration shouldBe PpaData.PPARiskLevel.RISK_LEVEL_LOW
+                testResult shouldBe PpaData.PPATestResult.TEST_RESULT_POSITIVE
+                hoursSinceTestRegistration shouldBe 0
+                hoursSinceHighRiskWarningAtTestRegistration shouldBe -1
+                daysSinceMostRecentDateAtRiskLevelAtTestRegistration shouldBe 0
+            }
+        }
+    }
+
+    @Test
+    fun `Donation is collected when test result is NEGATIVE`() {
+        runBlockingTest {
+            every { testResultDonorSettings.testScannedAfterConsent } returns mockFlowPreference(true)
+            every { testResultDonorSettings.testResultAtRegistration } returns mockFlowPreference(TestResult.NEGATIVE)
+            every { testResultDonorSettings.finalTestResultReceivedAt } returns mockFlowPreference(Instant.now())
+
+            val donation = testResultDonor.beginDonation(TestRequest)
+            donation.shouldBeInstanceOf<TestResultDonor.TestResultMetadataContribution>()
+            with(donation.testResultMetadata) {
+                riskLevelAtTestRegistration shouldBe PpaData.PPARiskLevel.RISK_LEVEL_LOW
+                testResult shouldBe PpaData.PPATestResult.TEST_RESULT_NEGATIVE
+                hoursSinceTestRegistration shouldBe 0
+                hoursSinceHighRiskWarningAtTestRegistration shouldBe -1
+                daysSinceMostRecentDateAtRiskLevelAtTestRegistration shouldBe 0
+            }
+        }
+    }
+
+    @Test
+    fun deleteData() = runBlockingTest {
+        every { testResultDonorSettings.clear() } just Runs
+
+        testResultDonor.deleteData()
+
+        verify {
+            testResultDonorSettings.clear()
+        }
+    }
+
+    object TestRequest : DonorModule.Request {
+        override val currentConfig: ConfigData
+            get() = mockk<ConfigData>().apply {
+                every { analytics } returns
+                    mockk<AnalyticsConfig>().apply {
+                        every { hoursSinceTestRegistrationToSubmitTestResultMetadata } returns 20
+                    }
+            }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
index 9df500c21..b3d1b9bae 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
@@ -1,13 +1,19 @@
 package de.rki.coronawarnapp.ui.submission.qrcode.scan
 
 import de.rki.coronawarnapp.bugreporting.censors.QRCodeCensor
+import de.rki.coronawarnapp.datadonation.analytics.modules.registeredtest.TestResultDataCollector
 import de.rki.coronawarnapp.playbook.BackgroundNoise
+import de.rki.coronawarnapp.service.submission.QRScanResult
 import de.rki.coronawarnapp.submission.SubmissionRepository
 import de.rki.coronawarnapp.ui.submission.ScanStatus
+import de.rki.coronawarnapp.util.formatter.TestResult
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
+import io.mockk.coEvery
+import io.mockk.coVerify
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
 import io.mockk.mockkObject
 import org.junit.Assert
 import org.junit.jupiter.api.BeforeEach
@@ -21,6 +27,7 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() {
 
     @MockK lateinit var backgroundNoise: BackgroundNoise
     @MockK lateinit var submissionRepository: SubmissionRepository
+    @MockK lateinit var testResultDataCollector: TestResultDataCollector
 
     @BeforeEach
     fun setUp() {
@@ -30,7 +37,10 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() {
         every { BackgroundNoise.getInstance() } returns backgroundNoise
     }
 
-    private fun createViewModel() = SubmissionQRCodeScanViewModel(submissionRepository)
+    private fun createViewModel() = SubmissionQRCodeScanViewModel(
+        submissionRepository,
+        testResultDataCollector
+    )
 
     @Test
     fun scanStatusValid() {
@@ -53,4 +63,17 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() {
         viewModel.validateTestGUID("https://no-guid-here")
         viewModel.scanStatusValue.let { Assert.assertEquals(ScanStatus.INVALID, it.value) }
     }
+
+    @Test
+    fun `doDeviceRegistration calls TestResultDataCollector`() {
+        val viewModel = createViewModel()
+        val mockResult = mockk<QRScanResult>().apply {
+            every { guid } returns "guid"
+        }
+
+        coEvery { submissionRepository.asyncRegisterDeviceViaGUID(any()) } returns TestResult.POSITIVE
+        viewModel.doDeviceRegistration(mockResult)
+
+        coVerify { testResultDataCollector.saveTestResultAnalyticsSettings(any()) }
+    }
 }
-- 
GitLab