diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/internal/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/internal/ApplicationConfigurationInvalidException.kt index 15ec3692bca6ba188403a32a2e17de7a8dac0fea..f1c0cfe2ac1bec5eb1c4325e1a158ae00b80ced0 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/internal/ApplicationConfigurationInvalidException.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/internal/ApplicationConfigurationInvalidException.kt @@ -1,12 +1,12 @@ package de.rki.coronawarnapp.appconfig.internal import de.rki.coronawarnapp.exception.reporting.ErrorCodes -import de.rki.coronawarnapp.exception.reporting.ReportedException +import de.rki.coronawarnapp.util.security.InvalidSignatureException class ApplicationConfigurationInvalidException( cause: Exception? = null, - message: String? = null -) : ReportedException( + message: String +) : InvalidSignatureException( code = ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code, message = message, cause = cause diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/StatisticsModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/StatisticsModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..24db16888aa78f3f78d4d6811b908827509178e1 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/StatisticsModule.kt @@ -0,0 +1,70 @@ +package de.rki.coronawarnapp.statistics + +import android.content.Context +import dagger.Module +import dagger.Provides +import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient +import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl +import de.rki.coronawarnapp.statistics.source.StatisticsApiV1 +import de.rki.coronawarnapp.util.di.AppContext +import okhttp3.Cache +import okhttp3.OkHttpClient +import org.joda.time.Duration +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import java.io.File +import java.util.concurrent.TimeUnit +import javax.inject.Qualifier +import javax.inject.Singleton + +@Module +class StatisticsModule { + + @Singleton + @Provides + @Statistics + fun cacheDir( + @AppContext context: Context + ): File = File(context.cacheDir, "statistics") + + @Singleton + @Provides + @Statistics + fun httpCache( + @Statistics cacheDir: File + ): Cache = Cache(File(cacheDir, "cache_http"), DEFAULT_CACHE_SIZE) + + @Singleton + @Provides + fun api( + @DownloadCDNHttpClient client: OkHttpClient, + @DownloadCDNServerUrl url: String, + gsonConverterFactory: GsonConverterFactory, + @Statistics cache: Cache + ): StatisticsApiV1 { + val configHttpClient = client.newBuilder().apply { + cache(cache) + connectTimeout(HTTP_TIMEOUT.millis, TimeUnit.MILLISECONDS) + readTimeout(HTTP_TIMEOUT.millis, TimeUnit.MILLISECONDS) + writeTimeout(HTTP_TIMEOUT.millis, TimeUnit.MILLISECONDS) + callTimeout(HTTP_TIMEOUT.millis, TimeUnit.MILLISECONDS) + }.build() + + return Retrofit.Builder() + .client(configHttpClient) + .baseUrl(url) + .addConverterFactory(gsonConverterFactory) + .build() + .create(StatisticsApiV1::class.java) + } + + companion object { + private const val DEFAULT_CACHE_SIZE = 5 * 1024 * 1024L // 5MB + private val HTTP_TIMEOUT = Duration.standardSeconds(10) + } +} + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class Statistics diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/StatsItem.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/StatsItem.kt index bc55a2f769e0aa2b867f0df33482e13f54dc51be..5a8cd74e476f77263d71a8a3fd743b84ccfd43fa 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/StatsItem.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/StatsItem.kt @@ -1,30 +1,117 @@ package de.rki.coronawarnapp.statistics -import de.rki.coronawarnapp.server.protocols.internal.stats.KeyFigureCardOuterClass +import de.rki.coronawarnapp.server.protocols.internal.stats.KeyFigureCardOuterClass.KeyFigure import org.joda.time.Instant +import timber.log.Timber data class StatisticsData( - val items: List<StatsItem> + val items: List<StatsItem> = emptyList() ) { val isDataAvailable: Boolean = items.isNotEmpty() + + override fun toString(): String { + return "StatisticsData(cards=${items.map { it.cardType.name + " " + it.updatedAt }})" + } } -sealed class StatsItem(val cardId: Int) { +sealed class StatsItem(val cardType: Type) { abstract val updatedAt: Instant - abstract val keyFigures: List<KeyFigureCardOuterClass.KeyFigure> + abstract val keyFigures: List<KeyFigure> + + enum class Type(val id: Int) { + INFECTION(1), + INCIDENCE(2), + KEYSUBMISSION(3), + SEVEN_DAY_RVALUE(4) + } + + abstract fun requireValidity() } data class InfectionStats( override val updatedAt: Instant, - override val keyFigures: List<KeyFigureCardOuterClass.KeyFigure> -) : StatsItem(cardId = 1) + override val keyFigures: List<KeyFigure> +) : StatsItem(cardType = Type.INFECTION) { + + val newInfections: KeyFigure + get() = keyFigures.single { it.rank == KeyFigure.Rank.PRIMARY } + + val sevenDayAverage: KeyFigure + get() = keyFigures.single { it.rank == KeyFigure.Rank.SECONDARY } + + val total: KeyFigure + get() = keyFigures.single { it.rank == KeyFigure.Rank.TERTIARY } + + override fun requireValidity() { + require(keyFigures.size == 3) + requireNotNull(keyFigures.singleOrNull { it.rank == KeyFigure.Rank.PRIMARY }) { + Timber.w("InfectionStats is missing primary value") + } + requireNotNull(keyFigures.singleOrNull { it.rank == KeyFigure.Rank.SECONDARY }) { + Timber.w("InfectionStats is missing secondary value") + } + requireNotNull(keyFigures.singleOrNull { it.rank == KeyFigure.Rank.TERTIARY }) { + Timber.w("InfectionStats is missing secondary value") + } + } +} data class IncidenceStats( override val updatedAt: Instant, - override val keyFigures: List<KeyFigureCardOuterClass.KeyFigure> -) : StatsItem(cardId = 2) + override val keyFigures: List<KeyFigure> +) : StatsItem(cardType = Type.INCIDENCE) { + + val sevenDayIncidence: KeyFigure + get() = keyFigures.single { it.rank == KeyFigure.Rank.PRIMARY } + + override fun requireValidity() { + require(keyFigures.size == 1) + requireNotNull(keyFigures.singleOrNull { it.rank == KeyFigure.Rank.PRIMARY }) { + Timber.w("IncidenceStats is missing primary value") + } + } +} data class KeySubmissionsStats( override val updatedAt: Instant, - override val keyFigures: List<KeyFigureCardOuterClass.KeyFigure> -) : StatsItem(cardId = 3) + override val keyFigures: List<KeyFigure> +) : StatsItem(cardType = Type.KEYSUBMISSION) { + + val keySubmissions: KeyFigure + get() = keyFigures.single { it.rank == KeyFigure.Rank.PRIMARY } + + val sevenDayAverage: KeyFigure + get() = keyFigures.single { it.rank == KeyFigure.Rank.SECONDARY } + + val total: KeyFigure + get() = keyFigures.single { it.rank == KeyFigure.Rank.TERTIARY } + + override fun requireValidity() { + require(keyFigures.size == 3) + requireNotNull(keyFigures.singleOrNull { it.rank == KeyFigure.Rank.PRIMARY }) { + Timber.w("KeySubmissionsStats is missing primary value") + } + requireNotNull(keyFigures.singleOrNull { it.rank == KeyFigure.Rank.SECONDARY }) { + Timber.w("KeySubmissionsStats is missing secondary value") + } + requireNotNull(keyFigures.singleOrNull { it.rank == KeyFigure.Rank.TERTIARY }) { + Timber.w("KeySubmissionsStats is missing secondary value") + } + } +} + +data class SevenDayRValue( + override val updatedAt: Instant, + override val keyFigures: List<KeyFigure> +) : StatsItem(cardType = Type.SEVEN_DAY_RVALUE) { + + val reproductionNumber: KeyFigure + get() = keyFigures.single { it.rank == KeyFigure.Rank.PRIMARY } + + override fun requireValidity() { + require(keyFigures.size == 1) + requireNotNull(keyFigures.singleOrNull { it.rank == KeyFigure.Rank.PRIMARY }) { + Timber.w("SevenDayRValue is missing primary value") + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/InvalidStatisticsSignatureException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/InvalidStatisticsSignatureException.kt new file mode 100644 index 0000000000000000000000000000000000000000..3cb39d826e6a7e41c8dd9ac52fcc9aebdff9ea93 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/InvalidStatisticsSignatureException.kt @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.statistics.source + +import de.rki.coronawarnapp.exception.reporting.ErrorCodes +import de.rki.coronawarnapp.util.security.InvalidSignatureException + +class InvalidStatisticsSignatureException(message: String) : InvalidSignatureException( + code = ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code, + message = message +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsApiV1.kt new file mode 100644 index 0000000000000000000000000000000000000000..7bdc561573500c4205a4c8700225e1df212eeff8 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsApiV1.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.statistics.source + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET + +interface StatisticsApiV1 { + + @GET("/version/v1/stats") + suspend fun getStatistics(): Response<ResponseBody> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsCache.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsCache.kt new file mode 100644 index 0000000000000000000000000000000000000000..01e4aca577c45f2c507576182436b3c00dbe04ef --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsCache.kt @@ -0,0 +1,40 @@ +package de.rki.coronawarnapp.statistics.source + +import de.rki.coronawarnapp.statistics.Statistics +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class StatisticsCache @Inject constructor( + @Statistics cacheDir: File +) { + + private val cacheFile = File(cacheDir, "cache_raw") + + fun load(): ByteArray? = try { + if (cacheFile.exists()) cacheFile.readBytes() else null + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to load raw statistics from cache.") + null + } + + fun save(data: ByteArray?) { + if (data == null) { + if (cacheFile.exists() && cacheFile.delete()) { + Timber.tag(TAG).d("Cache file was deleted.") + } + return + } + if (cacheFile.exists()) { + Timber.tag(TAG).d("Overwriting with new data (size=%d)", data.size) + } + cacheFile.parentFile?.mkdirs() + cacheFile.writeBytes(data) + } + + companion object { + const val TAG = "StatisticsCache" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsParser.kt new file mode 100644 index 0000000000000000000000000000000000000000..1dc2ee301c7280a4abfab1cd57358b4ae1a978d7 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsParser.kt @@ -0,0 +1,61 @@ +package de.rki.coronawarnapp.statistics.source + +import dagger.Reusable +import de.rki.coronawarnapp.server.protocols.internal.stats.StatisticsOuterClass +import de.rki.coronawarnapp.statistics.IncidenceStats +import de.rki.coronawarnapp.statistics.InfectionStats +import de.rki.coronawarnapp.statistics.KeySubmissionsStats +import de.rki.coronawarnapp.statistics.SevenDayRValue +import de.rki.coronawarnapp.statistics.StatisticsData +import de.rki.coronawarnapp.statistics.StatsItem +import org.joda.time.Instant +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class StatisticsParser @Inject constructor() { + + fun parse(rawData: ByteArray): StatisticsData { + val parsed = StatisticsOuterClass.Statistics.parseFrom(rawData) + + if (parsed.cardIdSequenceCount != parsed.keyFigureCardsCount) { + Timber.tag(TAG).w( + "Cards have been hidden (sequenceCount=%d != cardCount=%d)", + parsed.cardIdSequenceCount, parsed.keyFigureCardsCount + ) + } + + val mappedItems: Set<StatsItem> = parsed.keyFigureCardsList.mapNotNull { rawCard -> + try { + val updatedAt = Instant.ofEpochMilli(rawCard.header.updatedAt) + val keyFigures = rawCard.keyFiguresList + when (StatsItem.Type.values().singleOrNull { it.id == rawCard.header.cardId }) { + StatsItem.Type.INFECTION -> InfectionStats(updatedAt = updatedAt, keyFigures = keyFigures) + StatsItem.Type.INCIDENCE -> IncidenceStats(updatedAt = updatedAt, keyFigures = keyFigures) + StatsItem.Type.KEYSUBMISSION -> KeySubmissionsStats(updatedAt = updatedAt, keyFigures = keyFigures) + StatsItem.Type.SEVEN_DAY_RVALUE -> SevenDayRValue(updatedAt = updatedAt, keyFigures = keyFigures) + null -> null.also { Timber.tag(TAG).e("Unknown statistics type: %s", rawCard) } + }.also { + Timber.tag(TAG).v("Parsed %s", it.toString().replace("\n", ", ")) + it?.requireValidity() + } + } catch (e: Exception) { + Timber.tag(TAG).e("Failed to parse raw card: %s", rawCard) + null + } + }.toSet() + + val orderedItems = parsed.cardIdSequenceList.mapNotNull { cardId -> + mappedItems.singleOrNull { it.cardType.id == cardId }.also { + if (it == null) Timber.tag(TAG).w("There was no card data for ID=%d", cardId) + } + } + return StatisticsData(items = orderedItems).also { + Timber.tag(TAG).d("Parsed statistics data, %d cards.", it.items.size) + } + } + + companion object { + const val TAG = "StatisticsParser" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsProvider.kt index 2e657fa0ec0308d18ec7f4744cfa6c458832175e..f70cc86aabd525e2c6bba253cf29bb36441105bf 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsProvider.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsProvider.kt @@ -1,102 +1,99 @@ package de.rki.coronawarnapp.statistics.source -import de.rki.coronawarnapp.server.protocols.internal.stats.KeyFigureCardOuterClass -import de.rki.coronawarnapp.statistics.IncidenceStats -import de.rki.coronawarnapp.statistics.InfectionStats -import de.rki.coronawarnapp.statistics.KeySubmissionsStats import de.rki.coronawarnapp.statistics.StatisticsData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope +import de.rki.coronawarnapp.util.coroutine.AppScope +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.device.ForegroundState +import de.rki.coronawarnapp.util.flow.HotDataFlow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.launch -import org.joda.time.Instant +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton -class StatisticsProvider @Inject constructor() { +class StatisticsProvider @Inject constructor( + @AppScope private val scope: CoroutineScope, + private val server: StatisticsServer, + private val localCache: StatisticsCache, + private val parser: StatisticsParser, + foregroundState: ForegroundState, + dispatcherProvider: DispatcherProvider +) { - private val currentInternal = MutableStateFlow(StatisticsData(items = emptyList())) - val current: Flow<StatisticsData> = currentInternal.filterNotNull() + private val statisticsData = HotDataFlow( + loggingTag = TAG, + scope = scope, + coroutineContext = dispatcherProvider.IO, + sharingBehavior = SharingStarted.Lazily + ) { + try { + fromCache() ?: fromServer() + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to get data from server.") + StatisticsData() + } + } + + val current: Flow<StatisticsData> = statisticsData.data init { - // Mock data - GlobalScope.launch(context = Dispatchers.IO) { - val statisticsData = StatisticsData( - items = listOf( - InfectionStats( - updatedAt = Instant.ofEpochMilli(1604839761), - keyFigures = listOf( - KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { - rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY - value = 14714.0 - decimals = 0 - trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND - trendSemantic = - KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC - }.build(), - KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { - rank = KeyFigureCardOuterClass.KeyFigure.Rank.SECONDARY - value = 11981.0 - decimals = 0 - trend = KeyFigureCardOuterClass.KeyFigure.Trend.INCREASING - trendSemantic = KeyFigureCardOuterClass.KeyFigure.TrendSemantic.NEGATIVE - }.build(), KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { - rank = KeyFigureCardOuterClass.KeyFigure.Rank.TERTIARY - value = 429181.0 - decimals = 0 - trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND - trendSemantic = - KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC - }.build() - ) - ), - IncidenceStats( - updatedAt = Instant.ofEpochMilli(1604839761), - keyFigures = listOf( - KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { - rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY - value = 98.9 - decimals = 1 - trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND - trendSemantic = - KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC - }.build() - ) - ), - KeySubmissionsStats( - updatedAt = Instant.ofEpochMilli(1604839761), - keyFigures = listOf( - KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { - rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY - value = 1514.0 - decimals = 0 - trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND - trendSemantic = - KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC - }.build(), - KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { - rank = KeyFigureCardOuterClass.KeyFigure.Rank.SECONDARY - value = 1812.0 - decimals = 0 - trend = KeyFigureCardOuterClass.KeyFigure.Trend.DECREASING - trendSemantic = KeyFigureCardOuterClass.KeyFigure.TrendSemantic.NEGATIVE - }.build(), - KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { - rank = KeyFigureCardOuterClass.KeyFigure.Rank.TERTIARY - value = 20922.0 - decimals = 0 - trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND - trendSemantic = - KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC - }.build() - ) - ) - ) - ) - currentInternal.emit(statisticsData) + foregroundState.isInForeground + .onEach { + if (it) { + Timber.tag(TAG).d("App moved to foreground triggering statistics update.") + triggerUpdate() + } + } + .catch { Timber.tag(TAG).e("Failed to trigger statistics update.") } + .launchIn(scope) + } + + private fun fromCache(): StatisticsData? = try { + Timber.tag(TAG).d("fromCache()") + localCache.load()?.let { parser.parse(it) }?.also { + Timber.tag(TAG).d("Parsed from cache: %s", it) + } + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to parse cached data.") + null + } + + private suspend fun fromServer(): StatisticsData { + Timber.tag(TAG).d("fromServer()") + val rawData = server.getRawStatistics() + return parser.parse(rawData).also { + Timber.tag(TAG).d("Parsed from server: %s", it) + localCache.save(rawData) } } + + fun triggerUpdate() { + Timber.tag(TAG).d("triggerUpdate()") + statisticsData.updateSafely { + try { + fromServer() + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to update statistics.") + this@updateSafely // return previous data + } + } + } + + suspend fun clear() { + Timber.d("clear()") + server.clear() + localCache.save(null) + statisticsData.updateBlocking { + StatisticsData() + } + } + + companion object { + const val TAG = "StatisticsProvider" + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsServer.kt new file mode 100644 index 0000000000000000000000000000000000000000..11526a01c55cf71e0cbeca327521b8dc53535e3c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/source/StatisticsServer.kt @@ -0,0 +1,58 @@ +package de.rki.coronawarnapp.statistics.source + +import dagger.Lazy +import dagger.Reusable +import de.rki.coronawarnapp.statistics.Statistics +import de.rki.coronawarnapp.util.ZipHelper.readIntoMap +import de.rki.coronawarnapp.util.ZipHelper.unzip +import de.rki.coronawarnapp.util.security.VerificationKeys +import okhttp3.Cache +import retrofit2.HttpException +import timber.log.Timber +import java.io.IOException +import javax.inject.Inject + +@Reusable +class StatisticsServer @Inject constructor( + private val api: Lazy<StatisticsApiV1>, + private val verificationKeys: VerificationKeys, + @Statistics val cache: Cache +) { + + suspend fun getRawStatistics(): ByteArray { + Timber.tag(TAG).d("Fetching statistics.") + + val response = api.get().getStatistics() + if (!response.isSuccessful) throw HttpException(response) + + return with( + requireNotNull(response.body()) { "Response was successful but body was null" } + ) { + val fileMap = byteStream().unzip().readIntoMap() + + val exportBinary = fileMap[EXPORT_BINARY_FILE_NAME] + val exportSignature = fileMap[EXPORT_SIGNATURE_FILE_NAME] + + if (exportBinary == null || exportSignature == null) { + throw IOException("Unknown files: ${fileMap.keys}") + } + + if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) { + throw InvalidStatisticsSignatureException(message = "Statistics signature did not match.") + } + + exportBinary + } + } + + fun clear() { + Timber.d("clear()") + cache.evictAll() + } + + companion object { + private const val EXPORT_BINARY_FILE_NAME = "export.bin" + private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig" + private const val TAG = "StatisticsServer" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/StatisticsCardAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/StatisticsCardAdapter.kt index 815fd900b219ca80676a6594561ef6ead342bee5..1805ced630701cbb84f251a1c0ede9c0cdb40f0a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/StatisticsCardAdapter.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/StatisticsCardAdapter.kt @@ -6,10 +6,12 @@ import androidx.viewbinding.ViewBinding import de.rki.coronawarnapp.statistics.IncidenceStats import de.rki.coronawarnapp.statistics.InfectionStats import de.rki.coronawarnapp.statistics.KeySubmissionsStats +import de.rki.coronawarnapp.statistics.SevenDayRValue import de.rki.coronawarnapp.statistics.ui.homecards.StatisticsCardAdapter.ItemVH import de.rki.coronawarnapp.statistics.ui.homecards.cards.IncidenceCard import de.rki.coronawarnapp.statistics.ui.homecards.cards.InfectionsCard import de.rki.coronawarnapp.statistics.ui.homecards.cards.KeySubmissionsCard +import de.rki.coronawarnapp.statistics.ui.homecards.cards.SevenDayRValueCard import de.rki.coronawarnapp.statistics.ui.homecards.cards.StatisticsCardItem import de.rki.coronawarnapp.util.lists.BindableVH import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffUtilAdapter @@ -30,7 +32,8 @@ class StatisticsCardAdapter : ModularAdapter<ItemVH<StatisticsCardItem, ViewBind DataBinderMod<StatisticsCardItem, ItemVH<StatisticsCardItem, ViewBinding>>(data), TypedVHCreatorMod({ data[it].stats is InfectionStats }) { InfectionsCard(it) }, TypedVHCreatorMod({ data[it].stats is IncidenceStats }) { IncidenceCard(it) }, - TypedVHCreatorMod({ data[it].stats is KeySubmissionsStats }) { KeySubmissionsCard(it) } + TypedVHCreatorMod({ data[it].stats is KeySubmissionsStats }) { KeySubmissionsCard(it) }, + TypedVHCreatorMod({ data[it].stats is SevenDayRValue }) { SevenDayRValueCard(it) } ).let { modules.addAll(it) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/cards/SevenDayRValueCard.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/cards/SevenDayRValueCard.kt new file mode 100644 index 0000000000000000000000000000000000000000..9ad68b9ad40ca69c6d0acd7e848c13dffeec1553 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/cards/SevenDayRValueCard.kt @@ -0,0 +1,28 @@ +package de.rki.coronawarnapp.statistics.ui.homecards.cards + +import android.view.ViewGroup +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.HomeStatisticsCardsSevendayrvalueLayoutBinding +import de.rki.coronawarnapp.statistics.SevenDayRValue +import de.rki.coronawarnapp.statistics.ui.homecards.StatisticsCardAdapter + +class SevenDayRValueCard(parent: ViewGroup) : + StatisticsCardAdapter.ItemVH<StatisticsCardItem, HomeStatisticsCardsSevendayrvalueLayoutBinding>( + R.layout.home_statistics_cards_basecard_layout, parent + ) { + + override val viewBinding = lazy { + HomeStatisticsCardsSevendayrvalueLayoutBinding.inflate( + layoutInflater, + itemView.findViewById(R.id.card_container), + true + ) + } + + override val onBindData: HomeStatisticsCardsSevendayrvalueLayoutBinding.( + item: StatisticsCardItem, + payloads: List<Any> + ) -> Unit = { item, payloads -> + item.stats as SevenDayRValue + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/cards/StatisticsCardItem.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/cards/StatisticsCardItem.kt index 0130712ea939546e20dda1aebbcd28b896594f62..d2aeb5ab12c5c7567e3b6f826faad18ac5c0c6ec 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/cards/StatisticsCardItem.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/statistics/ui/homecards/cards/StatisticsCardItem.kt @@ -8,5 +8,5 @@ data class StatisticsCardItem( val onHelpAction: (StatsItem) -> Unit ) : HasStableId { - override val stableId: Long = stats.cardId.toLong() + override val stableId: Long = stats.cardType.id.toLong() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt index 09078110d76ff7585fdef084aecf26112a8741c7..9d576b1a1cb54cba70c3652e046d732c6b63598e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt @@ -10,6 +10,7 @@ import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository import de.rki.coronawarnapp.main.CWASettings import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker import de.rki.coronawarnapp.risk.storage.RiskLevelStorage +import de.rki.coronawarnapp.statistics.source.StatisticsProvider import de.rki.coronawarnapp.storage.AppDatabase import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.submission.SubmissionRepository @@ -36,7 +37,8 @@ class DataReset @Inject constructor( private val riskLevelStorage: RiskLevelStorage, private val contactDiaryRepository: ContactDiaryRepository, private var contactDiarySettings: ContactDiarySettings, - private val cwaSettings: CWASettings + private val cwaSettings: CWASettings, + private val statisticsProvider: StatisticsProvider ) { private val mutex = Mutex() @@ -68,6 +70,8 @@ class DataReset @Inject constructor( // Clear contact diary database contactDiaryRepository.clear() + statisticsProvider.clear() + Timber.w("CWA LOCAL DATA DELETION COMPLETED.") } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt index 793ba8478adc68b9bce2772e24dab5a12225f86e..376305143adacbdae87645da447fde734a94e32c 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt @@ -24,6 +24,7 @@ import de.rki.coronawarnapp.playbook.PlaybookModule import de.rki.coronawarnapp.receiver.ReceiverBinder import de.rki.coronawarnapp.risk.RiskModule import de.rki.coronawarnapp.service.ServiceBinder +import de.rki.coronawarnapp.statistics.StatisticsModule import de.rki.coronawarnapp.submission.SubmissionModule import de.rki.coronawarnapp.submission.task.SubmissionTaskModule import de.rki.coronawarnapp.task.TaskController @@ -69,7 +70,8 @@ import javax.inject.Singleton BugReportingSharedModule::class, SerializationModule::class, WorkerBinder::class, - ContactDiaryRootModule::class + ContactDiaryRootModule::class, + StatisticsModule::class ] ) interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/InvalidSignatureException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/InvalidSignatureException.kt new file mode 100644 index 0000000000000000000000000000000000000000..b5c532f3d96026e2350f213eb6ef7de02c7c2a7f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/InvalidSignatureException.kt @@ -0,0 +1,13 @@ +package de.rki.coronawarnapp.util.security + +import de.rki.coronawarnapp.exception.reporting.ReportedException + +open class InvalidSignatureException( + code: Int, + message: String, + cause: Throwable? = null +) : ReportedException( + code = code, + message = message, + cause = cause +) diff --git a/Corona-Warn-App/src/main/res/layout/home_statistics_cards_sevendayrvalue_layout.xml b/Corona-Warn-App/src/main/res/layout/home_statistics_cards_sevendayrvalue_layout.xml new file mode 100644 index 0000000000000000000000000000000000000000..2f51130ca34ddbde6d7d0205fbbe4909ab8c824a --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/home_statistics_cards_sevendayrvalue_layout.xml @@ -0,0 +1,24 @@ +<?xml version="1.0" encoding="utf-8"?> +<layout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:showIn="@layout/home_statistics_cards_basecard_layout"> + + <TextView + android:id="@+id/title" + style="@style/headline5" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="24dp" + android:layout_marginTop="24dp" + android:focusable="false" + android:text="7-Day R Value" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + </androidx.constraintlayout.widget.ConstraintLayout> + +</layout> \ No newline at end of file diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/StatisticsDataTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/StatisticsDataTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..065349ad9360b19352d57cf49a05ca4af5016cbb --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/StatisticsDataTest.kt @@ -0,0 +1,128 @@ +package de.rki.coronawarnapp.statistics + +import de.rki.coronawarnapp.server.protocols.internal.stats.KeyFigureCardOuterClass +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.joda.time.Instant +import org.junit.jupiter.api.Test + +class StatisticsDataTest { + + @Test + fun `infection mapping`() { + val stats = InfectionStats( + updatedAt = Instant.EPOCH, + keyFigures = listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 1.0 + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.SECONDARY + value = 2.0 + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.TERTIARY + value = 3.0 + }.build() + ) + ) + stats.apply { + cardType shouldBe StatsItem.Type.INFECTION + cardType.id shouldBe 1 + newInfections.value shouldBe 1.0 + sevenDayAverage.value shouldBe 2.0 + total.value shouldBe 3.0 + } + + stats.requireValidity() + + shouldThrow<IllegalArgumentException> { + stats.copy(keyFigures = stats.keyFigures.subList(0, 1)).requireValidity() + } + } + + @Test + fun `incidence mapping`() { + val stats = IncidenceStats( + updatedAt = Instant.EPOCH, + keyFigures = listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 1.0 + }.build() + ) + ) + stats.apply { + cardType shouldBe StatsItem.Type.INCIDENCE + cardType.id shouldBe 2 + sevenDayIncidence.value shouldBe 1.0 + } + + stats.requireValidity() + + shouldThrow<IllegalArgumentException> { + stats.copy(keyFigures = emptyList()).requireValidity() + } + } + + @Test + fun `keysubmission mapping`() { + val stats = KeySubmissionsStats( + updatedAt = Instant.EPOCH, + keyFigures = listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 1.0 + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.SECONDARY + value = 2.0 + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.TERTIARY + value = 3.0 + }.build() + ) + ) + + stats.apply { + cardType shouldBe StatsItem.Type.KEYSUBMISSION + cardType.id shouldBe 3 + keySubmissions.value shouldBe 1.0 + sevenDayAverage.value shouldBe 2.0 + total.value shouldBe 3.0 + } + + stats.requireValidity() + + shouldThrow<IllegalArgumentException> { + stats.copy(keyFigures = stats.keyFigures.subList(0, 1)).requireValidity() + } + } + + @Test + fun `7 day R value mapping`() { + val stats = SevenDayRValue( + updatedAt = Instant.EPOCH, + keyFigures = listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 1.0 + }.build() + ) + ) + + stats.apply { + cardType shouldBe StatsItem.Type.SEVEN_DAY_RVALUE + cardType.id shouldBe 4 + reproductionNumber.value shouldBe 1.0 + } + + stats.requireValidity() + + shouldThrow<IllegalArgumentException> { + stats.copy(keyFigures = emptyList()).requireValidity() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/StatisticsModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/StatisticsModuleTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..393a62e60ed3da9cff0af8ef0490c3e082124a6f --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/StatisticsModuleTest.kt @@ -0,0 +1,45 @@ +package de.rki.coronawarnapp.statistics + +import android.content.Context +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File + +class StatisticsModuleTest : BaseIOTest() { + @MockK lateinit var context: Context + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheFiles = File(testDir, "cache") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.cacheDir } returns cacheFiles + + testDir.mkdirs() + testDir.exists() shouldBe true + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createModule() = StatisticsModule() + + @Test + fun `sideeffect free instantiation`() { + shouldNotThrowAny { + createModule() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsAPIV1Test.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsAPIV1Test.kt new file mode 100644 index 0000000000000000000000000000000000000000..62a262fa455d77f730648819dfd6996e4e81847d --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsAPIV1Test.kt @@ -0,0 +1,81 @@ +package de.rki.coronawarnapp.statistics.source + +import de.rki.coronawarnapp.environment.download.DownloadCDNModule +import de.rki.coronawarnapp.http.HttpModule +import de.rki.coronawarnapp.statistics.StatisticsModule +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import kotlinx.coroutines.runBlocking +import okhttp3.ConnectionSpec +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File +import java.util.concurrent.TimeUnit + +class StatisticsAPIV1Test : BaseIOTest() { + + private lateinit var webServer: MockWebServer + private lateinit var serverAddress: String + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val cacheDir = File(testDir, "cacheDir") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + webServer = MockWebServer() + webServer.start() + serverAddress = "http://${webServer.hostName}:${webServer.port}" + } + + @AfterEach + fun teardown() { + clearAllMocks() + webServer.shutdown() + testDir.deleteRecursively() + } + + private fun createAPI(): StatisticsApiV1 { + val httpModule = HttpModule() + val defaultHttpClient = httpModule.defaultHttpClient() + val gsonConverterFactory = httpModule.provideGSONConverter() + + val cdnHttpClient = DownloadCDNModule() + .cdnHttpClient(defaultHttpClient) + .newBuilder() + .connectionSpecs(listOf(ConnectionSpec.CLEARTEXT, ConnectionSpec.MODERN_TLS)) + .build() + + val cache = StatisticsModule().httpCache(cacheDir) + + return StatisticsModule().api( + client = cdnHttpClient, + url = serverAddress, + gsonConverterFactory = gsonConverterFactory, + cache = cache + ) + } + + @Test + fun `application config download`() { + val api = createAPI() + + webServer.enqueue(MockResponse().setBody("~look at me, I'm statistics")) + + runBlocking { + api.getStatistics().apply { + body()!!.string() shouldBe "~look at me, I'm statistics" + } + } + + val request = webServer.takeRequest(5, TimeUnit.SECONDS)!! + request.method shouldBe "GET" + request.path shouldBe "/version/v1/stats" + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsCacheTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsCacheTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..cfdca2dcb3102041d1fa05a7d024dc4fb1790646 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsCacheTest.kt @@ -0,0 +1,62 @@ +package de.rki.coronawarnapp.statistics.source + +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File + +class StatisticsCacheTest : BaseIOTest() { + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val androidCacheDir = File(testDir, "cache") + private val statisticsCacheDir = File(androidCacheDir, "statistics") + + private val cacheFile = File(statisticsCacheDir, "cache_raw") + + private val testData = "Row, Row, Row Your Boat".encodeToByteArray() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + testDir.deleteRecursively() + } + + fun createInstance() = StatisticsCache( + cacheDir = statisticsCacheDir + ) + + @Test + fun `empty start`() { + createInstance().load() shouldBe null + cacheFile.exists() shouldBe false + } + + @Test + fun `loading data`() { + cacheFile.parentFile?.mkdirs() + cacheFile.writeBytes(testData) + createInstance().load() shouldBe testData + } + + @Test + fun `saving data`() { + cacheFile.exists() shouldBe false + createInstance().save(testData) + cacheFile.readBytes() shouldBe testData + } + + @Test + fun `deleting data`() { + cacheFile.parentFile?.mkdirs() + cacheFile.writeBytes(testData) + createInstance().save(null) + cacheFile.exists() shouldBe false + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsParserTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsParserTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2af4825898c14a52f47529c28e3d2bed3c6ac2ef --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsParserTest.kt @@ -0,0 +1,324 @@ +package de.rki.coronawarnapp.statistics.source + +import de.rki.coronawarnapp.server.protocols.internal.stats.CardHeaderOuterClass +import de.rki.coronawarnapp.server.protocols.internal.stats.KeyFigureCardOuterClass +import de.rki.coronawarnapp.server.protocols.internal.stats.StatisticsOuterClass +import de.rki.coronawarnapp.statistics.IncidenceStats +import de.rki.coronawarnapp.statistics.InfectionStats +import de.rki.coronawarnapp.statistics.KeySubmissionsStats +import de.rki.coronawarnapp.statistics.SevenDayRValue +import de.rki.coronawarnapp.statistics.StatisticsData +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +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 + +class StatisticsParserTest : BaseTest() { + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createInstance() = StatisticsParser() + + @Test + fun `default parsing of all types`() { + val statisticsProto = StatisticsOuterClass.Statistics.newBuilder().apply { + addAllCardIdSequence(listOf(1, 3, 2, 4)) + addKeyFigureCards(INFECTION_PROTO) + addKeyFigureCards(KEYSUBMISSION_PROTO) + addKeyFigureCards(INCIDENCE_PROTO) + addKeyFigureCards(SEVENDAYRVALUE_PROTO) + }.build().toByteArray() + createInstance().parse(statisticsProto) shouldBe StatisticsData( + listOf(INFECTION_STATS, KEYSUBMISSION_STATS, INCIDENCE_STATS, SEVENDAYRVALUE_STATS) + ) + } + + @Test + fun `handle empty statistics data`() { + val statisticsProto = StatisticsOuterClass.Statistics.newBuilder().build().toByteArray() + createInstance().parse(statisticsProto) shouldBe StatisticsData() + } + + @Test + fun `handle hidden card for which we have data`() { + val statisticsProto = StatisticsOuterClass.Statistics.newBuilder().apply { + addCardIdSequence(3) + addKeyFigureCards(INFECTION_PROTO) + addKeyFigureCards(KEYSUBMISSION_PROTO) + }.build().toByteArray() + createInstance().parse(statisticsProto) shouldBe StatisticsData( + listOf(KEYSUBMISSION_STATS) + ) + } + + @Test + fun `handle corrupt card data`() { + val statisticsProto = StatisticsOuterClass.Statistics.newBuilder().apply { + addCardIdSequence(3) + addCardIdSequence(1) + INFECTION_PROTO.toBuilder().apply { + removeKeyFigures(2) + }.build().let { addKeyFigureCards(it) } + addKeyFigureCards(KEYSUBMISSION_PROTO) + }.build().toByteArray() + createInstance().parse(statisticsProto) shouldBe StatisticsData( + listOf(KEYSUBMISSION_STATS) + ) + } + + @Test + fun `handle duplicate card data`() { + val statisticsProto = StatisticsOuterClass.Statistics.newBuilder().apply { + addCardIdSequence(3) + addCardIdSequence(1) + addCardIdSequence(3) + addKeyFigureCards(INFECTION_PROTO) + addKeyFigureCards(KEYSUBMISSION_PROTO) + addKeyFigureCards(KEYSUBMISSION_PROTO) + }.build().toByteArray() + createInstance().parse(statisticsProto) shouldBe StatisticsData( + listOf(KEYSUBMISSION_STATS, INFECTION_STATS, KEYSUBMISSION_STATS) + ) + } + + @Test + fun `handle duplicate id in card sequence without crash`() { + val statisticsProto = StatisticsOuterClass.Statistics.newBuilder().apply { + addCardIdSequence(3) + addCardIdSequence(1) + addCardIdSequence(3) + addKeyFigureCards(INFECTION_PROTO) + addKeyFigureCards(KEYSUBMISSION_PROTO) + }.build().toByteArray() + createInstance().parse(statisticsProto) shouldBe StatisticsData( + listOf(KEYSUBMISSION_STATS, INFECTION_STATS, KEYSUBMISSION_STATS) + ) + } + + @Test + fun `handle unknown keycard data`() { + val statisticsProto = StatisticsOuterClass.Statistics.newBuilder().apply { + addCardIdSequence(3) + + INFECTION_PROTO.newBuilderForType().apply { + header = this.header.toBuilder().apply { + cardId = 99 + }.build() + }.build().let { addKeyFigureCards(it) } + + addKeyFigureCards(KEYSUBMISSION_PROTO) + }.build().toByteArray() + createInstance().parse(statisticsProto) shouldBe StatisticsData( + listOf(KEYSUBMISSION_STATS) + ) + } + + @Test + fun `handle unknown id in card sequence`() { + val statisticsProto = StatisticsOuterClass.Statistics.newBuilder().apply { + addCardIdSequence(3) + addCardIdSequence(99) + addKeyFigureCards(INFECTION_PROTO) + addKeyFigureCards(KEYSUBMISSION_PROTO) + }.build().toByteArray() + createInstance().parse(statisticsProto) shouldBe StatisticsData( + listOf(KEYSUBMISSION_STATS) + ) + } + + companion object { + val INFECTION_PROTO = KeyFigureCardOuterClass.KeyFigureCard.newBuilder().apply { + CardHeaderOuterClass.CardHeader.newBuilder().apply { + cardId = 1 + updatedAt = 123456778890 + }.build().let { header = it } + listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 14714.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.SECONDARY + value = 11981.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.INCREASING + trendSemantic = KeyFigureCardOuterClass.KeyFigure.TrendSemantic.NEGATIVE + }.build(), KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.TERTIARY + value = 429181.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build() + ).let { addAllKeyFigures(it) } + }.build() + + val INFECTION_STATS = InfectionStats( + updatedAt = Instant.ofEpochMilli(123456778890), + keyFigures = listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 14714.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.SECONDARY + value = 11981.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.INCREASING + trendSemantic = KeyFigureCardOuterClass.KeyFigure.TrendSemantic.NEGATIVE + }.build(), KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.TERTIARY + value = 429181.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build() + ) + ) + + val INCIDENCE_PROTO = KeyFigureCardOuterClass.KeyFigureCard.newBuilder().apply { + CardHeaderOuterClass.CardHeader.newBuilder().apply { + cardId = 2 + updatedAt = 1604839761 + }.build().let { header = it } + listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 98.9 + decimals = 1 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build() + ).let { addAllKeyFigures(it) } + }.build() + + val INCIDENCE_STATS = IncidenceStats( + updatedAt = Instant.ofEpochMilli(1604839761), + keyFigures = listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 98.9 + decimals = 1 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build() + ) + ) + + val KEYSUBMISSION_PROTO = KeyFigureCardOuterClass.KeyFigureCard.newBuilder().apply { + CardHeaderOuterClass.CardHeader.newBuilder().apply { + cardId = 3 + updatedAt = 0 + }.build().let { header = it } + listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 1514.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.SECONDARY + value = 1812.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.DECREASING + trendSemantic = KeyFigureCardOuterClass.KeyFigure.TrendSemantic.NEGATIVE + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.TERTIARY + value = 20922.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build() + ).let { addAllKeyFigures(it) } + }.build() + + val KEYSUBMISSION_STATS = KeySubmissionsStats( + updatedAt = Instant.ofEpochMilli(0), + keyFigures = listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 1514.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.SECONDARY + value = 1812.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.DECREASING + trendSemantic = KeyFigureCardOuterClass.KeyFigure.TrendSemantic.NEGATIVE + }.build(), + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.TERTIARY + value = 20922.0 + decimals = 0 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.UNSPECIFIED_TREND + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.UNSPECIFIED_TREND_SEMANTIC + }.build() + ) + ) + + val SEVENDAYRVALUE_PROTO = KeyFigureCardOuterClass.KeyFigureCard.newBuilder().apply { + CardHeaderOuterClass.CardHeader.newBuilder().apply { + cardId = 4 + updatedAt = 1604839761 + }.build().let { header = it } + listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 1.04 + decimals = 2 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.INCREASING + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.NEGATIVE + }.build() + ).let { addAllKeyFigures(it) } + }.build() + + val SEVENDAYRVALUE_STATS = SevenDayRValue( + updatedAt = Instant.ofEpochMilli(1604839761), + keyFigures = listOf( + KeyFigureCardOuterClass.KeyFigure.newBuilder().apply { + rank = KeyFigureCardOuterClass.KeyFigure.Rank.PRIMARY + value = 1.04 + decimals = 2 + trend = KeyFigureCardOuterClass.KeyFigure.Trend.INCREASING + trendSemantic = + KeyFigureCardOuterClass.KeyFigure.TrendSemantic.NEGATIVE + }.build() + ) + ) + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsProviderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c23e6a70b395aac53e50b644620c0d8450cba3d0 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsProviderTest.kt @@ -0,0 +1,142 @@ +package de.rki.coronawarnapp.statistics.source + +import de.rki.coronawarnapp.statistics.StatisticsData +import de.rki.coronawarnapp.util.device.ForegroundState +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifySequence +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.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.TestDispatcherProvider +import testhelpers.coroutines.runBlockingTest2 +import testhelpers.coroutines.test +import java.io.IOException + +class StatisticsProviderTest : BaseTest() { + @MockK lateinit var server: StatisticsServer + @MockK lateinit var localCache: StatisticsCache + @MockK lateinit var parser: StatisticsParser + @MockK lateinit var foregroundState: ForegroundState + @MockK lateinit var statisticsData: StatisticsData + + private val testData = "ABC".encodeToByteArray() + + private val testForegroundState = MutableStateFlow(false) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + server.apply { + coEvery { getRawStatistics() } returns testData + every { clear() } just Runs + } + + every { parser.parse(testData) } returns statisticsData + every { foregroundState.isInForeground } returns testForegroundState + + localCache.apply { + var testLocalCache: ByteArray? = null + every { load() } answers { testLocalCache } + every { save(any()) } answers { testLocalCache = arg(0) } + } + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + fun createInstance(scope: CoroutineScope) = StatisticsProvider( + server = server, + scope = scope, + localCache = localCache, + parser = parser, + foregroundState = foregroundState, + dispatcherProvider = TestDispatcherProvider + ) + + @Test + fun `creation is sideeffect free`() = runBlockingTest2(ignoreActive = true) { + createInstance(this) + verify(exactly = 0) { localCache.load() } + } + + @Test + fun `initial subscription tries cache, then server`() = runBlockingTest2(ignoreActive = true) { + val testCollector = createInstance(this).current.test(startOnScope = this) + + coVerifySequence { + localCache.load() + server.getRawStatistics() + parser.parse(testData) + localCache.save(testData) + } + + testCollector.latestValue shouldBe statisticsData + } + + @Test + fun `update foreground state change`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) + val testCollector = instance.current.test(startOnScope = this) + + testCollector.latestValue shouldBe statisticsData + + val newRawStatisticsData = "Bernd".encodeToByteArray() + coEvery { server.getRawStatistics() } returns newRawStatisticsData + val newStatisticsData = mockk<StatisticsData>() + coEvery { parser.parse(any()) } returns newStatisticsData + + testForegroundState.value = false + testForegroundState.value = true + + testCollector.latestValues shouldBe listOf(statisticsData, newStatisticsData) + verify { localCache.save(newRawStatisticsData) } + } + + @Test + fun `failed update does not destroy cache`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) + val testCollector = instance.current.test(startOnScope = this) + + testCollector.latestValue shouldBe statisticsData + + coEvery { server.getRawStatistics() } throws IOException() + + instance.triggerUpdate() + + testCollector.latestValues shouldBe listOf(statisticsData) + verify(exactly = 0) { localCache.save(null) } + } + + @Test + fun `clear deletes cache and current flow`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) + + val testCollector = instance.current.test(startOnScope = this) + testCollector.latestValue shouldBe statisticsData + + instance.clear() + + coVerify { + server.clear() + localCache.save(null) + } + + testCollector.latestValue shouldBe StatisticsData() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsServerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..5a13d25527a2431b34a94c658e5f3057481cb2e4 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/statistics/source/StatisticsServerTest.kt @@ -0,0 +1,114 @@ +package de.rki.coronawarnapp.statistics.source + +import de.rki.coronawarnapp.util.security.VerificationKeys +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.verify +import kotlinx.coroutines.test.runBlockingTest +import okhttp3.Cache +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.ByteString.Companion.decodeHex +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import retrofit2.Response +import testhelpers.BaseIOTest +import java.io.IOException + +class StatisticsServerTest : BaseIOTest() { + + @MockK lateinit var api: StatisticsApiV1 + @MockK lateinit var verificationKeys: VerificationKeys + @MockK lateinit var cache: Cache + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + every { cache.evictAll() } just Runs + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createInstance() = StatisticsServer( + api = { api }, + verificationKeys = verificationKeys, + cache = cache + ) + + @Test + fun `successful download`() = runBlockingTest { + coEvery { api.getStatistics() } returns Response.success(STATS_ZIP.toResponseBody()) + + val server = createInstance() + + val rawStatistics = server.getRawStatistics() + rawStatistics shouldBe STATS_PROTO + + verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) } + } + + @Test + fun `data is faulty`() = runBlockingTest { + coEvery { api.getStatistics() } returns Response.success("123ABC".decodeHex().toResponseBody()) + + val server = createInstance() + + shouldThrow<IOException> { + server.getRawStatistics() + } + } + + @Test + fun `verification fails`() = runBlockingTest { + coEvery { api.getStatistics() } returns Response.success(STATS_ZIP.toResponseBody()) + every { verificationKeys.hasInvalidSignature(any(), any()) } returns true + + val server = createInstance() + + shouldThrow<InvalidStatisticsSignatureException> { + server.getRawStatistics() + } + } + + @Test + fun `clear clears cache`() { + createInstance().clear() + verify { cache.evictAll() } + } + + companion object { + private val STATS_ZIP = + ( + "504b03041400080808008d4a2f520000000000000000000000000a0000006578706f72742e736967018b0074ff0a88010a380a" + + "1864652e726b692e636f726f6e617761726e6170702d6465761a02763122033236322a13312e322e3834302e3130303435" + + "2e342e332e321001180122483046022100f363cc4813367bdd2fa03c91fc49c3521abf2db86ec8f5836f97f5d13f915285" + + "022100e8a51c68ece56ccc44de41cee75d766adb5f1d688d1773875dc1c6944bc2a1de504b0708a4db7bfc900000008b00" + + "0000504b03041400080808008d4a2f520000000000000000000000000a0000006578706f72742e62696ee3626164666211" + + "32e5e2e060146898fcef3fab103707a3200308dcb8ea20c4cfc104e6386cb9e4a0c0a4c10c9465060b1c5a69e728240bd4" + + "c604d52608d41621e1f3e19e73928304a302b30613d8546674535f34204c6560a8725060d460849bfac18d11622a8b40c3" + + "93c750538376c8b5be0efc602fc104520b00504b070867e7aa477b000000b2000000504b010214001400080808008d4a2f" + + "52a4db7bfc900000008b0000000a00000000000000000000000000000000006578706f72742e736967504b010214001400" + + "080808008d4a2f5267e7aa477b000000b20000000a00000000000000000000000000c80000006578706f72742e62696e50" + + "4b05060000000002000200700000007b0100000000" + ).decodeHex() + private val STATS_PROTO = + ( + "0a040103020412350a080801108093feff05120b0801110000000000d8d540120f0802110000000040b4d24020022803120b08" + + "031100000000c2a93e41121d0a080802108093feff05121108011158184cf0de43624018012003280212350a0808031080" + + "93feff05120b0801110000000000e88040120f0802110000000000007a4020012801120b08031100000000f0460141121d" + + "0a0808041080e4e3ff05121108011152b81e85eb51f03f180220012801" + ).decodeHex().toByteArray() + } +} diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt index 68d1ba19c174d4bb013eb4bcba609566a31cafdf..74f4a86af0db8ce3580a86504f0601a5cbd61eda 100644 --- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt +++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt @@ -9,6 +9,10 @@ import timber.log.Timber import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext +/** + * If you have a test that uses a coroutine that never stops, you may use this. + */ + @ExperimentalCoroutinesApi // Since 1.2.1, tentatively till 1.3.0 fun TestCoroutineScope.runBlockingTest2( ignoreActive: Boolean = false,