From 3946e3bb12faedb576abe7b720909afe07697f6e Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <darken@darken.eu>
Date: Tue, 22 Sep 2020 14:29:26 +0200
Subject: [PATCH] QuotaCalculator v2 (#1201)

* QuotaCalculator V2

* Add extra logging for transaction and worker exceptions. (#1195) (EXPOSUREAPP-2640)

* Remove QuotaCalculation due to unexpected sideeffects. (#1196) (EXPOUSREAPP-2640)

* Fix klint/sourceCheck.

* Add test for first initialization.

* Add visibility modifier

* Simplify quota reset time calculation and extend tests for edge cases.

* Reduce impact of quota check to a warning for 1.4.
Add tests for silent quota check and configuration fallback behavior.

* Addressed PR comments.
---
 .../coronawarnapp/test/TestForAPIFragment.kt  |   7 +-
 .../test/TestRiskLevelCalculationFragment.kt  |  11 +-
 .../de/rki/coronawarnapp/nearby/ENFClient.kt  |  35 +++
 .../nearby/ENFClientLocalData.kt              |  34 +++
 .../de/rki/coronawarnapp/nearby/ENFModule.kt  |  24 ++
 .../InternalExposureNotificationClient.kt     |  37 +--
 .../DefaultDiagnosisKeyProvider.kt            | 106 +++++++
 .../DiagnosisKeyProvider.kt                   |  25 ++
 .../diagnosiskeyprovider/SubmissionQuota.kt   |  91 ++++++
 .../de/rki/coronawarnapp/storage/LocalData.kt |  40 ---
 .../RetrieveDiagnosisInjectionHelper.kt       |   4 +-
 .../RetrieveDiagnosisKeysTransaction.kt       | 129 ++------
 .../util/GoogleQuotaCalculator.kt             |  87 ------
 .../rki/coronawarnapp/util/QuotaCalculator.kt |  29 --
 .../de/rki/coronawarnapp/util/TimeStamper.kt  |  12 +
 .../util/di/ApplicationComponent.kt           |   7 +-
 .../worker/BackgroundWorkHelper.kt            |   2 +
 .../DiagnosisKeyRetrievalOneTimeWorker.kt     |  24 +-
 .../DiagnosisKeyRetrievalPeriodicWorker.kt    |  24 +-
 .../rki/coronawarnapp/nearby/ENFClientTest.kt |  76 +++++
 .../DefaultDiagnosisKeyProviderTest.kt        | 200 ++++++++++++
 .../SubmissionQuotaTest.kt                    | 228 ++++++++++++++
 .../RetrieveDiagnosisKeysTransactionTest.kt   |  89 +++---
 .../util/GoogleQuotaCalculatorTest.kt         | 289 ------------------
 24 files changed, 955 insertions(+), 655 deletions(-)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClientLocalData.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DiagnosisKeyProvider.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/SubmissionQuota.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/GoogleQuotaCalculator.kt
 delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/QuotaCalculator.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeStamper.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProviderTest.kt
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/SubmissionQuotaTest.kt
 delete mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/GoogleQuotaCalculatorTest.kt

diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/TestForAPIFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/TestForAPIFragment.kt
index 5c20d3a46..6787f0362 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/TestForAPIFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/TestForAPIFragment.kt
@@ -52,6 +52,7 @@ import de.rki.coronawarnapp.transaction.RiskLevelTransaction
 import de.rki.coronawarnapp.ui.viewLifecycle
 import de.rki.coronawarnapp.ui.viewmodel.TracingViewModel
 import de.rki.coronawarnapp.util.KeyFileHelper
+import de.rki.coronawarnapp.util.di.AppInjector
 import kotlinx.android.synthetic.deviceForTesters.fragment_test_for_a_p_i.*
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.async
@@ -80,6 +81,10 @@ class TestForAPIFragment : Fragment(), InternalExposureNotificationPermissionHel
         }
     }
 
+    private val enfClient by lazy {
+        AppInjector.component.enfClient
+    }
+
     private var myExposureKeysJSON: String? = null
     private var myExposureKeys: List<TemporaryExposureKey>? = mutableListOf()
     private var otherExposureKey: AppleLegacyKeyExchange.Key? = null
@@ -397,7 +402,7 @@ class TestForAPIFragment : Fragment(), InternalExposureNotificationPermissionHel
                 Timber.i("Provide ${googleFileList.count()} files with ${appleKeyList.size} keys with token $token")
                 try {
                     // only testing implementation: this is used to wait for the broadcastreceiver of the OS / EN API
-                    InternalExposureNotificationClient.asyncProvideDiagnosisKeys(
+                    enfClient.provideDiagnosisKeys(
                         googleFileList,
                         ApplicationConfigurationService.asyncRetrieveExposureConfiguration(),
                         token!!
diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/TestRiskLevelCalculationFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/TestRiskLevelCalculationFragment.kt
index 6172b4f2f..b3ff3300d 100644
--- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/TestRiskLevelCalculationFragment.kt
+++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/TestRiskLevelCalculationFragment.kt
@@ -11,11 +11,9 @@ import androidx.fragment.app.Fragment
 import androidx.fragment.app.activityViewModels
 import androidx.lifecycle.lifecycleScope
 import androidx.lifecycle.viewModelScope
-import com.google.android.gms.nearby.Nearby
 import com.google.android.gms.nearby.exposurenotification.ExposureInformation
 import com.google.zxing.integration.android.IntentIntegrator
 import com.google.zxing.integration.android.IntentResult
-import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.databinding.FragmentTestRiskLevelCalculationBinding
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.TransactionException
@@ -38,6 +36,7 @@ import de.rki.coronawarnapp.ui.viewmodel.SettingsViewModel
 import de.rki.coronawarnapp.ui.viewmodel.SubmissionViewModel
 import de.rki.coronawarnapp.ui.viewmodel.TracingViewModel
 import de.rki.coronawarnapp.util.KeyFileHelper
+import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.security.SecurityHelper
 import kotlinx.android.synthetic.deviceForTesters.fragment_test_risk_level_calculation.*
 import kotlinx.coroutines.Dispatchers
@@ -63,8 +62,8 @@ class TestRiskLevelCalculationFragment : Fragment() {
     private var binding: FragmentTestRiskLevelCalculationBinding by viewLifecycle()
 
     // reference to the client from the Google framework with the given application context
-    private val exposureNotificationClient by lazy {
-        Nearby.getExposureNotificationClient(CoronaWarnApplication.getAppContext())
+    private val enfClient by lazy {
+        AppInjector.component.enfClient
     }
 
     override fun onCreateView(
@@ -214,7 +213,7 @@ class TestRiskLevelCalculationFragment : Fragment() {
                 Timber.i("Provide ${googleFileList.count()} files with ${appleKeyList.size} keys with token $token")
                 try {
                     // only testing implementation: this is used to wait for the broadcastreceiver of the OS / EN API
-                    InternalExposureNotificationClient.asyncProvideDiagnosisKeys(
+                    enfClient.provideDiagnosisKeys(
                         googleFileList,
                         ApplicationConfigurationService.asyncRetrieveExposureConfiguration(),
                         token
@@ -340,7 +339,7 @@ class TestRiskLevelCalculationFragment : Fragment() {
 
     suspend fun asyncGetExposureInformation(token: String): List<ExposureInformation> =
         suspendCoroutine { cont ->
-            exposureNotificationClient.getExposureInformation(token)
+            enfClient.internalClient.getExposureInformation(token)
                 .addOnSuccessListener {
                     cont.resume(it)
                 }.addOnFailureListener {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
new file mode 100644
index 000000000..526b5e052
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClient.kt
@@ -0,0 +1,35 @@
+@file:Suppress("DEPRECATION")
+
+package de.rki.coronawarnapp.nearby
+
+import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ENFClient @Inject constructor(
+    private val googleENFClient: ExposureNotificationClient,
+    private val diagnosisKeyProvider: DiagnosisKeyProvider
+) : DiagnosisKeyProvider {
+
+    // TODO Remove this once we no longer need direct access to the ENF Client,
+    // i.e. in **[InternalExposureNotificationClient]**
+    internal val internalClient: ExposureNotificationClient
+        get() = googleENFClient
+
+    override suspend fun provideDiagnosisKeys(
+        keyFiles: Collection<File>,
+        configuration: ExposureConfiguration?,
+        token: String
+    ): Boolean {
+        Timber.d(
+            "asyncProvideDiagnosisKeys(keyFiles=%s, configuration=%s, token=%s)",
+            keyFiles, configuration, token
+        )
+        return diagnosisKeyProvider.provideDiagnosisKeys(keyFiles, configuration, token)
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClientLocalData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClientLocalData.kt
new file mode 100644
index 000000000..26564ab4a
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFClientLocalData.kt
@@ -0,0 +1,34 @@
+package de.rki.coronawarnapp.nearby
+
+import android.content.Context
+import androidx.core.content.edit
+import org.joda.time.Instant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class ENFClientLocalData @Inject constructor(
+    private val context: Context
+) {
+
+    private val prefs by lazy {
+        context.getSharedPreferences("enfclient_localdata", Context.MODE_PRIVATE)
+    }
+
+    var lastQuotaResetAt: Instant
+        get() = Instant.ofEpochMilli(prefs.getLong(PKEY_QUOTA_LAST_RESET, 0L))
+        set(value) = prefs.edit(true) {
+            putLong(PKEY_QUOTA_LAST_RESET, value.millis)
+        }
+
+    var currentQuota: Int
+        get() = prefs.getInt(PKEY_QUOTA_CURRENT, 0)
+        set(value) = prefs.edit(true) {
+            putInt(PKEY_QUOTA_CURRENT, value)
+        }
+
+    companion object {
+        private const val PKEY_QUOTA_LAST_RESET = "enfclient.quota.lastreset"
+        private const val PKEY_QUOTA_CURRENT = "enfclient.quota.current"
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
new file mode 100644
index 000000000..4b7094c8f
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/ENFModule.kt
@@ -0,0 +1,24 @@
+package de.rki.coronawarnapp.nearby
+
+import android.content.Context
+import com.google.android.gms.nearby.Nearby
+import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import dagger.Module
+import dagger.Provides
+import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DefaultDiagnosisKeyProvider
+import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
+import javax.inject.Singleton
+
+@Module
+class ENFModule {
+
+    @Singleton
+    @Provides
+    fun exposureNotificationClient(context: Context): ExposureNotificationClient =
+        Nearby.getExposureNotificationClient(context)
+
+    @Singleton
+    @Provides
+    fun diagnosisKeySubmitter(submitter: DefaultDiagnosisKeyProvider): DiagnosisKeyProvider =
+        submitter
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationClient.kt
index 020dd1d40..06b33b550 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationClient.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationClient.kt
@@ -1,8 +1,5 @@
 package de.rki.coronawarnapp.nearby
 
-import com.google.android.gms.nearby.Nearby
-import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
-import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration.ExposureConfigurationBuilder
 import com.google.android.gms.nearby.exposurenotification.ExposureSummary
 import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey
 import de.rki.coronawarnapp.CoronaWarnApplication
@@ -10,7 +7,7 @@ import de.rki.coronawarnapp.risk.TimeVariables
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.storage.tracing.TracingIntervalRepository
 import de.rki.coronawarnapp.util.TimeAndDateExtensions.millisecondsToSeconds
-import java.io.File
+import de.rki.coronawarnapp.util.di.AppInjector
 import java.util.Date
 import kotlin.coroutines.resume
 import kotlin.coroutines.resumeWithException
@@ -24,7 +21,7 @@ object InternalExposureNotificationClient {
 
     // reference to the client from the Google framework with the given application context
     private val exposureNotificationClient by lazy {
-        Nearby.getExposureNotificationClient(CoronaWarnApplication.getAppContext())
+        AppInjector.component.enfClient.internalClient
     }
 
     /****************************************************
@@ -101,36 +98,6 @@ object InternalExposureNotificationClient {
             }
     }
 
-    /**
-     * Takes an ExposureConfiguration object. Inserts a list of files that contain key
-     * information into the on-device database. Provide the keys of confirmed cases retrieved
-     * from your internet-accessible server to the Google Play service once requested from the
-     * API. Information about the file format is in the Exposure Key Export File Format and
-     * Verification document that is linked from google.com/covid19/exposurenotifications.
-     *
-     * @param keyFiles
-     * @param configuration
-     * @param token
-     * @return
-     */
-    suspend fun asyncProvideDiagnosisKeys(
-        keyFiles: Collection<File>,
-        configuration: ExposureConfiguration?,
-        token: String
-    ): Void = suspendCoroutine { cont ->
-        val exposureConfiguration = configuration ?: ExposureConfigurationBuilder().build()
-        exposureNotificationClient.provideDiagnosisKeys(
-            keyFiles.toList(),
-            exposureConfiguration,
-            token
-        )
-            .addOnSuccessListener {
-                cont.resume(it)
-            }.addOnFailureListener {
-                cont.resumeWithException(it)
-            }
-    }
-
     /**
      * Retrieves key history from the data store on the device for uploading to your
      * internet-accessible server. Calling this method prompts Google Play services to display
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt
new file mode 100644
index 000000000..bca8dfdd4
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt
@@ -0,0 +1,106 @@
+@file:Suppress("DEPRECATION")
+
+package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider
+
+import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import de.rki.coronawarnapp.util.GoogleAPIVersion
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+import javax.inject.Singleton
+import kotlin.coroutines.resume
+import kotlin.coroutines.resumeWithException
+import kotlin.coroutines.suspendCoroutine
+
+@Singleton
+class DefaultDiagnosisKeyProvider @Inject constructor(
+    private val googleAPIVersion: GoogleAPIVersion,
+    private val submissionQuota: SubmissionQuota,
+    private val enfClient: ExposureNotificationClient
+) : DiagnosisKeyProvider {
+
+    override suspend fun provideDiagnosisKeys(
+        keyFiles: Collection<File>,
+        configuration: ExposureConfiguration?,
+        token: String
+    ): Boolean {
+        return try {
+            if (keyFiles.isEmpty()) {
+                Timber.d("No key files submitted, returning early.")
+                return true
+            }
+
+            val usedConfiguration = if (configuration == null) {
+                Timber.w("Passed configuration was NULL, creating fallback.")
+                ExposureConfiguration.ExposureConfigurationBuilder().build()
+            } else {
+                configuration
+            }
+
+            if (googleAPIVersion.isAtLeast(GoogleAPIVersion.V16)) {
+                provideKeys(keyFiles, usedConfiguration, token)
+            } else {
+                provideKeysLegacy(keyFiles, usedConfiguration, token)
+            }
+        } catch (e: Exception) {
+            Timber.e(
+                e, "Error during provideDiagnosisKeys(keyFiles=%s, configuration=%s, token=%s)",
+                keyFiles, configuration, token
+            )
+            throw e
+        }
+    }
+
+    private suspend fun provideKeys(
+        files: Collection<File>,
+        configuration: ExposureConfiguration,
+        token: String
+    ): Boolean {
+        Timber.d("Using non-legacy key provision.")
+
+        if (!submissionQuota.consumeQuota(1)) {
+            Timber.w("Not enough quota available.")
+            // TODO Currently only logging, we'll be more strict in a future release
+            // return false
+        }
+
+        performSubmission(files, configuration, token)
+        return true
+    }
+
+    /**
+     * We use Batch Size 1 and thus submit multiple times to the API.
+     * This means that instead of directly submitting all files at once, we have to split up
+     * our file list as this equals a different batch for Google every time.
+     */
+    private suspend fun provideKeysLegacy(
+        keyFiles: Collection<File>,
+        configuration: ExposureConfiguration,
+        token: String
+    ): Boolean {
+        Timber.d("Using LEGACY key provision.")
+
+        if (!submissionQuota.consumeQuota(keyFiles.size)) {
+            Timber.w("Not enough quota available.")
+            // TODO What about proceeding with partial submission?
+            // TODO Currently only logging, we'll be more strict in a future release
+            // return false
+        }
+
+        keyFiles.forEach { performSubmission(listOf(it), configuration, token) }
+        return true
+    }
+
+    private suspend fun performSubmission(
+        keyFiles: Collection<File>,
+        configuration: ExposureConfiguration,
+        token: String
+    ): Void = suspendCoroutine { cont ->
+        Timber.d("Performing key submission.")
+        enfClient
+            .provideDiagnosisKeys(keyFiles.toList(), configuration, token)
+            .addOnSuccessListener { cont.resume(it) }
+            .addOnFailureListener { cont.resumeWithException(it) }
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DiagnosisKeyProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DiagnosisKeyProvider.kt
new file mode 100644
index 000000000..accedeed0
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DiagnosisKeyProvider.kt
@@ -0,0 +1,25 @@
+package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider
+
+import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import java.io.File
+
+interface DiagnosisKeyProvider {
+
+    /**
+     * Takes an ExposureConfiguration object. Inserts a list of files that contain key
+     * information into the on-device database. Provide the keys of confirmed cases retrieved
+     * from your internet-accessible server to the Google Play service once requested from the
+     * API. Information about the file format is in the Exposure Key Export File Format and
+     * Verification document that is linked from google.com/covid19/exposurenotifications.
+     *
+     * @param keyFiles
+     * @param configuration
+     * @param token
+     * @return
+     */
+    suspend fun provideDiagnosisKeys(
+        keyFiles: Collection<File>,
+        configuration: ExposureConfiguration?,
+        token: String
+    ): Boolean
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/SubmissionQuota.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/SubmissionQuota.kt
new file mode 100644
index 000000000..d9bd53506
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/SubmissionQuota.kt
@@ -0,0 +1,91 @@
+package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider
+
+import androidx.annotation.VisibleForTesting
+import de.rki.coronawarnapp.nearby.ENFClientLocalData
+import de.rki.coronawarnapp.util.TimeStamper
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import org.joda.time.DateTimeZone
+import org.joda.time.Duration
+import org.joda.time.Instant
+import timber.log.Timber
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class SubmissionQuota @Inject constructor(
+    private val enfData: ENFClientLocalData,
+    private val timeStamper: TimeStamper
+) {
+
+    private var currentQuota: Int
+        get() = enfData.currentQuota
+        set(value) {
+            enfData.currentQuota = value
+        }
+
+    private var lastQuotaReset: Instant
+        get() = enfData.lastQuotaResetAt
+        set(value) {
+            enfData.lastQuotaResetAt = value
+        }
+
+    private val mutex = Mutex()
+
+    /**
+     * Attempts to consume quota, and returns true if enough quota was available.
+     */
+    suspend fun consumeQuota(wanted: Int): Boolean = mutex.withLock {
+        attemptQuotaReset()
+
+        if (currentQuota < wanted) {
+            Timber.d("Not enough quota: want=%d, have=%d", wanted, currentQuota)
+            return false
+        }
+
+        run {
+            val oldQuota = currentQuota
+            val newQuota = currentQuota - wanted
+            Timber.d("Consuming quota: old=%d, new=%d", oldQuota, newQuota)
+            currentQuota = newQuota
+        }
+        return true
+    }
+
+    /**
+     * Attempts to reset the quota
+     * On initial launch, the lastQuotaReset is set to Instant.EPOCH,
+     * thus the quota will be immediately set to 20.
+     */
+    private fun attemptQuotaReset() {
+        val oldQuota = currentQuota
+        val oldQuotaReset = lastQuotaReset
+
+        val now = timeStamper.nowUTC
+
+        val nextQuotaReset = lastQuotaReset
+            .toDateTime(DateTimeZone.UTC)
+            .withTimeAtStartOfDay()
+            .plus(Duration.standardDays(1))
+
+        if (now.isAfter(nextQuotaReset)) {
+            currentQuota = DEFAULT_QUOTA
+            lastQuotaReset = now
+
+            Timber.i(
+                "Quota reset: oldQuota=%d, lastReset=%s -> newQuota=%d, thisReset=%s",
+                oldQuota, oldQuotaReset, currentQuota, now
+            )
+        } else {
+            Timber.d(
+                "No new quota available (now=%s, availableAt=%s)",
+                now, nextQuotaReset
+            )
+        }
+    }
+
+    companion object {
+        @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+        internal const val DEFAULT_QUOTA = 20
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
index 48bc37e40..889ab5669 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt
@@ -6,7 +6,6 @@ import de.rki.coronawarnapp.CoronaWarnApplication
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.risk.RiskLevel
 import de.rki.coronawarnapp.util.security.SecurityHelper.globalEncryptedSharedPreferencesInstance
-import org.joda.time.Instant
 import java.util.Date
 
 /**
@@ -19,11 +18,6 @@ object LocalData {
 
     private val TAG: String? = LocalData::class.simpleName
 
-    private const val PREFERENCE_NEXT_TIME_RATE_LIMITING_UNLOCKS =
-        "preference_next_time_rate_limiting_unlocks"
-    private const val PREFERENCE_GOOGLE_API_PROVIDE_DIAGNOSIS_KEYS_CALL_COUNT =
-        "preference_google_api_provide_diagnosis_keys_call_count"
-
     /****************************************************
      * ONBOARDING DATA
      ****************************************************/
@@ -396,40 +390,6 @@ object LocalData {
         }
     }
 
-    var nextTimeRateLimitingUnlocks: Instant
-        get() {
-            return Instant.ofEpochMilli(
-                getSharedPreferenceInstance().getLong(
-                    PREFERENCE_NEXT_TIME_RATE_LIMITING_UNLOCKS,
-                    0L
-                )
-            )
-        }
-        set(value) {
-            getSharedPreferenceInstance().edit(true) {
-                putLong(
-                    PREFERENCE_NEXT_TIME_RATE_LIMITING_UNLOCKS,
-                    value.millis
-                )
-            }
-        }
-
-    var googleAPIProvideDiagnosisKeysCallCount: Int
-        get() {
-            return getSharedPreferenceInstance().getInt(
-                PREFERENCE_GOOGLE_API_PROVIDE_DIAGNOSIS_KEYS_CALL_COUNT,
-                0
-            )
-        }
-        set(value) {
-            getSharedPreferenceInstance().edit(true) {
-                putInt(
-                    PREFERENCE_GOOGLE_API_PROVIDE_DIAGNOSIS_KEYS_CALL_COUNT,
-                    value
-                )
-            }
-        }
-
     /**
      * Gets the last time of successful risk level calculation as long
      * from the EncryptedSharedPrefs
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisInjectionHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisInjectionHelper.kt
index 69598eedc..38ff9dc34 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisInjectionHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisInjectionHelper.kt
@@ -1,5 +1,6 @@
 package de.rki.coronawarnapp.transaction
 
+import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.util.GoogleAPIVersion
 import javax.inject.Inject
 import javax.inject.Singleton
@@ -8,5 +9,6 @@ import javax.inject.Singleton
 @Singleton
 data class RetrieveDiagnosisInjectionHelper @Inject constructor(
     val transactionScope: TransactionCoroutineScope,
-    val googleAPIVersion: GoogleAPIVersion
+    val googleAPIVersion: GoogleAPIVersion,
+    val cwaEnfClient: ENFClient
 )
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
index c37880ad2..a9dba3279 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt
@@ -20,6 +20,7 @@
 package de.rki.coronawarnapp.transaction
 
 import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
 import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.storage.FileStorageHelper
@@ -28,23 +29,17 @@ import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.Retriev
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.CLOSE
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.FETCH_DATE_UPDATE
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.FILES_FROM_WEB_REQUESTS
-import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.QUOTA_CALCULATION
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.RETRIEVE_RISK_SCORE_PARAMS
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.SETUP
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.TOKEN
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.rollback
 import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.start
 import de.rki.coronawarnapp.util.CachedKeyFileHolder
-import de.rki.coronawarnapp.util.GoogleAPIVersion
-import de.rki.coronawarnapp.util.GoogleQuotaCalculator
-import de.rki.coronawarnapp.util.QuotaCalculator
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.worker.BackgroundWorkHelper
 import org.joda.time.DateTime
 import org.joda.time.DateTimeZone
-import org.joda.time.Duration
 import org.joda.time.Instant
-import org.joda.time.chrono.GJChronology
 import timber.log.Timber
 import java.io.File
 import java.util.Date
@@ -95,9 +90,6 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
         /** Initial Setup of the Transaction and Transaction ID Generation and Date Lock */
         SETUP,
 
-        /** calculates the Quota so that the rate limiting is caught gracefully*/
-        QUOTA_CALCULATION,
-
         /** Initialisation of the identifying token used during the entire transaction */
         TOKEN,
 
@@ -126,25 +118,12 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
     /** atomic reference for the rollback value for created files during the transaction */
     private val exportFilesForRollback = AtomicReference<List<File>>()
 
-    private val progressTowardsQuotaForRollback = AtomicReference<Int>()
-
     private val transactionScope: TransactionCoroutineScope by lazy {
         AppInjector.component.transRetrieveKeysInjection.transactionScope
     }
 
-    private const val QUOTA_RESET_PERIOD_IN_HOURS = 24
-
-    private val quotaCalculator: QuotaCalculator<Int> = GoogleQuotaCalculator(
-        incrementByAmount = 14,
-        quotaLimit = 20,
-        quotaResetPeriod = Duration.standardHours(QUOTA_RESET_PERIOD_IN_HOURS.toLong()),
-        quotaTimeZone = DateTimeZone.UTC,
-        quotaChronology = GJChronology.getInstanceUTC()
-    )
-
-    private val googleAPIVersion: GoogleAPIVersion by lazy {
-        AppInjector.component.transRetrieveKeysInjection.googleAPIVersion
-    }
+    private val enfClient: ENFClient
+        get() = AppInjector.component.transRetrieveKeysInjection.cwaEnfClient
 
     suspend fun startWithConstraints() {
         val currentDate = DateTime(Instant.now(), DateTimeZone.UTC)
@@ -152,7 +131,6 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
             LocalData.lastTimeDiagnosisKeysFromServerFetch(),
             DateTimeZone.UTC
         )
-
         if (LocalData.lastTimeDiagnosisKeysFromServerFetch() == null ||
             currentDate.withTimeAtStartOfDay() != lastFetch.withTimeAtStartOfDay()
         ) {
@@ -181,48 +159,28 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
          ****************************************************/
         val currentDate = executeSetup()
 
-        /****************************************************
-         * CALCULATE QUOTA FOR PROVIDE DIAGNOSIS KEYS
-         ****************************************************/
-        val hasExceededQuota = executeQuotaCalculation()
-
-        // When we are above the Quote, cancel the execution entirely
-        if (hasExceededQuota) {
-            Timber.tag(TAG).w("above quota, skipping RetrieveDiagnosisKeys")
-            executeClose()
-            return@lockAndExecute
-        }
-
         /****************************************************
          * RETRIEVE TOKEN
          ****************************************************/
         val token = executeToken()
 
-        /****************************************************
-         * RETRIEVE RISK SCORE PARAMETERS
-         ****************************************************/
+        // RETRIEVE RISK SCORE PARAMETERS
         val exposureConfiguration = executeRetrieveRiskScoreParams()
 
-        /****************************************************
-         * FILES FROM WEB REQUESTS
-         ****************************************************/
-        val keyFiles = executeFetchKeyFilesFromServer(currentDate)
-
-        if (keyFiles.isNotEmpty()) {
-            /****************************************************
-             * SUBMIT FILES TO API
-             ****************************************************/
-            executeAPISubmission(token, keyFiles, exposureConfiguration)
-        } else {
-            Timber.tag(TAG).w("no key files, skipping submission to internal API.")
+        val availableKeyFiles = executeFetchKeyFilesFromServer(currentDate)
+
+        if (availableKeyFiles.isEmpty()) {
+            Timber.tag(TAG).w("No keyfiles were available!")
         }
-        /****************************************************
-         * Fetch Date Update
-         ****************************************************/
-        executeFetchDateUpdate(currentDate)
-        /****************************************************
-         * CLOSE TRANSACTION
-         ****************************************************/
+
+        val isSubmissionSuccessful = executeAPISubmission(
+            exportFiles = availableKeyFiles,
+            exposureConfiguration = exposureConfiguration,
+            token = token
+        )
+
+        if (isSubmissionSuccessful) executeFetchDateUpdate(currentDate)
+
         executeClose()
     }
 
@@ -235,10 +193,6 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
             if (TOKEN.isInStateStack()) {
                 rollbackToken()
             }
-            // we reset the quota only if the submission has not happened yet
-            if (QUOTA_CALCULATION.isInStateStack() && !API_SUBMISSION.isInStateStack()) {
-                rollbackProgressTowardsQuota()
-            }
         } catch (e: Exception) {
             // We handle every exception through a RollbackException to make sure that a single EntryPoint
             // is available for the caller.
@@ -256,11 +210,6 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
         LocalData.googleApiToken(googleAPITokenForRollback.get())
     }
 
-    private fun rollbackProgressTowardsQuota() {
-        Timber.tag(TAG).v("rollback $QUOTA_CALCULATION")
-        quotaCalculator.resetProgressTowardsQuota(progressTowardsQuotaForRollback.get())
-    }
-
     /**
      * Executes the INIT Transaction State
      */
@@ -271,16 +220,6 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
         currentDate
     }
 
-    /**
-     * Executes the QUOTA_CALCULATION Transaction State
-     */
-    private suspend fun executeQuotaCalculation() = executeState(
-        QUOTA_CALCULATION
-    ) {
-        progressTowardsQuotaForRollback.set(quotaCalculator.getProgressTowardsQuota())
-        quotaCalculator.calculateQuota()
-    }
-
     /**
      * Executes the TOKEN Transaction State
      */
@@ -309,34 +248,19 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
         CachedKeyFileHolder.asyncFetchFiles(currentDate)
     }
 
-    /**
-     * Executes the API_SUBMISSION Transaction State
-     *
-     * We currently use Batch Size 1 and thus submit multiple times to the API.
-     * This means that instead of directly submitting all files at once, we have to split up
-     * our file list as this equals a different batch for Google every time.
-     */
     private suspend fun executeAPISubmission(
         token: String,
         exportFiles: Collection<File>,
         exposureConfiguration: ExposureConfiguration?
-    ) = executeState(API_SUBMISSION) {
-        if (googleAPIVersion.isAtLeast(GoogleAPIVersion.V16)) {
-            InternalExposureNotificationClient.asyncProvideDiagnosisKeys(
-                exportFiles,
-                exposureConfiguration,
-                token
-            )
-        } else {
-            exportFiles.forEach { batch ->
-                InternalExposureNotificationClient.asyncProvideDiagnosisKeys(
-                    listOf(batch),
-                    exposureConfiguration,
-                    token
-                )
-            }
-        }
-        Timber.tag(TAG).d("Diagnosis Keys provided successfully, Token: $token")
+    ): Boolean = executeState(API_SUBMISSION) {
+        Timber.tag(TAG).d("Attempting submission to ENF")
+        val success = enfClient.provideDiagnosisKeys(
+            keyFiles = exportFiles,
+            configuration = exposureConfiguration,
+            token = token
+        )
+        Timber.tag(TAG).d("Diagnosis Keys provided (success=%s, token=%s)", success, token)
+        return@executeState success
     }
 
     /**
@@ -345,6 +269,7 @@ object RetrieveDiagnosisKeysTransaction : Transaction() {
     private suspend fun executeFetchDateUpdate(
         currentDate: Date
     ) = executeState(FETCH_DATE_UPDATE) {
+        Timber.tag(TAG).d("executeFetchDateUpdate(currentDate=%s)", currentDate)
         LocalData.lastTimeDiagnosisKeysFromServerFetch(currentDate)
     }
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/GoogleQuotaCalculator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/GoogleQuotaCalculator.kt
deleted file mode 100644
index e686eec75..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/GoogleQuotaCalculator.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package de.rki.coronawarnapp.util
-
-import de.rki.coronawarnapp.storage.LocalData
-import org.joda.time.Chronology
-import org.joda.time.DateTime
-import org.joda.time.DateTimeZone
-import org.joda.time.Duration
-import org.joda.time.Instant
-import timber.log.Timber
-
-/**
- * This Calculator class takes multiple parameters to check if the Google API
- * can be called or the Rate Limit has been reached. The Quota is expected to reset at
- * the start of the day in the given timeZone and Chronology
- *
- * @property incrementByAmount The amount of Quota Calls to increment per Call
- * @property quotaLimit The maximum amount of Quota Calls allowed before Rate Limiting
- * @property quotaResetPeriod The Period after which the Quota Resets
- * @property quotaTimeZone The Timezone to work in
- * @property quotaChronology The Chronology to work in
- */
-class GoogleQuotaCalculator(
-    val incrementByAmount: Int,
-    val quotaLimit: Int,
-    val quotaResetPeriod: Duration,
-    val quotaTimeZone: DateTimeZone,
-    val quotaChronology: Chronology
-) : QuotaCalculator<Int> {
-    override var hasExceededQuota: Boolean = false
-
-    override fun calculateQuota(): Boolean {
-        val oldQuota = LocalData.googleAPIProvideDiagnosisKeysCallCount
-        var currentQuota = oldQuota
-
-        val now = Instant.now()
-        val nextUnlock = LocalData.nextTimeRateLimitingUnlocks
-
-        Timber.v(
-            "calculateQuota() start! (currentQuota=%s, timeNow=%s, timeReset=%s)",
-            oldQuota, now, nextUnlock
-        )
-        if (now.isAfter(nextUnlock)) {
-            LocalData.nextTimeRateLimitingUnlocks = DateTime
-                .now(quotaTimeZone)
-                .withChronology(quotaChronology)
-                .plus(quotaResetPeriod)
-                .withTimeAtStartOfDay()
-                .toInstant()
-            Timber.d("calculateQuota() quota reset to 0.")
-            currentQuota = 0
-        } else {
-            Timber.v("calculateQuota() can't be reset yet.")
-        }
-
-        if (currentQuota <= quotaLimit) {
-            currentQuota += incrementByAmount
-        }
-
-        if (currentQuota != oldQuota) {
-            LocalData.googleAPIProvideDiagnosisKeysCallCount = currentQuota
-        }
-
-        return (currentQuota > quotaLimit).also {
-            hasExceededQuota = it
-            Timber.v(
-                "calculateQuota() done! -> oldQuota=%d, currentQuotaHm=%d, quotaLimit=%d, EXCEEDED=%b",
-                oldQuota, currentQuota, quotaLimit, it
-            )
-        }
-    }
-
-    override fun resetProgressTowardsQuota(newProgress: Int) {
-        if (newProgress > quotaLimit) {
-            Timber.w("cannot reset progress to a value higher than the quota limit")
-            return
-        }
-        if (newProgress % incrementByAmount != 0) {
-            Timber.e("supplied progress is no multiple of $incrementByAmount")
-            return
-        }
-        LocalData.googleAPIProvideDiagnosisKeysCallCount = newProgress
-        hasExceededQuota = false
-        Timber.d("resetProgressTowardsQuota(newProgress=%d) done", newProgress)
-    }
-
-    override fun getProgressTowardsQuota(): Int = LocalData.googleAPIProvideDiagnosisKeysCallCount
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/QuotaCalculator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/QuotaCalculator.kt
deleted file mode 100644
index 682f4a600..000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/QuotaCalculator.kt
+++ /dev/null
@@ -1,29 +0,0 @@
-package de.rki.coronawarnapp.util
-
-/**
- * Class to check if a Quota has been reached based on the calculation done inside
- * the Calculator
- *
- */
-interface QuotaCalculator<T> {
-    val hasExceededQuota: Boolean
-
-    /**
-     * This function is called to recalculate an old quota score
-     */
-    fun calculateQuota(): Boolean
-
-    /**
-     * Reset the quota progress
-     *
-     * @param newProgress new progress towards the quota
-     */
-    fun resetProgressTowardsQuota(newProgress: T)
-
-    /**
-     * Retrieve the current progress towards the quota
-     *
-     * @return current progress count
-     */
-    fun getProgressTowardsQuota(): T
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeStamper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeStamper.kt
new file mode 100644
index 000000000..fcbfc75d6
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeStamper.kt
@@ -0,0 +1,12 @@
+package de.rki.coronawarnapp.util
+
+import org.joda.time.Instant
+import javax.inject.Inject
+import javax.inject.Singleton
+
+@Singleton
+class TimeStamper @Inject constructor() {
+
+    val nowUTC: Instant
+        get() = Instant.now()
+}
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 b175c93c8..5dfef7317 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
@@ -5,6 +5,8 @@ import dagger.Component
 import dagger.android.AndroidInjector
 import dagger.android.support.AndroidSupportInjectionModule
 import de.rki.coronawarnapp.CoronaWarnApplication
+import de.rki.coronawarnapp.nearby.ENFClient
+import de.rki.coronawarnapp.nearby.ENFModule
 import de.rki.coronawarnapp.receiver.ReceiverBinder
 import de.rki.coronawarnapp.risk.RiskModule
 import de.rki.coronawarnapp.service.ServiceBinder
@@ -28,7 +30,8 @@ import javax.inject.Singleton
         ActivityBinder::class,
         RiskModule::class,
         UtilModule::class,
-        DeviceModule::class
+        DeviceModule::class,
+        ENFModule::class
     ]
 )
 interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
@@ -42,6 +45,8 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> {
 
     val settingsRepository: SettingsRepository
 
+    val enfClient: ENFClient
+
     @Component.Factory
     interface Factory {
         fun create(@BindsInstance app: CoronaWarnApplication): ApplicationComponent
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt
index c7e797fa3..9e9fb0a70 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt
@@ -5,6 +5,7 @@ import androidx.work.Constraints
 import androidx.work.NetworkType
 import de.rki.coronawarnapp.notification.NotificationHelper
 import de.rki.coronawarnapp.storage.LocalData
+import timber.log.Timber
 import kotlin.random.Random
 
 /**
@@ -90,6 +91,7 @@ object BackgroundWorkHelper {
      * @see LocalData.backgroundNotification()
      */
     fun sendDebugNotification(title: String, content: String) {
+        Timber.d("sendDebugNotification(title=%s, content=%s)", title, content)
         if (!LocalData.backgroundNotification()) return
         NotificationHelper.sendNotification(title, content, NotificationCompat.PRIORITY_HIGH, true)
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt
index e45eb28ba..95cf66a25 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalOneTimeWorker.kt
@@ -15,10 +15,6 @@ import timber.log.Timber
 class DiagnosisKeyRetrievalOneTimeWorker(val context: Context, workerParams: WorkerParameters) :
     CoroutineWorker(context, workerParams) {
 
-    companion object {
-        private val TAG: String? = DiagnosisKeyRetrievalOneTimeWorker::class.simpleName
-    }
-
     /**
      * Work execution
      *
@@ -27,28 +23,40 @@ class DiagnosisKeyRetrievalOneTimeWorker(val context: Context, workerParams: Wor
      * @see RetrieveDiagnosisKeysTransaction
      */
     override suspend fun doWork(): Result {
-        Timber.d("Background job started. Run attempt: $runAttemptCount ")
+        Timber.d("$id: doWork() started. Run attempt: $runAttemptCount")
+
         BackgroundWorkHelper.sendDebugNotification(
-            "KeyOneTime Executing: Start", "KeyOneTime started. Run attempt: $runAttemptCount ")
+            "KeyOneTime Executing: Start", "KeyOneTime started. Run attempt: $runAttemptCount "
+        )
 
         var result = Result.success()
         try {
             RetrieveDiagnosisKeysTransaction.startWithConstraints()
         } catch (e: Exception) {
+            Timber.w(
+                e, "$id: Error during RetrieveDiagnosisKeysTransaction.startWithConstraints()."
+            )
+
             if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
+                Timber.w(e, "$id: Retry attempts exceeded.")
 
                 BackgroundWorkHelper.sendDebugNotification(
-                    "KeyOneTime Executing: Failure", "KeyOneTime failed with $runAttemptCount attempts")
+                    "KeyOneTime Executing: Failure",
+                    "KeyOneTime failed with $runAttemptCount attempts"
+                )
 
                 return Result.failure()
             } else {
+                Timber.d(e, "$id: Retrying.")
                 result = Result.retry()
             }
         }
 
         BackgroundWorkHelper.sendDebugNotification(
-            "KeyOneTime Executing: End", "KeyOneTime result: $result ")
+            "KeyOneTime Executing: End", "KeyOneTime result: $result "
+        )
 
+        Timber.d("$id: doWork() finished with %s", result)
         return result
     }
 }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
index 79f091610..f7baa0f08 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisKeyRetrievalPeriodicWorker.kt
@@ -15,10 +15,6 @@ import timber.log.Timber
 class DiagnosisKeyRetrievalPeriodicWorker(val context: Context, workerParams: WorkerParameters) :
     CoroutineWorker(context, workerParams) {
 
-    companion object {
-        private val TAG: String? = DiagnosisKeyRetrievalPeriodicWorker::class.simpleName
-    }
-
     /**
      * Work execution
      *
@@ -28,28 +24,40 @@ class DiagnosisKeyRetrievalPeriodicWorker(val context: Context, workerParams: Wo
      * @see BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()
      */
     override suspend fun doWork(): Result {
-        Timber.d("Background job started. Run attempt: $runAttemptCount")
+        Timber.d("$id: doWork() started. Run attempt: $runAttemptCount")
+
         BackgroundWorkHelper.sendDebugNotification(
-            "KeyPeriodic Executing: Start", "KeyPeriodic started. Run attempt: $runAttemptCount ")
+            "KeyPeriodic Executing: Start", "KeyPeriodic started. Run attempt: $runAttemptCount"
+        )
 
         var result = Result.success()
         try {
             BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()
         } catch (e: Exception) {
+            Timber.w(
+                e, "$id: Error during BackgroundWorkScheduler.scheduleDiagnosisKeyOneTimeWork()."
+            )
+
             if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) {
+                Timber.w(e, "$id: Retry attempts exceeded.")
 
                 BackgroundWorkHelper.sendDebugNotification(
-                    "KeyPeriodic Executing: Failure", "KeyPeriodic failed with $runAttemptCount attempts")
+                    "KeyPeriodic Executing: Failure",
+                    "KeyPeriodic failed with $runAttemptCount attempts"
+                )
 
                 return Result.failure()
             } else {
+                Timber.d(e, "$id: Retrying.")
                 result = Result.retry()
             }
         }
 
         BackgroundWorkHelper.sendDebugNotification(
-            "KeyPeriodic Executing: End", "KeyPeriodic result: $result ")
+            "KeyPeriodic Executing: End", "KeyPeriodic result: $result "
+        )
 
+        Timber.d("$id: doWork() finished with %s", result)
         return result
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
new file mode 100644
index 000000000..5a91e9b4e
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/ENFClientTest.kt
@@ -0,0 +1,76 @@
+package de.rki.coronawarnapp.nearby
+
+import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import java.io.File
+
+@Suppress("DEPRECATION")
+class ENFClientTest : BaseTest() {
+
+    @MockK
+    lateinit var googleENFClient: ExposureNotificationClient
+
+    @MockK
+    lateinit var diagnosisKeyProvider: DiagnosisKeyProvider
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns true
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createClient() = ENFClient(
+        googleENFClient = googleENFClient,
+        diagnosisKeyProvider = diagnosisKeyProvider
+    )
+
+    @Test
+    fun `internal enf client is available as workaround`() {
+        val client = createClient()
+        client.internalClient shouldBe googleENFClient
+    }
+
+    @Test
+    fun `provide diagnosis key call is forwarded to the right module`() {
+        val client = createClient()
+        val keyFiles = listOf(File("test"))
+        val configuration = mockk<ExposureConfiguration>()
+        val token = "123"
+
+        coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns true
+        runBlocking {
+            client.provideDiagnosisKeys(keyFiles, configuration, token) shouldBe true
+        }
+
+        coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns false
+        runBlocking {
+            client.provideDiagnosisKeys(keyFiles, configuration, token) shouldBe false
+        }
+
+        coVerify(exactly = 2) {
+            diagnosisKeyProvider.provideDiagnosisKeys(
+                keyFiles,
+                configuration,
+                token
+            )
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProviderTest.kt
new file mode 100644
index 000000000..214168036
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProviderTest.kt
@@ -0,0 +1,200 @@
+@file:Suppress("DEPRECATION")
+
+package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider
+
+import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient
+import com.google.android.gms.tasks.OnSuccessListener
+import com.google.android.gms.tasks.Task
+import de.rki.coronawarnapp.util.GoogleAPIVersion
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
+import kotlinx.coroutines.runBlocking
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+import java.io.File
+
+class DefaultDiagnosisKeyProviderTest : BaseTest() {
+    @MockK
+    lateinit var googleENFClient: ExposureNotificationClient
+
+    @MockK
+    lateinit var googleAPIVersion: GoogleAPIVersion
+
+    @MockK
+    lateinit var submissionQuota: SubmissionQuota
+
+    @MockK
+    lateinit var exampleConfiguration: ExposureConfiguration
+    private val exampleKeyFiles = listOf(File("file1"), File("file2"))
+    private val exampleToken = "123e4567-e89b-12d3-a456-426655440000"
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        coEvery { submissionQuota.consumeQuota(any()) } returns true
+
+        val taskResult = mockk<Task<Void>>()
+        every { taskResult.addOnSuccessListener(any()) } answers {
+            val listener = arg<OnSuccessListener<Nothing>>(0)
+            listener.onSuccess(null)
+            taskResult
+        }
+        every { taskResult.addOnFailureListener(any()) } returns taskResult
+        coEvery { googleENFClient.provideDiagnosisKeys(any(), any(), any()) } returns taskResult
+
+        coEvery { googleAPIVersion.isAtLeast(GoogleAPIVersion.V16) } returns true
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createProvider() = DefaultDiagnosisKeyProvider(
+        googleAPIVersion = googleAPIVersion,
+        submissionQuota = submissionQuota,
+        enfClient = googleENFClient
+    )
+
+    @Test
+    fun `legacy key provision is used on older ENF versions`() {
+        coEvery { googleAPIVersion.isAtLeast(GoogleAPIVersion.V16) } returns false
+
+        val provider = createProvider()
+
+        runBlocking {
+            provider.provideDiagnosisKeys(exampleKeyFiles, exampleConfiguration, exampleToken)
+        }
+
+        coVerify(exactly = 0) {
+            googleENFClient.provideDiagnosisKeys(
+                exampleKeyFiles, exampleConfiguration, exampleToken
+            )
+        }
+
+        coVerify(exactly = 1) {
+            googleENFClient.provideDiagnosisKeys(
+                listOf(exampleKeyFiles[0]), exampleConfiguration, exampleToken
+            )
+            googleENFClient.provideDiagnosisKeys(
+                listOf(exampleKeyFiles[1]), exampleConfiguration, exampleToken
+            )
+            submissionQuota.consumeQuota(2)
+        }
+    }
+
+    @Test
+    fun `normal key provision is used on newer ENF versions`() {
+        coEvery { googleAPIVersion.isAtLeast(GoogleAPIVersion.V16) } returns true
+
+        val provider = createProvider()
+
+        runBlocking {
+            provider.provideDiagnosisKeys(exampleKeyFiles, exampleConfiguration, exampleToken)
+        }
+
+        coVerify(exactly = 1) {
+            googleENFClient.provideDiagnosisKeys(any(), any(), any())
+            googleENFClient.provideDiagnosisKeys(
+                exampleKeyFiles, exampleConfiguration, exampleToken
+            )
+            submissionQuota.consumeQuota(1)
+        }
+    }
+
+    @Test
+    fun `passing an a null configuration leads to constructing a fallback from defaults`() {
+        coEvery { googleAPIVersion.isAtLeast(GoogleAPIVersion.V16) } returns true
+
+        val provider = createProvider()
+        val fallback = ExposureConfiguration.ExposureConfigurationBuilder().build()
+
+        runBlocking {
+            provider.provideDiagnosisKeys(exampleKeyFiles, null, exampleToken)
+        }
+
+        coVerify(exactly = 1) {
+            googleENFClient.provideDiagnosisKeys(any(), any(), any())
+            googleENFClient.provideDiagnosisKeys(exampleKeyFiles, fallback, exampleToken)
+        }
+    }
+
+    @Test
+    fun `passing an a null configuration leads to constructing a fallback from defaults, legacy`() {
+        coEvery { googleAPIVersion.isAtLeast(GoogleAPIVersion.V16) } returns false
+
+        val provider = createProvider()
+        val fallback = ExposureConfiguration.ExposureConfigurationBuilder().build()
+
+        runBlocking {
+            provider.provideDiagnosisKeys(exampleKeyFiles, null, exampleToken)
+        }
+
+        coVerify(exactly = 1) {
+            googleENFClient.provideDiagnosisKeys(
+                listOf(exampleKeyFiles[0]), fallback, exampleToken
+            )
+            googleENFClient.provideDiagnosisKeys(
+                listOf(exampleKeyFiles[1]), fallback, exampleToken
+            )
+            submissionQuota.consumeQuota(2)
+        }
+    }
+
+    @Test
+    fun `quota is consumed silenently`() {
+        coEvery { googleAPIVersion.isAtLeast(GoogleAPIVersion.V16) } returns true
+        coEvery { submissionQuota.consumeQuota(any()) } returns false
+
+        val provider = createProvider()
+
+        runBlocking {
+            provider.provideDiagnosisKeys(exampleKeyFiles, exampleConfiguration, exampleToken)
+        }
+
+        coVerify(exactly = 1) {
+            googleENFClient.provideDiagnosisKeys(any(), any(), any())
+            googleENFClient.provideDiagnosisKeys(
+                exampleKeyFiles, exampleConfiguration, exampleToken
+            )
+            submissionQuota.consumeQuota(1)
+        }
+    }
+
+    @Test
+    fun `quota is consumed silently, legacy`() {
+        coEvery { googleAPIVersion.isAtLeast(GoogleAPIVersion.V16) } returns false
+        coEvery { submissionQuota.consumeQuota(any()) } returns false
+
+        val provider = createProvider()
+
+        runBlocking {
+            provider.provideDiagnosisKeys(exampleKeyFiles, exampleConfiguration, exampleToken)
+        }
+
+        coVerify(exactly = 0) {
+            googleENFClient.provideDiagnosisKeys(
+                exampleKeyFiles, exampleConfiguration, exampleToken
+            )
+        }
+
+        coVerify(exactly = 1) {
+            googleENFClient.provideDiagnosisKeys(
+                listOf(exampleKeyFiles[0]), exampleConfiguration, exampleToken
+            )
+            googleENFClient.provideDiagnosisKeys(
+                listOf(exampleKeyFiles[1]), exampleConfiguration, exampleToken
+            )
+            submissionQuota.consumeQuota(2)
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/SubmissionQuotaTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/SubmissionQuotaTest.kt
new file mode 100644
index 000000000..c5d7238a8
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/SubmissionQuotaTest.kt
@@ -0,0 +1,228 @@
+package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider
+
+import de.rki.coronawarnapp.nearby.ENFClientLocalData
+import de.rki.coronawarnapp.util.TimeStamper
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.clearAllMocks
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import io.mockk.verify
+import kotlinx.coroutines.runBlocking
+import org.joda.time.Duration
+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 SubmissionQuotaTest : BaseTest() {
+    @MockK
+    lateinit var enfData: ENFClientLocalData
+
+    @MockK
+    lateinit var timeStamper: TimeStamper
+
+    private var testStorageCurrentQuota = SubmissionQuota.DEFAULT_QUOTA
+    private var testStorageLastQuotaReset = Instant.parse("2020-08-01T01:00:00.000Z")
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+
+        every { enfData.currentQuota = any() } answers {
+            testStorageCurrentQuota = arg(0)
+            Unit
+        }
+        every { enfData.currentQuota } answers {
+            testStorageCurrentQuota
+        }
+        every { enfData.lastQuotaResetAt } answers {
+            testStorageLastQuotaReset
+        }
+        every { enfData.lastQuotaResetAt = any() } answers {
+            testStorageLastQuotaReset = arg(0)
+            Unit
+        }
+        every { timeStamper.nowUTC } returns Instant.parse("2020-08-01T23:00:00.000Z")
+    }
+
+    @AfterEach
+    fun teardown() {
+        clearAllMocks()
+    }
+
+    private fun createQuota() = SubmissionQuota(
+        enfData = enfData,
+        timeStamper = timeStamper
+    )
+
+    @Test
+    fun `first init sets a sane default quota`() {
+        // The default lastQuotaReset is at 0L EPOCH Millis
+        testStorageLastQuotaReset = Instant.EPOCH
+
+        val quota = createQuota()
+
+        runBlocking {
+            quota.consumeQuota(5) shouldBe true
+        }
+
+        coVerify { enfData.currentQuota = 20 }
+
+        // Reset to 20, then consumed 5
+        testStorageCurrentQuota shouldBe 15
+    }
+
+    @Test
+    fun `quota consumption return true if quota was available`() {
+        testStorageCurrentQuota shouldBe 20
+
+        val quota = createQuota()
+
+        runBlocking {
+            quota.consumeQuota(10) shouldBe true
+            quota.consumeQuota(10) shouldBe true
+            quota.consumeQuota(10) shouldBe false
+            quota.consumeQuota(1) shouldBe false
+        }
+
+        verify(exactly = 4) { timeStamper.nowUTC }
+    }
+
+    @Test
+    fun `consumption of 0 quota is handled`() {
+        val quota = createQuota()
+
+        runBlocking {
+            quota.consumeQuota(0) shouldBe true
+            quota.consumeQuota(20) shouldBe true
+            quota.consumeQuota(0) shouldBe true
+            quota.consumeQuota(1) shouldBe false
+        }
+    }
+
+    @Test
+    fun `partial consumption is not possible`() {
+        testStorageCurrentQuota shouldBe 20
+
+        val quota = createQuota()
+
+        runBlocking {
+            quota.consumeQuota(18) shouldBe true
+            quota.consumeQuota(1) shouldBe true
+            quota.consumeQuota(2) shouldBe false
+        }
+    }
+
+    @Test
+    fun `quota consumption automatically fills up quota if possible`() {
+        val quota = createQuota()
+
+        // Reset is at 00:00:00UTC, we trigger at 1 milisecond after midnight
+        val timeTravelTarget = Instant.parse("2020-12-24T00:00:00.001Z")
+
+        runBlocking {
+            quota.consumeQuota(20) shouldBe true
+            quota.consumeQuota(20) shouldBe false
+
+            every { timeStamper.nowUTC } returns timeTravelTarget
+
+            quota.consumeQuota(20) shouldBe true
+            quota.consumeQuota(1) shouldBe false
+        }
+
+        coVerify(exactly = 1) { enfData.currentQuota = 20 }
+        verify(exactly = 4) { timeStamper.nowUTC }
+        verify(exactly = 1) { enfData.lastQuotaResetAt = timeTravelTarget }
+    }
+
+    @Test
+    fun `quota fill up is at midnight`() {
+        testStorageCurrentQuota = 20
+        testStorageLastQuotaReset = Instant.parse("2020-12-24T23:00:00.000Z")
+        val startTime = Instant.parse("2020-12-24T23:59:59.998Z")
+        every { timeStamper.nowUTC } returns startTime
+
+        val quota = createQuota()
+
+        runBlocking {
+            quota.consumeQuota(20) shouldBe true
+            quota.consumeQuota(1) shouldBe false
+
+            every { timeStamper.nowUTC } returns startTime.plus(1)
+            quota.consumeQuota(1) shouldBe false
+
+            every { timeStamper.nowUTC } returns startTime.plus(2)
+            quota.consumeQuota(1) shouldBe false
+
+            every { timeStamper.nowUTC } returns startTime.plus(3)
+            quota.consumeQuota(1) shouldBe true
+
+            every { timeStamper.nowUTC } returns startTime.plus(4)
+            quota.consumeQuota(20) shouldBe false
+
+            every { timeStamper.nowUTC } returns startTime.plus(3).plus(Duration.standardDays(1))
+            quota.consumeQuota(20) shouldBe true
+        }
+    }
+
+    @Test
+    fun `large time gaps are no issue`() {
+        val startTime = Instant.parse("2020-12-24T20:00:00.000Z")
+
+        runBlocking {
+            every { timeStamper.nowUTC } returns startTime
+            val quota = createQuota()
+            quota.consumeQuota(17) shouldBe true
+        }
+
+        runBlocking {
+            every { timeStamper.nowUTC } returns startTime.plus(Duration.standardDays(365))
+            val quota = createQuota()
+            quota.consumeQuota(20) shouldBe true
+            quota.consumeQuota(1) shouldBe false
+        }
+
+        runBlocking {
+            every { timeStamper.nowUTC } returns startTime.plus(Duration.standardDays(365 * 2))
+            val quota = createQuota()
+            quota.consumeQuota(17) shouldBe true
+        }
+        runBlocking {
+            every { timeStamper.nowUTC } returns startTime.plus(Duration.standardDays(365 * 3))
+            val quota = createQuota()
+            quota.consumeQuota(3) shouldBe true
+            quota.consumeQuota(17) shouldBe true
+            quota.consumeQuota(1) shouldBe false
+        }
+    }
+
+    @Test
+    fun `reverse timetravel is handled `() {
+        testStorageLastQuotaReset = Instant.parse("2020-12-24T23:00:00.000Z")
+        val startTime = Instant.parse("2020-12-24T23:59:59.999Z")
+        every { timeStamper.nowUTC } returns startTime
+
+        val quota = createQuota()
+
+        runBlocking {
+            quota.consumeQuota(20) shouldBe true
+            quota.consumeQuota(1) shouldBe false
+
+            // Go forward and get a reset
+            every { timeStamper.nowUTC } returns startTime.plus(Duration.standardHours(1))
+            quota.consumeQuota(20) shouldBe true
+            quota.consumeQuota(1) shouldBe false
+
+            // Go backwards and don't gain a reset
+            every { timeStamper.nowUTC } returns startTime.minus(Duration.standardHours(1))
+            quota.consumeQuota(1) shouldBe false
+
+            // Go forward again, but no new reset happens
+            every { timeStamper.nowUTC } returns startTime.plus(Duration.standardHours(1))
+            quota.consumeQuota(1) shouldBe false
+        }
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
index c2029628c..c030ad1c5 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt
@@ -1,25 +1,27 @@
 package de.rki.coronawarnapp.transaction
 
-import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration
+import de.rki.coronawarnapp.nearby.ENFClient
 import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient
 import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService
 import de.rki.coronawarnapp.storage.LocalData
 import de.rki.coronawarnapp.util.GoogleAPIVersion
 import de.rki.coronawarnapp.util.di.AppInjector
 import de.rki.coronawarnapp.util.di.ApplicationComponent
+import io.mockk.MockKAnnotations
 import io.mockk.Runs
+import io.mockk.clearAllMocks
 import io.mockk.coEvery
+import io.mockk.coVerify
 import io.mockk.coVerifyOrder
 import io.mockk.every
+import io.mockk.impl.annotations.MockK
 import io.mockk.just
 import io.mockk.mockk
 import io.mockk.mockkObject
-import io.mockk.unmockkAll
 import kotlinx.coroutines.runBlocking
-import org.joda.time.Instant
-import org.junit.After
-import org.junit.Before
-import org.junit.Test
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
 import java.io.File
 import java.nio.file.Paths
 import java.util.Date
@@ -30,13 +32,19 @@ import java.util.UUID
  */
 class RetrieveDiagnosisKeysTransactionTest {
 
-    @Before
+    @MockK
+    lateinit var mockEnfClient: ENFClient
+
+    @BeforeEach
     fun setUp() {
+        MockKAnnotations.init(this)
+
         mockkObject(AppInjector)
         val appComponent = mockk<ApplicationComponent>().apply {
             every { transRetrieveKeysInjection } returns RetrieveDiagnosisInjectionHelper(
                 TransactionCoroutineScope(),
-                GoogleAPIVersion()
+                GoogleAPIVersion(),
+                mockEnfClient
             )
         }
         every { AppInjector.component } returns appComponent
@@ -47,47 +55,42 @@ class RetrieveDiagnosisKeysTransactionTest {
         mockkObject(LocalData)
 
         coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true
-        coEvery {
-            InternalExposureNotificationClient.asyncProvideDiagnosisKeys(
-                any(),
-                any(),
-                any()
-            )
-        } returns mockk()
-        coEvery {
-            InternalExposureNotificationClient.getVersion()
-        } returns 17000000L
+
         coEvery { ApplicationConfigurationService.asyncRetrieveExposureConfiguration() } returns mockk()
         every { LocalData.googleApiToken(any()) } just Runs
         every { LocalData.lastTimeDiagnosisKeysFromServerFetch() } returns Date()
         every { LocalData.lastTimeDiagnosisKeysFromServerFetch(any()) } just Runs
         every { LocalData.googleApiToken() } returns UUID.randomUUID().toString()
-        every { LocalData.googleAPIProvideDiagnosisKeysCallCount = any() } just Runs
-        every { LocalData.googleAPIProvideDiagnosisKeysCallCount } returns 0
-        every { LocalData.nextTimeRateLimitingUnlocks = any() } just Runs
-        every { LocalData.nextTimeRateLimitingUnlocks } returns Instant.now()
+    }
+
+    @AfterEach
+    fun cleanUp() {
+        clearAllMocks()
     }
 
     @Test
-    fun testTransactionNoFiles() {
+    fun `unsuccessful ENF submission`() {
+        coEvery { mockEnfClient.provideDiagnosisKeys(any(), any(), any()) } returns false
         coEvery { RetrieveDiagnosisKeysTransaction["executeFetchKeyFilesFromServer"](any<Date>()) } returns listOf<File>()
 
         runBlocking {
             RetrieveDiagnosisKeysTransaction.start()
+        }
 
-            coVerifyOrder {
-                RetrieveDiagnosisKeysTransaction["executeSetup"]()
-                RetrieveDiagnosisKeysTransaction["executeQuotaCalculation"]()
-                RetrieveDiagnosisKeysTransaction["executeRetrieveRiskScoreParams"]()
-                RetrieveDiagnosisKeysTransaction["executeFetchKeyFilesFromServer"](any<Date>())
-                RetrieveDiagnosisKeysTransaction["executeFetchDateUpdate"](any<Date>())
-            }
+        coVerifyOrder {
+            RetrieveDiagnosisKeysTransaction["executeSetup"]()
+            RetrieveDiagnosisKeysTransaction["executeRetrieveRiskScoreParams"]()
+            RetrieveDiagnosisKeysTransaction["executeFetchKeyFilesFromServer"](any<Date>())
+        }
+        coVerify(exactly = 0) {
+            RetrieveDiagnosisKeysTransaction["executeFetchDateUpdate"](any<Date>())
         }
     }
 
     @Test
-    fun testTransactionHasFiles() {
+    fun `successful submission`() {
         val file = Paths.get("src", "test", "resources", "keys.bin").toFile()
+        coEvery { mockEnfClient.provideDiagnosisKeys(listOf(file), any(), any()) } returns true
 
         coEvery { RetrieveDiagnosisKeysTransaction["executeFetchKeyFilesFromServer"](any<Date>()) } returns listOf(
             file
@@ -95,24 +98,14 @@ class RetrieveDiagnosisKeysTransactionTest {
 
         runBlocking {
             RetrieveDiagnosisKeysTransaction.start()
-
-            coVerifyOrder {
-                RetrieveDiagnosisKeysTransaction["executeSetup"]()
-                RetrieveDiagnosisKeysTransaction["executeQuotaCalculation"]()
-                RetrieveDiagnosisKeysTransaction["executeRetrieveRiskScoreParams"]()
-                RetrieveDiagnosisKeysTransaction["executeFetchKeyFilesFromServer"](any<Date>())
-                RetrieveDiagnosisKeysTransaction["executeAPISubmission"](
-                    any<String>(),
-                    listOf(file),
-                    any<ExposureConfiguration>()
-                )
-                RetrieveDiagnosisKeysTransaction["executeFetchDateUpdate"](any<Date>())
-            }
         }
-    }
 
-    @After
-    fun cleanUp() {
-        unmockkAll()
+        coVerifyOrder {
+            RetrieveDiagnosisKeysTransaction["executeSetup"]()
+            RetrieveDiagnosisKeysTransaction["executeRetrieveRiskScoreParams"]()
+            RetrieveDiagnosisKeysTransaction["executeFetchKeyFilesFromServer"](any<Date>())
+            mockEnfClient.provideDiagnosisKeys(listOf(file), any(), any())
+            RetrieveDiagnosisKeysTransaction["executeFetchDateUpdate"](any<Date>())
+        }
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/GoogleQuotaCalculatorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/GoogleQuotaCalculatorTest.kt
deleted file mode 100644
index 693e31871..000000000
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/GoogleQuotaCalculatorTest.kt
+++ /dev/null
@@ -1,289 +0,0 @@
-package de.rki.coronawarnapp.util
-
-import de.rki.coronawarnapp.storage.LocalData
-import io.mockk.every
-import io.mockk.mockkObject
-import io.mockk.unmockkObject
-import org.joda.time.DateTime
-import org.joda.time.DateTimeUtils
-import org.joda.time.DateTimeZone
-import org.joda.time.Duration
-import org.joda.time.Instant
-import org.joda.time.chrono.GJChronology
-import org.junit.jupiter.api.AfterEach
-import org.junit.jupiter.api.Assertions.assertEquals
-import org.junit.jupiter.api.BeforeEach
-import org.junit.jupiter.api.Test
-import org.junit.jupiter.api.assertThrows
-import testhelpers.BaseTest
-import timber.log.Timber
-import java.util.concurrent.atomic.AtomicInteger
-import java.util.concurrent.atomic.AtomicLong
-
-internal class GoogleQuotaCalculatorTest : BaseTest() {
-
-    private val timeInTest = DateTimeUtils.currentTimeMillis()
-
-    private lateinit var classUnderTest: GoogleQuotaCalculator
-    private val nextTimeRateLimitingUnlocksInTesting = AtomicLong()
-    private val googleAPIProvideDiagnosisKeysCallCount = AtomicInteger()
-
-    private val defaultIncrementByAmountInTest = 14
-    private val defaultQuotaLimitInTest = 20
-
-    @BeforeEach
-    fun setUpClassUnderTest() {
-        classUnderTest = GoogleQuotaCalculator(
-            incrementByAmount = defaultIncrementByAmountInTest,
-            quotaLimit = defaultQuotaLimitInTest,
-            quotaResetPeriod = Duration.standardHours(24),
-            quotaTimeZone = DateTimeZone.UTC,
-            quotaChronology = GJChronology.getInstanceUTC()
-        )
-        DateTimeUtils.setCurrentMillisFixed(timeInTest)
-
-        // Since LocalData is simple to mock
-        mockkObject(LocalData)
-        every { LocalData.nextTimeRateLimitingUnlocks = any() } answers {
-            nextTimeRateLimitingUnlocksInTesting.set((this.arg(0) as Instant).millis)
-        }
-        every { LocalData.nextTimeRateLimitingUnlocks } answers {
-            Instant.ofEpochMilli(nextTimeRateLimitingUnlocksInTesting.get())
-        }
-        every { LocalData.googleAPIProvideDiagnosisKeysCallCount = any() } answers {
-            googleAPIProvideDiagnosisKeysCallCount.set(this.arg(0))
-        }
-        every { LocalData.googleAPIProvideDiagnosisKeysCallCount } answers {
-            googleAPIProvideDiagnosisKeysCallCount.get()
-        }
-    }
-
-    @Test
-    fun `isAboveQuota false if called initially`() {
-        assertEquals(classUnderTest.hasExceededQuota, false)
-    }
-
-    @Test
-    fun `isAboveQuota true if called above quota limit when calling with amount bigger than one`() {
-        for (callNumber in 1..5) {
-            classUnderTest.calculateQuota()
-            val aboveQuota = classUnderTest.hasExceededQuota
-            Timber.v("call number $callNumber above quota: $aboveQuota")
-            if (callNumber > 1) {
-                assertEquals(true, aboveQuota)
-            } else {
-                assertEquals(false, aboveQuota)
-            }
-        }
-    }
-
-    @Test
-    fun `getProgressTowardsQuota increases with calls to isAboveQuota but is stopped once increased above the quota`() {
-        var latestCallNumberWithoutLimiting = 1
-        for (callNumber in 1..5) {
-            classUnderTest.calculateQuota()
-            val aboveQuota = classUnderTest.hasExceededQuota
-            Timber.v("call number $callNumber above quota: $aboveQuota")
-            val expectedIncrement = callNumber * defaultIncrementByAmountInTest
-            if (expectedIncrement >= defaultQuotaLimitInTest) {
-                assertEquals(
-                    (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-            } else {
-                assertEquals(
-                    callNumber * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-                latestCallNumberWithoutLimiting = callNumber
-            }
-        }
-    }
-
-    @Test
-    fun `getProgressTowardsQuota is reset and the quota is not recalculated but isAboveQuota should still be false`() {
-        var latestCallNumberWithoutLimiting = 1
-        for (callNumber in 1..5) {
-            classUnderTest.calculateQuota()
-            val aboveQuota = classUnderTest.hasExceededQuota
-            Timber.v("call number $callNumber above quota: $aboveQuota")
-            val expectedIncrement = callNumber * defaultIncrementByAmountInTest
-            if (expectedIncrement >= defaultQuotaLimitInTest) {
-                assertEquals(
-                    (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-            } else {
-                assertEquals(
-                    callNumber * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-                latestCallNumberWithoutLimiting = callNumber
-            }
-        }
-
-        classUnderTest.resetProgressTowardsQuota(0)
-        assertEquals(false, classUnderTest.hasExceededQuota)
-    }
-
-    @Test
-    fun `getProgressTowardsQuota is reset but the reset value is no multiple of incrementByAmount`() {
-        var latestCallNumberWithoutLimiting = 1
-        for (callNumber in 1..5) {
-            classUnderTest.calculateQuota()
-            val aboveQuota = classUnderTest.hasExceededQuota
-            Timber.v("call number $callNumber above quota: $aboveQuota")
-            val expectedIncrement = callNumber * defaultIncrementByAmountInTest
-            if (expectedIncrement >= defaultQuotaLimitInTest) {
-                assertEquals(
-                    (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-            } else {
-                assertEquals(
-                    callNumber * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-                latestCallNumberWithoutLimiting = callNumber
-            }
-        }
-
-        classUnderTest.resetProgressTowardsQuota(defaultIncrementByAmountInTest + 1)
-    }
-
-    @Test
-    fun `getProgressTowardsQuota is reset and the quota is not recalculated and the progress should update`() {
-        var latestCallNumberWithoutLimiting = 1
-        for (callNumber in 1..5) {
-            classUnderTest.calculateQuota()
-            val aboveQuota = classUnderTest.hasExceededQuota
-            Timber.v("call number $callNumber above quota: $aboveQuota")
-            val expectedIncrement = callNumber * defaultIncrementByAmountInTest
-            if (expectedIncrement >= defaultQuotaLimitInTest) {
-                assertEquals(
-                    (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-            } else {
-                assertEquals(
-                    callNumber * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-                latestCallNumberWithoutLimiting = callNumber
-            }
-        }
-
-        val newProgressAfterReset = 14
-        classUnderTest.resetProgressTowardsQuota(newProgressAfterReset)
-        assertEquals(false, classUnderTest.hasExceededQuota)
-        assertEquals(newProgressAfterReset, classUnderTest.getProgressTowardsQuota())
-    }
-
-    @Test
-    fun `getProgressTowardsQuota is reset and the quota is not recalculated and the progress throws an error because of too high newProgress`() {
-        var latestCallNumberWithoutLimiting = 1
-        var progressBeforeReset: Int? = null
-        for (callNumber in 1..5) {
-            classUnderTest.calculateQuota()
-            val aboveQuota = classUnderTest.hasExceededQuota
-            Timber.v("call number $callNumber above quota: $aboveQuota")
-            val expectedIncrement = callNumber * defaultIncrementByAmountInTest
-            if (expectedIncrement >= defaultQuotaLimitInTest) {
-                progressBeforeReset =
-                    (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest
-                assertEquals(
-                    (latestCallNumberWithoutLimiting + 1) * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-            } else {
-                assertEquals(
-                    callNumber * defaultIncrementByAmountInTest,
-                    classUnderTest.getProgressTowardsQuota()
-                )
-                latestCallNumberWithoutLimiting = callNumber
-            }
-        }
-
-        val newProgressAfterReset = defaultQuotaLimitInTest + 1
-        classUnderTest.resetProgressTowardsQuota(newProgressAfterReset)
-        assertEquals(true, classUnderTest.hasExceededQuota)
-        assertEquals(
-            (progressBeforeReset
-                ?: throw IllegalStateException("progressBeforeReset was not set during test")),
-            classUnderTest.getProgressTowardsQuota()
-        )
-    }
-
-    @Test
-    fun `isAboveQuota true if called above quota limit when calling with amount one`() {
-        classUnderTest = GoogleQuotaCalculator(
-            incrementByAmount = 1,
-            quotaLimit = 3,
-            quotaResetPeriod = Duration.standardHours(24),
-            quotaTimeZone = DateTimeZone.UTC,
-            quotaChronology = GJChronology.getInstanceUTC()
-        )
-        for (callNumber in 1..15) {
-            classUnderTest.calculateQuota()
-            val aboveQuota = classUnderTest.hasExceededQuota
-            Timber.v("call number $callNumber above quota: $aboveQuota")
-            if (callNumber > 3) {
-                assertEquals(true, aboveQuota)
-            } else {
-                assertEquals(false, aboveQuota)
-            }
-        }
-    }
-
-    @Test
-    fun `isAboveQuota false if called above quota limit but next day resets quota`() {
-        for (callNumber in 1..5) {
-            classUnderTest.calculateQuota()
-            val aboveQuota = classUnderTest.hasExceededQuota
-            Timber.v("call number $callNumber above quota: $aboveQuota")
-            if (callNumber > 1) {
-                assertEquals(true, aboveQuota)
-            } else {
-                assertEquals(false, aboveQuota)
-            }
-        }
-
-        // Day Change
-        val timeInTestAdvancedByADay = timeInTest + Duration.standardDays(1).millis
-        DateTimeUtils.setCurrentMillisFixed(timeInTestAdvancedByADay)
-        classUnderTest.calculateQuota()
-        val aboveQuotaAfterDayAdvance = classUnderTest.hasExceededQuota
-        Timber.v("above quota after day advance: $aboveQuotaAfterDayAdvance")
-
-        assertEquals(false, aboveQuotaAfterDayAdvance)
-    }
-
-    @Test
-    fun `test if isAfter is affected by Timezone to make sure we do not run into Shifting Errors`() {
-        val testTimeUTC = DateTime(
-            timeInTest,
-            DateTimeZone.UTC
-        ).withChronology(GJChronology.getInstanceUTC())
-        val testTimeGMT = DateTime(
-            timeInTest,
-            DateTimeZone.forID("Etc/GMT+2")
-        ).withChronology(GJChronology.getInstanceUTC())
-
-        assertEquals(testTimeGMT, testTimeUTC)
-        assertEquals(testTimeGMT.millis, testTimeUTC.millis)
-
-        val testTimeUTCAfterGMT = testTimeUTC.plusMinutes(1)
-
-        assertEquals(true, testTimeUTCAfterGMT.isAfter(testTimeGMT))
-
-        val testTimeGMTAfterUTC = testTimeGMT.plusMinutes(1)
-
-        assertEquals(true, testTimeGMTAfterUTC.isAfter(testTimeUTC))
-    }
-
-    @AfterEach
-    fun cleanup() {
-        DateTimeUtils.setCurrentMillisSystem()
-        unmockkObject(LocalData)
-    }
-}
-- 
GitLab