diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt index 82936e52b38862e1288652fe250952d4e5669375..87d675328c7d39af0f306e2d81860789efe4b094 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt @@ -8,6 +8,7 @@ import de.rki.coronawarnapp.appconfig.download.AppConfigHttpCache import de.rki.coronawarnapp.appconfig.mapping.CWAConfigMapper import de.rki.coronawarnapp.appconfig.mapping.DownloadConfigMapper import de.rki.coronawarnapp.appconfig.mapping.ExposureDetectionConfigMapper +import de.rki.coronawarnapp.appconfig.mapping.ExposureWindowRiskCalculationConfigMapper import de.rki.coronawarnapp.appconfig.mapping.RiskCalculationConfigMapper import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl @@ -61,17 +62,24 @@ class AppConfigModule { } @Provides - fun cwaMapper(mapper: CWAConfigMapper): CWAConfig.Mapper = mapper + fun cwaMapper(mapper: CWAConfigMapper): + CWAConfig.Mapper = mapper @Provides - fun downloadMapper(mapper: DownloadConfigMapper): KeyDownloadConfig.Mapper = mapper + fun downloadMapper(mapper: DownloadConfigMapper): + KeyDownloadConfig.Mapper = mapper @Provides - fun exposurMapper(mapper: ExposureDetectionConfigMapper): ExposureDetectionConfig.Mapper = - mapper + fun exposurMapper(mapper: ExposureDetectionConfigMapper): + ExposureDetectionConfig.Mapper = mapper @Provides - fun riskMapper(mapper: RiskCalculationConfigMapper): RiskCalculationConfig.Mapper = mapper + fun riskMapper(mapper: RiskCalculationConfigMapper): + RiskCalculationConfig.Mapper = mapper + + @Provides + fun windowRiskMapper(mapper: ExposureWindowRiskCalculationConfigMapper): + ExposureWindowRiskCalculationConfig.Mapper = mapper companion object { private val HTTP_TIMEOUT_APPCONFIG = Duration.standardSeconds(10) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureWindowRiskCalculationConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureWindowRiskCalculationConfig.kt new file mode 100644 index 0000000000000000000000000000000000000000..80eadd589d6bf68fc19648513195ab5e36841036 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureWindowRiskCalculationConfig.kt @@ -0,0 +1,20 @@ +package de.rki.coronawarnapp.appconfig + +import de.rki.coronawarnapp.server.protocols.internal.v2.AppConfigAndroid +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass + +interface ExposureWindowRiskCalculationConfig { + val minutesAtAttenuationFilters: List<RiskCalculationParametersOuterClass.MinutesAtAttenuationFilter> + val minutesAtAttenuationWeights: List<RiskCalculationParametersOuterClass.MinutesAtAttenuationWeight> + val transmissionRiskLevelEncoding: RiskCalculationParametersOuterClass.TransmissionRiskLevelEncoding + val transmissionRiskLevelFilters: List<RiskCalculationParametersOuterClass.TrlFilter> + val transmissionRiskLevelMultiplier: Double + val normalizedTimePerExposureWindowToRiskLevelMapping: + List<RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping> + val normalizedTimePerDayToRiskLevelMappingList: + List<RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping> + + interface Mapper { + fun map(rawConfig: AppConfigAndroid.ApplicationConfigurationAndroid): ExposureWindowRiskCalculationConfig + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt index 9858ec812bfc0b74c37330b2d44704decd085a5d..1e9ccb2d404d3c6f0217e8f3b44fc33bbda68329 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt @@ -2,6 +2,7 @@ package de.rki.coronawarnapp.appconfig.mapping import de.rki.coronawarnapp.appconfig.CWAConfig import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig +import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig import de.rki.coronawarnapp.appconfig.KeyDownloadConfig import de.rki.coronawarnapp.appconfig.RiskCalculationConfig import de.rki.coronawarnapp.server.protocols.internal.AppConfig @@ -10,7 +11,8 @@ interface ConfigMapping : CWAConfig, KeyDownloadConfig, ExposureDetectionConfig, - RiskCalculationConfig { + RiskCalculationConfig, + ExposureWindowRiskCalculationConfig { @Deprecated("Try to access a more specific config type, avoid the RAW variant.") val rawConfig: AppConfig.ApplicationConfiguration diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt index 8449b81b7cf3c5e996dc8121f97de585bc0ef693..41988550a2cef0482119b04c1d2bbda3c5463c2c 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt @@ -3,9 +3,11 @@ package de.rki.coronawarnapp.appconfig.mapping import dagger.Reusable import de.rki.coronawarnapp.appconfig.CWAConfig import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig +import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig import de.rki.coronawarnapp.appconfig.KeyDownloadConfig import de.rki.coronawarnapp.appconfig.RiskCalculationConfig import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import de.rki.coronawarnapp.server.protocols.internal.v2.AppConfigAndroid import timber.log.Timber import javax.inject.Inject @@ -14,17 +16,24 @@ class ConfigParser @Inject constructor( private val cwaConfigMapper: CWAConfig.Mapper, private val keyDownloadConfigMapper: KeyDownloadConfig.Mapper, private val exposureDetectionConfigMapper: ExposureDetectionConfig.Mapper, - private val riskCalculationConfigMapper: RiskCalculationConfig.Mapper + private val riskCalculationConfigMapper: RiskCalculationConfig.Mapper, + private val exposureWindowRiskCalculationConfigMapper: ExposureWindowRiskCalculationConfig.Mapper ) { fun parse(configBytes: ByteArray): ConfigMapping = try { + // TODO replace with actual v2 config + val dummyConfig = AppConfigAndroid + .ApplicationConfigurationAndroid + .newBuilder() + .build() parseRawArray(configBytes).let { DefaultConfigMapping( rawConfig = it, cwaConfig = cwaConfigMapper.map(it), keyDownloadConfig = keyDownloadConfigMapper.map(it), exposureDetectionConfig = exposureDetectionConfigMapper.map(it), - riskCalculationConfig = riskCalculationConfigMapper.map(it) + riskCalculationConfig = riskCalculationConfigMapper.map(it), + exposureWindowRiskCalculationConfig = exposureWindowRiskCalculationConfigMapper.map(dummyConfig) ) } } catch (e: Exception) { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt index 783385ddfdd3e7c06198eaff8c0ef5ba5a2961ff..78bacc12c694bcbb9cb409e2a6ed590ed1e6120d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt @@ -2,6 +2,7 @@ package de.rki.coronawarnapp.appconfig.mapping import de.rki.coronawarnapp.appconfig.CWAConfig import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig +import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig import de.rki.coronawarnapp.appconfig.KeyDownloadConfig import de.rki.coronawarnapp.appconfig.RiskCalculationConfig import de.rki.coronawarnapp.server.protocols.internal.AppConfig @@ -11,9 +12,11 @@ data class DefaultConfigMapping( val cwaConfig: CWAConfig, val keyDownloadConfig: KeyDownloadConfig, val exposureDetectionConfig: ExposureDetectionConfig, - val riskCalculationConfig: RiskCalculationConfig + val riskCalculationConfig: RiskCalculationConfig, + val exposureWindowRiskCalculationConfig: ExposureWindowRiskCalculationConfig ) : ConfigMapping, CWAConfig by cwaConfig, KeyDownloadConfig by keyDownloadConfig, ExposureDetectionConfig by exposureDetectionConfig, - RiskCalculationConfig by riskCalculationConfig + RiskCalculationConfig by riskCalculationConfig, + ExposureWindowRiskCalculationConfig by exposureWindowRiskCalculationConfig diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureWindowRiskCalculationConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureWindowRiskCalculationConfigMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..e64fdc2bad115bfb34b9812a032badcdb69c3799 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureWindowRiskCalculationConfigMapper.kt @@ -0,0 +1,44 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import dagger.Reusable +import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig +import de.rki.coronawarnapp.server.protocols.internal.v2.AppConfigAndroid +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass +import javax.inject.Inject + +@Reusable +class ExposureWindowRiskCalculationConfigMapper @Inject constructor() : + ExposureWindowRiskCalculationConfig.Mapper { + + override fun map(rawConfig: AppConfigAndroid.ApplicationConfigurationAndroid): ExposureWindowRiskCalculationConfig { + val riskCalculationParameters = rawConfig.riskCalculationParameters + return ExposureWindowRiskCalculationContainer( + minutesAtAttenuationFilters = riskCalculationParameters + .minutesAtAttenuationFiltersList, + minutesAtAttenuationWeights = riskCalculationParameters + .minutesAtAttenuationWeightsList, + transmissionRiskLevelEncoding = riskCalculationParameters + .trlEncoding, + transmissionRiskLevelFilters = riskCalculationParameters + .trlFiltersList, + transmissionRiskLevelMultiplier = riskCalculationParameters + .transmissionRiskLevelMultiplier, + normalizedTimePerExposureWindowToRiskLevelMapping = riskCalculationParameters + .normalizedTimePerEWToRiskLevelMappingList, + normalizedTimePerDayToRiskLevelMappingList = riskCalculationParameters + .normalizedTimePerDayToRiskLevelMappingList + ) + } + + data class ExposureWindowRiskCalculationContainer( + override val minutesAtAttenuationFilters: List<RiskCalculationParametersOuterClass.MinutesAtAttenuationFilter>, + override val minutesAtAttenuationWeights: List<RiskCalculationParametersOuterClass.MinutesAtAttenuationWeight>, + override val transmissionRiskLevelEncoding: RiskCalculationParametersOuterClass.TransmissionRiskLevelEncoding, + override val transmissionRiskLevelFilters: List<RiskCalculationParametersOuterClass.TrlFilter>, + override val transmissionRiskLevelMultiplier: Double, + override val normalizedTimePerExposureWindowToRiskLevelMapping: + List<RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping>, + override val normalizedTimePerDayToRiskLevelMappingList: + List<RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping> + ) : ExposureWindowRiskCalculationConfig +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt index 4df5b3228daba8b66f098621e61dc89a8ff73ec1..e0fd8a80dae4a9ba7f5e9e93647e24686b105bfc 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt @@ -1,19 +1,32 @@ package de.rki.coronawarnapp.risk +import android.text.TextUtils import androidx.annotation.VisibleForTesting import androidx.core.app.NotificationCompat import com.google.android.gms.nearby.exposurenotification.ExposureSummary +import com.google.android.gms.nearby.exposurenotification.ExposureWindow +import com.google.android.gms.nearby.exposurenotification.Infectiousness +import com.google.android.gms.nearby.exposurenotification.ReportType import de.rki.coronawarnapp.CoronaWarnApplication import de.rki.coronawarnapp.R import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.ConfigData +import de.rki.coronawarnapp.appconfig.download.ApplicationConfigurationInvalidException import de.rki.coronawarnapp.exception.RiskLevelCalculationException import de.rki.coronawarnapp.notification.NotificationHelper import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_INITIAL import de.rki.coronawarnapp.risk.RiskLevel.UNKNOWN_RISK_OUTDATED_RESULTS +import de.rki.coronawarnapp.risk.result.AggregatedRiskPerDateResult +import de.rki.coronawarnapp.risk.result.AggregatedRiskResult +import de.rki.coronawarnapp.risk.result.RiskResult import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass.AttenuationDuration +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.RiskLevelRepository import de.rki.coronawarnapp.util.TimeAndDateExtensions.millisecondsToHours +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.runBlocking +import org.joda.time.Instant import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -24,6 +37,17 @@ class DefaultRiskLevels @Inject constructor( private val appConfigProvider: AppConfigProvider ) : RiskLevels { + private var appConfig: ConfigData + + init { + runBlocking { + appConfig = appConfigProvider.getAppConfig() + } + + appConfigProvider.currentConfig + .onEach { if (it != null) appConfig = it } + } + override fun updateRepository(riskLevel: RiskLevel, time: Long) { val rollbackItems = mutableListOf<RollbackItem>() try { @@ -207,14 +231,311 @@ class DefaultRiskLevels @Inject constructor( ) } if (lastCalculatedScore.raw == RiskLevelConstants.INCREASED_RISK && - riskLevel.raw == RiskLevelConstants.LOW_LEVEL_RISK) { + riskLevel.raw == RiskLevelConstants.LOW_LEVEL_RISK + ) { LocalData.isUserToBeNotifiedOfLoweredRiskLevel = true } RiskLevelRepository.setRiskLevelScore(riskLevel) } + private fun dropDueToMinutesAtAttenuation( + exposureWindow: ExposureWindow, + attenuationFilters: List<RiskCalculationParametersOuterClass.MinutesAtAttenuationFilter> + ) = + attenuationFilters.any { attenuationFilter -> + // Get total seconds at attenuation in exposure window + val secondsAtAttenuation = exposureWindow.scanInstances + .filter { attenuationFilter.attenuationRange.inRange(it.typicalAttenuationDb) } + .fold(0) { acc, scanInstance -> acc + scanInstance.secondsSinceLastScan } + + val minutesAtAttenuation = secondsAtAttenuation / 60 + return attenuationFilter.dropIfMinutesInRange.inRange(minutesAtAttenuation) + } + + private fun determineTransmissionRiskLevel( + exposureWindow: ExposureWindow, + transmissionRiskLevelEncoding: RiskCalculationParametersOuterClass.TransmissionRiskLevelEncoding + ): Int { + + val reportTypeOffset = when (exposureWindow.reportType) { + ReportType.RECURSIVE -> transmissionRiskLevelEncoding + .reportTypeOffsetRecursive + ReportType.SELF_REPORT -> transmissionRiskLevelEncoding + .reportTypeOffsetSelfReport + ReportType.CONFIRMED_CLINICAL_DIAGNOSIS -> transmissionRiskLevelEncoding + .reportTypeOffsetConfirmedClinicalDiagnosis + ReportType.CONFIRMED_TEST -> transmissionRiskLevelEncoding + .reportTypeOffsetConfirmedTest + else -> throw UnknownReportTypeException() + } + + val infectiousnessOffset = when (exposureWindow.infectiousness) { + Infectiousness.HIGH -> transmissionRiskLevelEncoding + .infectiousnessOffsetHigh + else -> transmissionRiskLevelEncoding + .infectiousnessOffsetStandard + } + + return reportTypeOffset + infectiousnessOffset + } + + private fun dropDueToTransmissionRiskLevel( + transmissionRiskLevel: Int, + transmissionRiskLevelFilters: List<RiskCalculationParametersOuterClass.TrlFilter> + ) = + transmissionRiskLevelFilters.any { + it.dropIfTrlInRange.inRange(transmissionRiskLevel) + } + + private fun determineWeightedSeconds( + exposureWindow: ExposureWindow, + minutesAtAttenuationWeight: List<RiskCalculationParametersOuterClass.MinutesAtAttenuationWeight> + ): Double = + exposureWindow.scanInstances.fold(.0) { seconds, scanInstance -> + val weight = + minutesAtAttenuationWeight + .filter { it.attenuationRange.inRange(scanInstance.typicalAttenuationDb) } + .map { it.weight } + .firstOrNull() ?: .0 + return seconds + scanInstance.secondsSinceLastScan * weight + } + + private fun determineRiskLevel( + normalizedTime: Double, + timeToRiskLevelMapping: List<RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping> + ) = + timeToRiskLevelMapping + .filter { it.normalizedTimeRange.inRange(normalizedTime) } + .map { it.riskLevel } + .firstOrNull() + + override fun calculateRisk( + exposureWindow: ExposureWindow + ): RiskResult? { + if (dropDueToMinutesAtAttenuation(exposureWindow, appConfig.minutesAtAttenuationFilters)) { + Timber.d( + "%s dropped due to minutes at attenuation filter", + exposureWindow + ) + return null + } + + val transmissionRiskLevel = determineTransmissionRiskLevel( + exposureWindow, + appConfig.transmissionRiskLevelEncoding + ) + + if (dropDueToTransmissionRiskLevel(transmissionRiskLevel, appConfig.transmissionRiskLevelFilters)) { + Timber.d( + "%s dropped due to transmission risk level filter, level is %s", + exposureWindow, + transmissionRiskLevel + ) + return null + } + + val transmissionRiskValue = + transmissionRiskLevel * appConfig.transmissionRiskLevelMultiplier + + Timber.d( + "%s's transmissionRiskValue is: %s", + exposureWindow, + transmissionRiskValue + ) + + val weightedMinutes = determineWeightedSeconds( + exposureWindow, + appConfig.minutesAtAttenuationWeights + ) / 60 + + Timber.d( + "%s's weightedMinutes are: %s", + exposureWindow, + weightedMinutes + ) + + val normalizedTime = transmissionRiskValue * weightedMinutes + + Timber.d( + "%s's normalizedTime is: %s", + exposureWindow, + normalizedTime + ) + + val riskLevel = determineRiskLevel( + normalizedTime, + appConfig.normalizedTimePerExposureWindowToRiskLevelMapping + ) + + if (riskLevel == null) { + Timber.e("Exposure Window: $exposureWindow could not be mapped to a risk level") + throw NormalizedTimePerExposureWindowToRiskLevelMappingMissingException() + } + + Timber.d( + "%s's riskLevel is: %s", + exposureWindow, + riskLevel + ) + + return RiskResult(transmissionRiskLevel, normalizedTime, riskLevel) + } + + override fun aggregateResults( + exposureWindowsAndResult: Map<ExposureWindow, RiskResult> + ): AggregatedRiskResult { + val uniqueDatesMillisSinceEpoch = exposureWindowsAndResult.keys + .map { it.dateMillisSinceEpoch } + .toSet() + + Timber.d( + "uniqueDates: ${ + TextUtils.join(System.lineSeparator(), uniqueDatesMillisSinceEpoch) + }" + ) + val exposureHistory = uniqueDatesMillisSinceEpoch.map { + aggregateRiskPerDate(it, exposureWindowsAndResult) + } + + Timber.d("exposureHistory size: ${exposureHistory.size}") + + // 6. Determine `Total Risk` + val totalRiskLevel = + if (exposureHistory.any { + it.riskLevel == RiskCalculationParametersOuterClass + .NormalizedTimeToRiskLevelMapping + .RiskLevel + .HIGH + }) { + RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH + } else { + RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.LOW + } + + Timber.d("totalRiskLevel: ${totalRiskLevel.name} (${totalRiskLevel.ordinal})") + + // 7. Determine `Date of Most Recent Date with Low Risk` + val mostRecentDateWithLowRisk = mostRecentDateForRisk( + exposureHistory, + RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.LOW + ) + + Timber.d("mostRecentDateWithLowRisk: $mostRecentDateWithLowRisk") + + // 8. Determine `Date of Most Recent Date with High Risk` + val mostRecentDateWithHighRisk = mostRecentDateForRisk( + exposureHistory, + RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH + ) + + Timber.d("mostRecentDateWithHighRisk: $mostRecentDateWithHighRisk") + + // 9. Determine `Total Minimum Distinct Encounters With Low Risk` + val totalMinimumDistinctEncountersWithLowRisk = exposureHistory + .sumBy { it.minimumDistinctEncountersWithLowRisk } + + Timber.d("totalMinimumDistinctEncountersWithLowRisk: $totalMinimumDistinctEncountersWithLowRisk") + + // 10. Determine `Total Minimum Distinct Encounters With High Risk` + val totalMinimumDistinctEncountersWithHighRisk = exposureHistory + .sumBy { it.minimumDistinctEncountersWithHighRisk } + + Timber.d("totalMinimumDistinctEncountersWithHighRisk: $totalMinimumDistinctEncountersWithHighRisk") + + return AggregatedRiskResult( + totalRiskLevel, + totalMinimumDistinctEncountersWithLowRisk, + totalMinimumDistinctEncountersWithHighRisk, + mostRecentDateWithLowRisk, + mostRecentDateWithHighRisk + ) + } + + private fun mostRecentDateForRisk( + exposureHistory: List<AggregatedRiskPerDateResult>, + riskLevel: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel + ): Instant? = exposureHistory + .filter { it.riskLevel == riskLevel } + .maxOfOrNull { it.dateMillisSinceEpoch } + ?.let { Instant.ofEpochMilli(it) } + + private fun aggregateRiskPerDate( + dateMillisSinceEpoch: Long, + exposureWindowsAndResult: Map<ExposureWindow, RiskResult> + ): AggregatedRiskPerDateResult { + // 1. Group `Exposure Windows by Date` + val exposureWindowsAndResultForDate = exposureWindowsAndResult + .filter { it.key.dateMillisSinceEpoch == dateMillisSinceEpoch } + + // 2. Determine `Normalized Time per Date` + val normalizedTime = exposureWindowsAndResultForDate.values + .sumOf { it.normalizedTime } + + Timber.d("Aggregating result for date $dateMillisSinceEpoch - ${Instant.ofEpochMilli(dateMillisSinceEpoch)}") + + // 3. Determine `Risk Level per Date` + val riskLevel = try { + appConfig.normalizedTimePerDayToRiskLevelMappingList + .filter { it.normalizedTimeRange.inRange(normalizedTime) } + .map { it.riskLevel } + .first() + } catch (e: Exception) { + throw ApplicationConfigurationInvalidException( + e, + "Invalid config for normalizedTimePerDayToRiskLevelMapping" + ) + } + + Timber.d("riskLevel: ${riskLevel.name} (${riskLevel.ordinal})") + + // 4. Determine `Minimum Distinct Encounters With Low Risk per Date` + val minimumDistinctEncountersWithLowRisk = minimumDistinctEncountersForRisk( + exposureWindowsAndResultForDate, + RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.LOW + ) + + Timber.d("minimumDistinctEncountersWithLowRisk: $minimumDistinctEncountersWithLowRisk") + + // 5. Determine `Minimum Distinct Encounters With High Risk per Date` + val minimumDistinctEncountersWithHighRisk = minimumDistinctEncountersForRisk( + exposureWindowsAndResultForDate, + RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel.HIGH + ) + + Timber.d("minimumDistinctEncountersWithHighRisk: $minimumDistinctEncountersWithHighRisk") + + return AggregatedRiskPerDateResult( + dateMillisSinceEpoch, + riskLevel, + minimumDistinctEncountersWithLowRisk, + minimumDistinctEncountersWithHighRisk + ) + } + + private fun minimumDistinctEncountersForRisk( + exposureWindowsAndResultForDate: Map<ExposureWindow, RiskResult>, + riskLevel: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel + ): Int = + exposureWindowsAndResultForDate + .filter { it.value.riskLevel == riskLevel } + .map { "${it.value.transmissionRiskLevel}_${it.key.calibrationConfidence}" } + .distinct() + .size + companion object { private val TAG = DefaultRiskLevels::class.java.simpleName private const val DECIMAL_MULTIPLIER = 100 + + class NormalizedTimePerExposureWindowToRiskLevelMappingMissingException : Exception() + class UnknownReportTypeException : Exception() + + private fun <T : Number> RiskCalculationParametersOuterClass.Range.inRange(value: T): Boolean = + when { + minExclusive && value.toDouble() <= min -> false + !minExclusive && value.toDouble() < min -> false + maxExclusive && value.toDouble() >= max -> false + !maxExclusive && value.toDouble() > max -> false + else -> true + } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt index 407fef631dd901e8995d64cff3047acc22beb5c6..29ab44cbd70cb8e8be0d268931571af9954c6ec5 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt @@ -124,18 +124,18 @@ class RiskLevelTask @Inject constructor( } private suspend fun backgroundJobsEnabled() = - backgroundModeStatus.isAutoModeEnabled.first().also { - if (it) { - Timber.tag(TAG) - .v("diagnosis keys outdated and active tracing time is above threshold") - Timber.tag(TAG) - .v("manual mode not active (background jobs enabled)") - } else { - Timber.tag(TAG) - .v("diagnosis keys outdated and active tracing time is above threshold") - Timber.tag(TAG).v("manual mode active (background jobs disabled)") - } + backgroundModeStatus.isAutoModeEnabled.first().also { + if (it) { + Timber.tag(TAG) + .v("diagnosis keys outdated and active tracing time is above threshold") + Timber.tag(TAG) + .v("manual mode not active (background jobs enabled)") + } else { + Timber.tag(TAG) + .v("diagnosis keys outdated and active tracing time is above threshold") + Timber.tag(TAG).v("manual mode active (background jobs disabled)") } + } override suspend fun cancel() { Timber.w("cancel() called.") diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt index b8cd2f00c6b4ea61636c4593b03d43b65520078d..28cdbf181ce883ce79a66412d4f0c4271a3f1cd0 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevels.kt @@ -1,6 +1,9 @@ package de.rki.coronawarnapp.risk import com.google.android.gms.nearby.exposurenotification.ExposureSummary +import com.google.android.gms.nearby.exposurenotification.ExposureWindow +import de.rki.coronawarnapp.risk.result.AggregatedRiskResult +import de.rki.coronawarnapp.risk.result.RiskResult import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass interface RiskLevels { @@ -22,8 +25,17 @@ interface RiskLevels { time: Long ) + @Deprecated("Switch to new calculation with Exposure Window") fun calculateRiskScore( attenuationParameters: AttenuationDurationOuterClass.AttenuationDuration, exposureSummary: ExposureSummary ): Double + + fun calculateRisk( + exposureWindow: ExposureWindow + ): RiskResult? + + fun aggregateResults( + exposureWindowsAndResult: Map<ExposureWindow, RiskResult> + ): AggregatedRiskResult } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskPerDateResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskPerDateResult.kt new file mode 100644 index 0000000000000000000000000000000000000000..99c140888f23c365690f3671430e015cf966d96b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskPerDateResult.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.risk.result + +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass + +data class AggregatedRiskPerDateResult( + val dateMillisSinceEpoch: Long, + val riskLevel: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel, + val minimumDistinctEncountersWithLowRisk: Int, + val minimumDistinctEncountersWithHighRisk: Int +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskResult.kt new file mode 100644 index 0000000000000000000000000000000000000000..17e48ce7644a01996839269dce8988141d484efd --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskResult.kt @@ -0,0 +1,12 @@ +package de.rki.coronawarnapp.risk.result + +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass +import org.joda.time.Instant + +data class AggregatedRiskResult( + val totalRiskLevel: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel, + val totalMinimumDistinctEncountersWithLowRisk: Int, + val totalMinimumDistinctEncountersWithHighRisk: Int, + val mostRecentDateWithLowRisk: Instant?, + val mostRecentDateWithHighRisk: Instant? +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/RiskResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/RiskResult.kt new file mode 100644 index 0000000000000000000000000000000000000000..e2b5c582b347c962ea92e47917e76b620b6a9143 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/RiskResult.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.risk.result + +import de.rki.coronawarnapp.server.protocols.internal.v2.RiskCalculationParametersOuterClass + +// TODO("Adjust Types") +data class RiskResult( + val transmissionRiskLevel: Int, + val normalizedTime: Double, + val riskLevel: RiskCalculationParametersOuterClass.NormalizedTimeToRiskLevelMapping.RiskLevel +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt index 18ee07f72f83cfdbfccae776f6df5edc21a23083..55e68f895f3defd027fe002af7aa113dc4b28aa7 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt @@ -2,6 +2,7 @@ package de.rki.coronawarnapp.appconfig.mapping import de.rki.coronawarnapp.appconfig.CWAConfig import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig +import de.rki.coronawarnapp.appconfig.ExposureWindowRiskCalculationConfig import de.rki.coronawarnapp.appconfig.KeyDownloadConfig import de.rki.coronawarnapp.appconfig.RiskCalculationConfig import io.mockk.MockKAnnotations @@ -21,6 +22,7 @@ class ConfigParserTest : BaseTest() { @MockK lateinit var keyDownloadConfigMapper: KeyDownloadConfig.Mapper @MockK lateinit var exposureDetectionConfigMapper: ExposureDetectionConfig.Mapper @MockK lateinit var riskCalculationConfigMapper: RiskCalculationConfig.Mapper + @MockK lateinit var exposureWindowRiskCalculationConfigMapper: ExposureWindowRiskCalculationConfig.Mapper @BeforeEach fun setup() { @@ -30,6 +32,7 @@ class ConfigParserTest : BaseTest() { every { keyDownloadConfigMapper.map(any()) } returns mockk() every { exposureDetectionConfigMapper.map(any()) } returns mockk() every { riskCalculationConfigMapper.map(any()) } returns mockk() + every { exposureWindowRiskCalculationConfigMapper.map(any()) } returns mockk() } @AfterEach @@ -41,7 +44,8 @@ class ConfigParserTest : BaseTest() { cwaConfigMapper = cwaConfigMapper, keyDownloadConfigMapper = keyDownloadConfigMapper, exposureDetectionConfigMapper = exposureDetectionConfigMapper, - riskCalculationConfigMapper = riskCalculationConfigMapper + riskCalculationConfigMapper = riskCalculationConfigMapper, + exposureWindowRiskCalculationConfigMapper = exposureWindowRiskCalculationConfigMapper ) @Test @@ -53,6 +57,7 @@ class ConfigParserTest : BaseTest() { keyDownloadConfigMapper.map(any()) exposureDetectionConfigMapper.map(any()) riskCalculationConfigMapper.map(any()) + exposureWindowRiskCalculationConfigMapper.map(any()) } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelsTest.kt index 845722a2c23174f01becc23d183dc78053e30226..746c8acf70be3dedb4969ce5a0611bd7bbd09529 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/risk/RiskLevelsTest.kt @@ -5,7 +5,10 @@ import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every import io.mockk.impl.annotations.MockK +import io.mockk.mockk import junit.framework.TestCase.assertEquals import org.junit.Before import org.junit.Test @@ -19,6 +22,10 @@ class RiskLevelsTest : BaseTest() { @Before fun setUp() { MockKAnnotations.init(this) + + coEvery { appConfigProvider.getAppConfig() } returns mockk() + every { appConfigProvider.currentConfig } returns mockk() + riskLevels = DefaultRiskLevels(appConfigProvider) }