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