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