From d2f89e74997fffc9d0d47a96ad5ff5ae4513c1b0 Mon Sep 17 00:00:00 2001 From: Kolya Opahle <k.opahle@sap.com> Date: Tue, 10 Nov 2020 15:45:30 +0100 Subject: [PATCH] Determine Risk Level for Exposure Windows & Aggregation (EXPOSUREAPP-3537, EXPOSUREAPP-3518) (#1546) * Split and hide the protobuf config behind interfaces with individual mappers responsible for creating the desired formats. * Merge branch 'release/1.7.x' into feature/3455-more-frequent-riskscore-updates-configs # Conflicts: # Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt # Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RiskLevelTransaction.kt # Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RiskLevelTransactionTest.kt * Make the AppConfig observable. Provide the server time offset. Offer a lastUpdatedAt timestamp. Add an app config specific test screen. Clean up test screens a bit and move debug options out of API test options. * Fix test regression due to refactoring (moved code around). * Store the server timestamp and offset at retrieval. Switch to config storage via json to be able to store additional meta data fields (i.e. time). * KLint and Me have a hate relationship based on both mutual admiration. * Fix time offset parsing being locale dependent. * Fix broken unit tests. * Improve offset accuracy, move before unzipping. * Fix overly long livedata subscription to results (viewmodel scope vs observer scope) * Add mapping for the new protobuf configs + tests. * For cached (retrofit) response, we need to check the cacheResponse and its timestamps to determine an accurate time offset. * Exposure a boolean property to tell us when a fallback config is being used. * Hide the observable flow<ConfigData> behind a method that can automatically triggers refreshes. * Use a common mapper interface. * set old risklevelcalculation deprecated * Created skeleton for new risk calculation and aggregation * Implementing steps to aggregate results form exposure windows - wip * Address PR comments and KLints. * Fix refactoring regression. * ktlint * Added ExposureWindowRiskLevelConfig and ExposureWindowRiskLevelConfigMapper for new config api (not yet introduced) Signed-off-by: Kolya Opahle <k.opahle@sap.com> * Added first Implementation of exposure window based calculateRisk function Signed-off-by: Kolya Opahle <k.opahle@sap.com> * Added generics to Range.inRange Signed-off-by: Kolya Opahle <k.opahle@sap.com> * Added Ugly Hack to RiskLevelTransaction to allow for compilation during testing Signed-off-by: Kolya Opahle <k.opahle@sap.com> * Linting and injecting RiskLevelCalculation into TestRiskLevelCalculationFragmentCWAViewModel, currently wont build because ExposureWindowRiskLevelConfig has no Provider Signed-off-by: Kolya Opahle <k.opahle@sap.com> * Linting extravaganza Signed-off-by: Kolya Opahle <k.opahle@sap.com> * Lint Wars Episode VI: Return of the trailing Comma * Improve config unzipping code. * Add flag to forward exception thrown during HotDataFlow.kt initialization. * Don't specify a default context via singleton. * Move download and fallback logic into it's own class just responsible for sourcing the config: "AppConfigSource". "AppConfigProvider" is now only responsible for making it available. * Simplify current concepts for making the app config observable until we have a default configuration. * Improve app config test screen, delete options, better feedback. Show toast instead of crash on errors. * Fixed GSON serialization not encoding/decoding the byte array correctly. Added specific type adapters for instant and duration to get cleaner json. * Remove type adapters from base gson due to conflict with CalculationTrackerStorage. * We want to default to forced serialization of instant by our converters, instead of using the default serialization which will differ between Java8.Instant and JodaTime.Instant, to prevent future headaches there, register explicit converters by default, and overwrite them if necessary (currently only needed for CalculationTrackerStorage.kt). * Improve AppConfigServer code readability by moving code into extensions. * Fix merge conflicts * Added missing import to WorkerBinderTest * fixed unit tests * Removed auto formatting on unrelated files (revert + cherry pick in other commit) Signed-off-by: Kolya Opahle <k.opahle@sap.com> * Implementing steps to aggregate results form exposure windows * Renamed ExposureWindowRiskLevelConfig to ExposureWindowRiskCalculationConfig * adjusted & refactored Windows aggregation * removed example Values * satisfy lint * make Aggregation work with Instant now * Use long while calculation * Added normalizedTimePerDayToRiskLevelMappingList to AppConfig * normalizedTimePerDayToRiskLevelMappingList from AppConfig * satisfy lint * Get AppConfig on init and listen for updates * exposureData to aggregatedRiskPerDateResult * Corrected name in ConfigParserTest * use instant for specific aggregation logs * satisfy CI * satisfy detekt * exposure history exception & log adjustment * Fixed unittests for new config parser and risk levels Signed-off-by: Kolya Opahle <k.opahle@sap.com> * Added some logging to the calculateRisk function and removed the suspend qualifiers as AppConfig is fetched during init Signed-off-by: Kolya Opahle <k.opahle@sap.com> Co-authored-by: Matthias Urhahn <matthias.urhahn@sap.com> Co-authored-by: BMItter <Berndus@gmx.de> Co-authored-by: harambasicluka <64483219+harambasicluka@users.noreply.github.com> --- .../appconfig/AppConfigModule.kt | 18 +- .../ExposureWindowRiskCalculationConfig.kt | 20 ++ .../appconfig/mapping/ConfigMapping.kt | 4 +- .../appconfig/mapping/ConfigParser.kt | 13 +- .../appconfig/mapping/DefaultConfigMapping.kt | 7 +- ...posureWindowRiskCalculationConfigMapper.kt | 44 +++ .../coronawarnapp/risk/DefaultRiskLevels.kt | 323 +++++++++++++++++- .../rki/coronawarnapp/risk/RiskLevelTask.kt | 22 +- .../de/rki/coronawarnapp/risk/RiskLevels.kt | 12 + .../result/AggregatedRiskPerDateResult.kt | 10 + .../risk/result/AggregatedRiskResult.kt | 12 + .../coronawarnapp/risk/result/RiskResult.kt | 10 + .../appconfig/mapping/ConfigParserTest.kt | 7 +- .../rki/coronawarnapp/risk/RiskLevelsTest.kt | 7 + 14 files changed, 486 insertions(+), 23 deletions(-) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureWindowRiskCalculationConfig.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureWindowRiskCalculationConfigMapper.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskPerDateResult.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/AggregatedRiskResult.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/result/RiskResult.kt 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 82936e52b..87d675328 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 000000000..80eadd589 --- /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 9858ec812..1e9ccb2d4 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 8449b81b7..41988550a 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 783385ddf..78bacc12c 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 000000000..e64fdc2ba --- /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 4df5b3228..e0fd8a80d 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 407fef631..29ab44cbd 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 b8cd2f00c..28cdbf181 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 000000000..99c140888 --- /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 000000000..17e48ce76 --- /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 000000000..e2b5c582b --- /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 18ee07f72..55e68f895 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 845722a2c..746c8acf7 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) } -- GitLab