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,