From 3e6e7b28604acf7d9ed826df618b82a754a3c651 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn <matthias.urhahn@sap.com> Date: Wed, 11 Nov 2020 10:11:47 +0100 Subject: [PATCH] Update Risk Score more than once a day (EXPOSUREAPP-3455) (#1550) * Replaced KeyFileDownloader.kt in favor of KeyFileSyncTool.kt. Supports new logic for more frequent downloads First draft. * Change "country" to location, which is more fitting. * Split sync success tracking into days and hours, track them separately. * Fix stale location data not being cleaned up. * Test fragment, first drafts. Remove 24 hour mode options, no longer used. * Implemented metered connection check for hourly key download. * Provide timeout values for download transaction and individual downloads via app config. * Add clear+download actions, and current network state display to the key download test fragment. * Complete unit tests for KeyDownloadTool.kt * Complete unit tests for FlowPreference and DownloadConfigMapper- * Unit tests for day and hour sync tool. * Complete tests for KeyPackageSyncTool.kt * klint & detekt <3 * Fix tags and commented out code (the deletion behavior is covered by `getting completed keys` * Fix text typo. * Sync tools need to tell what (if) new packages have been downloaded. * Interval checks for exposure detection. * Use the ENFClient, avoid direct access to the calculation tracker. * Remove defensive check "wanted countries"/"available countries" to reduce server hits. * CalculationTracker's timeout should come from the AppConfig (androidExposureDetectionParameters.overallTimeoutInSeconds) * Add test to confirm that we delete stale locations. * Finished key packages test screen. Introduced some boilerplate code to make async diffutils easier to use (will help us with the homefragment later on). * Remove ETag fallback behavior, missing ETag is such an edge case that we should throw. * Check valid max and min config values. * Fix linting issues. * Address PR comments. * EXPECT_NEW_*_PACKAGES should be based on the package time data, not the download creation timestamp. The downloads creation timestamp can be subject to race conditions. * Fix overflow due to time addition with Long.MAX_VALUE * Clean up tests * Don't crash the task if we can't get today's hour index. (Makes timetravel testing difficult, and a real abort reason would be a crash on the day index) * Display errors (don't crash) when running manual keysync from the test menu. * Fix last day/hour sync result success state not being updated. * Change Key download task collision mode from ENQUEUE to SKIP_IF_SIBLING_RUNNING. * Rename CalculationTracker to ExposureDetectionTracker * Rename `forceSync` to `forceIndexLookup`. * Remove outdated comments. * Spell out viewholder. * Move `maxExposureDetectionsPerUTCDay == 0` into it's own check. * Change test menu metered connection button behavior. It's now a button that "fakes" the connection status to metered. It's now also part of the `TestSettings` and the connection state is faked for the `NetworkStateProvider` class which may be reused. * Change test menu metered connection button behavior. It's now a button that "fakes" the connection status to metered. It's now also part of the `TestSettings` and the connection state is faked for the `NetworkStateProvider` class which may be reused. * Dry sync/error logging call. * Improve parameter naming, it's target locations, not available locations. The app config alone determines which locations we try to sync. Co-authored-by: BMItter <46747780+BMItter@users.noreply.github.com> --- .../storage/KeyCacheDatabaseTest.kt | 26 +- .../test/api/ui/DebugOptionsState.kt | 6 - .../test/api/ui/TestForAPIFragment.kt | 81 -- .../debugoptions/ui/DebugOptionsFragment.kt | 5 - .../ui/DebugOptionsFragmentViewModel.kt | 11 +- .../test/debugoptions/ui/DebugOptionsState.kt | 5 + .../test/keydownload/ui/CachedKeyListItem.kt | 12 + .../keydownload/ui/KeyDownloadTestFragment.kt | 79 ++ .../ui/KeyDownloadTestFragmentModule.kt | 18 + .../ui/KeyDownloadTestFragmentViewModel.kt | 80 ++ .../keydownload/ui/KeyFileDownloadAdapter.kt | 70 ++ .../test/menu/ui/TestMenuFragmentViewModel.kt | 2 + .../test/tasks/testtask/TestTask.kt | 3 +- .../ui/TestTaskControllerFragmentViewModel.kt | 2 +- .../ui/main/MainActivityTestModule.kt | 5 + .../res/layout/fragment_test_debugoptions.xml | 14 +- .../res/layout/fragment_test_for_a_p_i.xml | 83 -- .../res/layout/fragment_test_keydownload.xml | 88 ++ ...fragment_test_keydownload_adapter_line.xml | 77 ++ .../res/navigation/test_nav_graph.xml | 8 + .../appconfig/AppConfigModule.kt | 4 +- .../appconfig/ExposureDetectionConfig.kt | 5 + .../appconfig/KeyDownloadConfig.kt | 26 +- .../appconfig/mapping/DownloadConfigMapper.kt | 21 - .../mapping/ExposureDetectionConfigMapper.kt | 43 +- .../mapping/KeyDownloadParametersMapper.kt | 100 +++ .../DeadmanNotificationTimeCalculation.kt | 2 +- .../download/BaseKeyPackageSyncTool.kt | 99 +++ .../download/DayPackageSyncTool.kt | 138 ++++ .../download/DownloadDiagnosisKeysTask.kt | 109 ++- .../download/HourPackageSyncTool.kt | 166 ++++ .../diagnosiskeys/download/KeyDownloadTool.kt | 68 ++ .../download/KeyFileDownloader.kt | 348 -------- .../download/KeyPackageSyncSettings.kt | 41 + .../download/KeyPackageSyncTool.kt | 140 ++++ .../{CountryData.kt => LocationData.kt} | 44 +- .../diagnosiskeys/server/DiagnosisKeyApiV1.kt | 2 +- .../server/DiagnosisKeyServer.kt | 14 +- .../diagnosiskeys/server/DownloadInfo.kt | 21 +- .../diagnosiskeys/storage/CachedKey.kt | 5 + .../diagnosiskeys/storage/CachedKeyInfo.kt | 23 +- .../diagnosiskeys/storage/KeyCacheDatabase.kt | 3 +- .../storage/KeyCacheRepository.kt | 50 +- .../de/rki/coronawarnapp/nearby/ENFClient.kt | 17 +- .../de/rki/coronawarnapp/nearby/ENFModule.kt | 8 +- .../calculationtracker/CalculationTracker.kt | 11 - .../DefaultExposureDetectionTracker.kt} | 53 +- .../ExposureDetectionTracker.kt | 11 + .../ExposureDetectionTrackerStorage.kt} | 20 +- .../TrackedExposureDetection.kt} | 4 +- .../receiver/ExposureStateUpdateReceiver.kt | 14 +- .../rki/coronawarnapp/risk/RiskLevelTask.kt | 24 +- .../rki/coronawarnapp/storage/TestSettings.kt | 19 +- .../storage/TracingRepository.kt | 2 +- .../submission/SubmissionTask.kt | 2 +- .../rki/coronawarnapp/task/TaskController.kt | 2 +- .../de/rki/coronawarnapp/task/TaskFactory.kt | 2 +- .../task/example/QueueingTask.kt | 2 +- .../ui/main/home/HomeFragment.kt | 7 +- .../util/TimeAndDateExtensions.kt | 5 +- .../util/di/ApplicationComponent.kt | 2 - .../coronawarnapp/util/flow/FlowExtensions.kt | 22 +- .../coronawarnapp/util/lists/BindableVH.kt | 12 + .../coronawarnapp/util/lists/HasStableId.kt | 5 + .../util/lists/diffutil/HasPayloadDiffer.kt | 5 + .../util/lists/diffutil/SmartDiffUtil.kt | 58 ++ .../network/NetworkRequestBuilderProvider.kt | 9 + .../util/network/NetworkStateProvider.kt | 96 +++ .../util/preferences/FlowPreference.kt | 77 ++ .../mapping/DownloadConfigMapperTest.kt | 49 +- .../ExposureDetectionConfigMapperTest.kt | 58 ++ .../DeadmanNotificationTimeCalculationTest.kt | 22 +- .../download/BaseKeyPackageSyncToolTest.kt | 315 ++++++++ .../download/CommonSyncToolTest.kt | 154 ++++ .../diagnosiskeys/download/CountryDataTest.kt | 51 +- .../download/DayPackageSyncToolTest.kt | 183 +++++ .../download/DownloadDiagnosisKeysTaskTest.kt | 39 + .../download/HourPackageSyncToolTest.kt | 205 +++++ .../download/KeyDownloadToolTest.kt | 142 ++++ .../download/KeyFileDownloaderTest.kt | 757 ------------------ .../download/KeyPackageSyncToolTest.kt | 316 ++++++++ .../server/DiagnosisKeyApiTest.kt | 2 +- .../server/DiagnosisKeyServerTest.kt | 6 +- .../diagnosiskeys/server/DownloadInfoTest.kt | 6 +- .../storage/CachedKeyFileTest.kt | 18 +- .../storage/KeyCacheRepositoryTest.kt | 23 +- .../rki/coronawarnapp/nearby/ENFClientTest.kt | 80 +- .../DefaultExposureDetectionTrackerTest.kt} | 60 +- .../ExposureDetectionTrackerStorageTest.kt} | 16 +- .../TrackedExposureDetectionTest.kt} | 6 +- .../ExposureStateUpdateReceiverTest.kt | 12 +- .../coronawarnapp/storage/TestSettingsTest.kt | 29 - .../coronawarnapp/task/TaskControllerTest.kt | 12 +- .../task/testtasks/SkippingTask.kt | 4 +- .../task/testtasks/timeout/BaseTimeoutTask.kt | 2 +- .../ui/submission/tan/TanTest.kt | 4 +- .../util/network/NetworkStateProviderTest.kt | 215 +++++ .../util/preferences/FlowPreferenceTest.kt | 209 +++++ .../java/testhelpers/coroutines/FlowTest.kt | 3 +- .../testhelpers/coroutines/TestExtensions.kt | 2 + .../preferences/MockFlowPreference.kt | 20 + 101 files changed, 3895 insertions(+), 1761 deletions(-) delete mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsState.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/CachedKeyListItem.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragment.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentModule.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentViewModel.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyFileDownloadAdapter.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload.xml create mode 100644 Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload_adapter_line.xml delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadTool.kt delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloader.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncSettings.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncTool.kt rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/{CountryData.kt => LocationData.kt} (58%) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKey.kt delete mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/{calculationtracker/DefaultCalculationTracker.kt => detectiontracker/DefaultExposureDetectionTracker.kt} (68%) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTracker.kt rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/{calculationtracker/CalculationTrackerStorage.kt => detectiontracker/ExposureDetectionTrackerStorage.kt} (70%) rename Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/{calculationtracker/Calculation.kt => detectiontracker/TrackedExposureDetection.kt} (88%) create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/BindableVH.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/HasStableId.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/HasPayloadDiffer.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/SmartDiffUtil.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkRequestBuilderProvider.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkStateProvider.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/preferences/FlowPreference.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncToolTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CommonSyncToolTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadToolTest.kt delete mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloaderTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncToolTest.kt rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/{calculationtracker/DefaultCalculationTrackerTest.kt => detectiontracker/DefaultExposureDetectionTrackerTest.kt} (77%) rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/{calculationtracker/CalculationTrackerStorageTest.kt => detectiontracker/ExposureDetectionTrackerStorageTest.kt} (87%) rename Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/{calculationtracker/CalculationTest.kt => detectiontracker/TrackedExposureDetectionTest.kt} (81%) create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/network/NetworkStateProviderTest.kt create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/preferences/FlowPreferenceTest.kt create mode 100644 Corona-Warn-App/src/test/java/testhelpers/preferences/MockFlowPreference.kt diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabaseTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabaseTest.kt index ac0ccdefe..62230915d 100644 --- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabaseTest.kt +++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabaseTest.kt @@ -22,14 +22,14 @@ class KeyCacheDatabaseTest { @Test fun crud() { val keyDay = CachedKeyInfo( - type = CachedKeyInfo.Type.COUNTRY_DAY, + type = CachedKeyInfo.Type.LOCATION_DAY, location = LocationCode("DE"), day = LocalDate.now(), hour = null, createdAt = Instant.now() ) val keyHour = CachedKeyInfo( - type = CachedKeyInfo.Type.COUNTRY_HOUR, + type = CachedKeyInfo.Type.LOCATION_HOUR, location = LocationCode("DE"), day = LocalDate.now(), hour = LocalTime.now(), @@ -41,34 +41,34 @@ class KeyCacheDatabaseTest { dao.insertEntry(keyDay) dao.insertEntry(keyHour) dao.getAllEntries() shouldBe listOf(keyDay, keyHour) - dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_DAY.typeValue) shouldBe listOf(keyDay) - dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR.typeValue) shouldBe listOf(keyHour) + dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_DAY.typeValue) shouldBe listOf(keyDay) + dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_HOUR.typeValue) shouldBe listOf(keyHour) dao.updateDownloadState(keyDay.toDownloadUpdate("coffee")) - dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_DAY.typeValue).single().apply { + dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_DAY.typeValue).single().apply { isDownloadComplete shouldBe true - checksumMD5 shouldBe "coffee" + etag shouldBe "coffee" } - dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR.typeValue).single().apply { + dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_HOUR.typeValue).single().apply { isDownloadComplete shouldBe false - checksumMD5 shouldBe null + etag shouldBe null } dao.updateDownloadState(keyHour.toDownloadUpdate("with milk")) - dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_DAY.typeValue).single().apply { + dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_DAY.typeValue).single().apply { isDownloadComplete shouldBe true - checksumMD5 shouldBe "coffee" + etag shouldBe "coffee" } - dao.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR.typeValue).single().apply { + dao.getEntriesForType(CachedKeyInfo.Type.LOCATION_HOUR.typeValue).single().apply { isDownloadComplete shouldBe true - checksumMD5 shouldBe "with milk" + etag shouldBe "with milk" } dao.deleteEntry(keyDay) dao.getAllEntries() shouldBe listOf( keyHour.copy( isDownloadComplete = true, - checksumMD5 = "with milk" + etag = "with milk" ) ) diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt deleted file mode 100644 index 1b6dfba3e..000000000 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt +++ /dev/null @@ -1,6 +0,0 @@ -package de.rki.coronawarnapp.test.api.ui - -data class DebugOptionsState( - val areNotificationsEnabled: Boolean, - val isHourlyTestingMode: Boolean -) diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt index 03cbf73b4..694615a9a 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForAPIFragment.kt @@ -1,7 +1,6 @@ package de.rki.coronawarnapp.test.api.ui import android.annotation.SuppressLint -import android.content.Context import android.content.Intent import android.graphics.Bitmap import android.graphics.Color @@ -10,8 +9,6 @@ import android.util.Base64 import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.view.inputmethod.EditorInfo -import android.view.inputmethod.InputMethodManager import android.widget.ImageView import android.widget.Toast import androidx.fragment.app.Fragment @@ -31,7 +28,6 @@ import com.google.zxing.integration.android.IntentResult import com.google.zxing.qrcode.QRCodeWriter import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.FragmentTestForAPIBinding -import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.ExceptionCategory.INTERNAL import de.rki.coronawarnapp.exception.TransactionException @@ -46,7 +42,6 @@ import de.rki.coronawarnapp.storage.AppDatabase import de.rki.coronawarnapp.storage.ExposureSummaryRepository import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.tracing.TracingIntervalRepository -import de.rki.coronawarnapp.test.RiskLevelAndKeyRetrievalBenchmark import de.rki.coronawarnapp.test.menu.ui.TestMenuItem import de.rki.coronawarnapp.util.KeyFileHelper import de.rki.coronawarnapp.util.di.AppInjector @@ -79,7 +74,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), description = "A mix of API related test options.", targetId = R.id.test_for_api_fragment ) - const val CONFIG_SCORE = 8 fun keysToJson(keys: List<TemporaryExposureKey>): String { return Gson().toJson(keys).toString() @@ -110,8 +104,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), // Data and View binding private val binding: FragmentTestForAPIBinding by viewBindingLazy() - private var lastSetCountries: List<String>? = null - @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -212,29 +204,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), } } } - - // Country benchmark card - // Load countries from App config and update Country UI element states - lifecycleScope.launch { - lastSetCountries = - AppInjector.component.appConfigProvider.getAppConfig().supportedCountries - binding.inputCountryCodesEditText.setText( - lastSetCountries?.joinToString(",") - ) - - updateCountryStatusLabel() - } - binding.buttonFilterCountryCodes.setOnClickListener { filterCountryCodes() } - binding.buttonRetrieveDiagnosisKeysAndCalcRiskLevel.setOnClickListener { - startKeyRetrievalAndRiskCalcBenchmark() - } - - binding.inputMeasureRiskKeyRepeatCount.setOnEditorActionListener { v, actionCode, event -> - if (actionCode == EditorInfo.IME_ACTION_DONE) { - startKeyRetrievalAndRiskCalcBenchmark() - } - false - } } override fun onResume() { @@ -243,56 +212,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), updateExposureSummaryDisplay(null) } - private fun startKeyRetrievalAndRiskCalcBenchmark() { - hideKeyboard() - lifecycleScope.launch { - val repeatCount = - binding.inputMeasureRiskKeyRepeatCount.text.toString().toInt() - context?.let { - RiskLevelAndKeyRetrievalBenchmark( - it, - lastSetCountries ?: listOf("DE") - ).start(repeatCount) { status -> - binding.labelTestApiMeasureCalcKeyStatus.text = status - } - } - } - } - - private fun filterCountryCodes() { - hideKeyboard() - // Get user input country codes - val rawCountryCodes = binding.inputCountryCodesEditText.text.toString() - - // Country codes can be separated by space or , - val countryCodes = rawCountryCodes.split(',', ' ').filter { it.isNotEmpty() } - - lastSetCountries = countryCodes - - // Trigger asyncFetchFiles which will use all Countries passed as parameter - lifecycleScope.launch { - val locationCodes = countryCodes.map { LocationCode(it) } - AppInjector.component.keyFileDownloader.asyncFetchKeyFiles(locationCodes) - updateCountryStatusLabel() - } - } - - private fun hideKeyboard() { - activity?.currentFocus.let { - val inputManager = - context?.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - inputManager.hideSoftInputFromWindow(it?.windowToken, 0) - } - } - - /** - * Updates the Label for country filter - */ - private fun updateCountryStatusLabel() { - binding.labelCountryCodeFilterStatus.text = "Country filter applied for: \n " + - "${lastSetCountries?.joinToString(",")}" - } - private val prettyKey = { key: AppleLegacyKeyExchange.Key -> StringBuilder() .append("\nKey data: ${key.keyData}") diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt index 8015debc7..91b1206da 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt @@ -32,10 +32,6 @@ class DebugOptionsFragment : Fragment(R.layout.fragment_test_debugoptions), Auto super.onViewCreated(view, savedInstanceState) // Debug card - binding.hourlyKeyPkgMode.apply { - setOnClickListener { vm.setHourlyKeyPkgMode(isChecked) } - } - binding.backgroundNotificationsToggle.apply { setOnClickListener { vm.setBackgroundNotifications(isChecked) } } @@ -45,7 +41,6 @@ class DebugOptionsFragment : Fragment(R.layout.fragment_test_debugoptions), Auto vm.debugOptionsState.observe2(this) { state -> binding.apply { backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled - hourlyKeyPkgMode.isChecked = state.isHourlyTestingMode } } binding.testLogfileToggle.apply { diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt index 784c9731e..c58e64556 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt @@ -10,7 +10,6 @@ import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.TestSettings import de.rki.coronawarnapp.task.TaskController import de.rki.coronawarnapp.task.common.DefaultTaskRequest -import de.rki.coronawarnapp.test.api.ui.DebugOptionsState import de.rki.coronawarnapp.test.api.ui.EnvironmentState.Companion.toEnvironmentState import de.rki.coronawarnapp.test.api.ui.LoggerState.Companion.toLoggerState import de.rki.coronawarnapp.util.CWADebug @@ -34,18 +33,10 @@ class DebugOptionsFragmentViewModel @AssistedInject constructor( val debugOptionsState by smartLiveData { DebugOptionsState( - areNotificationsEnabled = LocalData.backgroundNotification(), - isHourlyTestingMode = testSettings.isHourKeyPkgMode + areNotificationsEnabled = LocalData.backgroundNotification() ) } - fun setHourlyKeyPkgMode(enabled: Boolean) { - debugOptionsState.update { - testSettings.isHourKeyPkgMode = enabled - it.copy(isHourlyTestingMode = enabled) - } - } - val environmentState by smartLiveData { envSetup.toEnvironmentState() } diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsState.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsState.kt new file mode 100644 index 000000000..da57e399e --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsState.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.test.debugoptions.ui + +data class DebugOptionsState( + val areNotificationsEnabled: Boolean +) diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/CachedKeyListItem.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/CachedKeyListItem.kt new file mode 100644 index 000000000..a659fbacf --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/CachedKeyListItem.kt @@ -0,0 +1,12 @@ +package de.rki.coronawarnapp.test.keydownload.ui + +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.util.lists.HasStableId + +data class CachedKeyListItem( + val info: CachedKeyInfo, + val fileSize: Long +) : HasStableId { + override val stableId: Long + get() = info.id.hashCode().toLong() +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragment.kt new file mode 100644 index 000000000..60233a4ea --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragment.kt @@ -0,0 +1,79 @@ +package de.rki.coronawarnapp.test.keydownload.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import androidx.recyclerview.widget.LinearLayoutManager +import com.google.android.material.snackbar.Snackbar +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.FragmentTestKeydownloadBinding +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.test.menu.ui.TestMenuItem +import de.rki.coronawarnapp.util.di.AutoInject +import de.rki.coronawarnapp.util.lists.diffutil.update +import de.rki.coronawarnapp.util.ui.observe2 +import de.rki.coronawarnapp.util.ui.viewBindingLazy +import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider +import de.rki.coronawarnapp.util.viewmodel.cwaViewModels +import javax.inject.Inject + +@SuppressLint("SetTextI18n") +class KeyDownloadTestFragment : Fragment(R.layout.fragment_test_keydownload), AutoInject { + + @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory + private val vm: KeyDownloadTestFragmentViewModel by cwaViewModels { viewModelFactory } + + private val binding: FragmentTestKeydownloadBinding by viewBindingLazy() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + vm.fakeMeteredConnection.observe2(this) { + binding.fakeMeteredConnectionToggle.isChecked = it + } + binding.fakeMeteredConnectionToggle.setOnClickListener { vm.toggleAllowMeteredConnections() } + + vm.isMeteredConnection.observe2(this) { + binding.infoMeteredNetwork.text = "Is metered network? $it" + } + + binding.apply { + downloadAction.setOnClickListener { vm.download() } + clearAction.setOnClickListener { vm.clearDownloads() } + } + + vm.isSyncRunning.observe2(this) { isRunning -> + binding.apply { + downloadAction.isEnabled = !isRunning + clearAction.isEnabled = !isRunning + } + } + + val keyFileAdapter = KeyFileDownloadAdapter { vm.deleteKeyFile(it) } + binding.cacheList.apply { + adapter = keyFileAdapter + layoutManager = LinearLayoutManager(requireContext()) + } + + vm.currentCache.observe2(this) { items -> + val dayCount = items.count { it.info.type == CachedKeyInfo.Type.LOCATION_DAY } + val hourCount = items.count { it.info.type == CachedKeyInfo.Type.LOCATION_HOUR } + binding.cacheListInfos.text = "${items.size} files, $dayCount days, $hourCount hours." + + keyFileAdapter.update(items) + } + + vm.errorEvent.observe2(this) { + Snackbar.make(requireView(), it.toString(), Snackbar.LENGTH_LONG).show() + } + } + + companion object { + val MENU_ITEM = TestMenuItem( + title = "Key Packages", + description = "View & Control the downloaded key pkgs..", + targetId = R.id.test_keydownload_fragment + ) + } +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentModule.kt new file mode 100644 index 000000000..c5af46aeb --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentModule.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.test.keydownload.ui + +import dagger.Binds +import dagger.Module +import dagger.multibindings.IntoMap +import de.rki.coronawarnapp.util.viewmodel.CWAViewModel +import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory +import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey + +@Module +abstract class KeyDownloadTestFragmentModule { + @Binds + @IntoMap + @CWAViewModelKey(KeyDownloadTestFragmentViewModel::class) + abstract fun testKeyDownloadFragment( + factory: KeyDownloadTestFragmentViewModel.Factory + ): CWAViewModelFactory<out CWAViewModel> +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentViewModel.kt new file mode 100644 index 000000000..c2dbd49fc --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyDownloadTestFragmentViewModel.kt @@ -0,0 +1,80 @@ +package de.rki.coronawarnapp.test.keydownload.ui + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.asLiveData +import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.diagnosiskeys.download.KeyPackageSyncTool +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.storage.TestSettings +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.network.NetworkStateProvider +import de.rki.coronawarnapp.util.ui.SingleLiveEvent +import de.rki.coronawarnapp.util.viewmodel.CWAViewModel +import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.runBlocking +import timber.log.Timber + +class KeyDownloadTestFragmentViewModel @AssistedInject constructor( + dispatcherProvider: DispatcherProvider, + networkStateProvider: NetworkStateProvider, + private val testSettings: TestSettings, + private val keyPackageSyncTool: KeyPackageSyncTool, + private val keyCacheRepository: KeyCacheRepository +) : CWAViewModel(dispatcherProvider = dispatcherProvider) { + + val currentCache = runBlocking { + // TODO runBlocking is not nice, how can we solve this better? + keyCacheRepository + .allCachedKeys() + .sample(250) + .map { items -> + items + .sortedWith(compareBy({ it.info.day }, { it.info.hour })) + .reversed() + .map { CachedKeyListItem(it.info, it.path.length()) } + } + .asLiveData() + } + + val isMeteredConnection = networkStateProvider.networkState + .map { it.isMeteredConnection } + .asLiveData() + + val fakeMeteredConnection = testSettings.fakeMeteredConnection.flow.asLiveData() + + val isSyncRunning = MutableLiveData(false) + val errorEvent = SingleLiveEvent<Exception>() + + fun toggleAllowMeteredConnections() { + testSettings.fakeMeteredConnection.update { !it } + } + + fun download() = launchWithSyncProgress { + keyPackageSyncTool.syncKeyFiles() + } + + fun clearDownloads() = launchWithSyncProgress { keyCacheRepository.clear() } + + private fun launchWithSyncProgress(action: suspend () -> Unit) { + isSyncRunning.postValue(true) + launch { + try { + action() + } catch (e: Exception) { + Timber.e(e, "Call failed.") + errorEvent.postValue(e) + } finally { + isSyncRunning.postValue(false) + } + } + } + + fun deleteKeyFile(it: CachedKeyListItem) = launchWithSyncProgress { + keyCacheRepository.delete(listOf(it.info)) + } + + @AssistedInject.Factory + interface Factory : SimpleCWAViewModelFactory<KeyDownloadTestFragmentViewModel> +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyFileDownloadAdapter.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyFileDownloadAdapter.kt new file mode 100644 index 000000000..596413a51 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/keydownload/ui/KeyFileDownloadAdapter.kt @@ -0,0 +1,70 @@ +package de.rki.coronawarnapp.test.keydownload.ui + +import android.text.format.Formatter +import android.view.ViewGroup +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.FragmentTestKeydownloadAdapterLineBinding +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.ui.lists.BaseAdapter +import de.rki.coronawarnapp.util.lists.BindableVH +import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffUtilAdapter +import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffer +import de.rki.coronawarnapp.util.ui.setGone +import org.joda.time.format.DateTimeFormat + +class KeyFileDownloadAdapter( + private val deleteAction: (CachedKeyListItem) -> Unit +) : BaseAdapter<KeyFileDownloadAdapter.CachedKeyViewHolder>(), AsyncDiffUtilAdapter<CachedKeyListItem> { + + init { + setHasStableIds(true) + } + + override val asyncDiffer: AsyncDiffer<CachedKeyListItem> = AsyncDiffer(this) + + override fun getItemCount(): Int = data.size + + override fun getItemId(position: Int): Long = data[position].stableId + + override fun onCreateBaseVH(parent: ViewGroup, viewType: Int): CachedKeyViewHolder = CachedKeyViewHolder(parent) + + override fun onBindBaseVH(holder: CachedKeyViewHolder, position: Int) { + val item = data[position] + holder.itemView.setOnLongClickListener { + deleteAction(item) + true + } + holder.bind(item) + } + + class CachedKeyViewHolder( + val parent: ViewGroup + ) : BaseAdapter.VH( + R.layout.fragment_test_keydownload_adapter_line, parent + ), BindableVH<CachedKeyListItem, FragmentTestKeydownloadAdapterLineBinding> { + + override val viewBinding = lazy { FragmentTestKeydownloadAdapterLineBinding.bind(itemView) } + + override val onBindData: FragmentTestKeydownloadAdapterLineBinding.(key: CachedKeyListItem) -> Unit = { item -> + locationInfo.text = item.info.location.identifier + + val shortSize = Formatter.formatShortFileSize(context, item.fileSize) + typeInfo.text = when (item.info.type) { + CachedKeyInfo.Type.LOCATION_DAY -> "Day ($shortSize)" + CachedKeyInfo.Type.LOCATION_HOUR -> "Hour ($shortSize)" + } + timeInfo.text = when (item.info.type) { + CachedKeyInfo.Type.LOCATION_DAY -> "${item.info.day}" + CachedKeyInfo.Type.LOCATION_HOUR -> "${item.info.day} ${item.info.hour!!.hourOfDay}:00" + } + creationData.text = item.info.createdAt.toString(DOWNLOAD_TIME_FORMATTER) + creationLabel.setGone(!item.info.isDownloadComplete) + creationData.setGone(!item.info.isDownloadComplete) + progressIndicator.setGone(item.info.isDownloadComplete) + } + } + + companion object { + private val DOWNLOAD_TIME_FORMATTER = DateTimeFormat.forPattern("yyyy-MM-dd HH:mm:ss.SSSS") + } +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt index d3316873e..36d71e9cb 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/menu/ui/TestMenuFragmentViewModel.kt @@ -6,6 +6,7 @@ import de.rki.coronawarnapp.test.api.ui.TestForAPIFragment import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment import de.rki.coronawarnapp.test.crash.ui.SettingsCrashReportFragment import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment +import de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragment import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment import de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment import de.rki.coronawarnapp.util.ui.SingleLiveEvent @@ -20,6 +21,7 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() { AppConfigTestFragment.MENU_ITEM, TestForAPIFragment.MENU_ITEM, TestRiskLevelCalculationFragment.MENU_ITEM, + KeyDownloadTestFragment.MENU_ITEM, TestTaskControllerFragment.MENU_ITEM, SettingsCrashReportFragment.MENU_ITEM ).let { MutableLiveData(it) } diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt index 426c2c718..45c6a1a39 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/testtask/TestTask.kt @@ -64,7 +64,8 @@ class TestTask @Inject constructor() : Task<DefaultProgress, TestTask.Result> { private val taskByDagger: Provider<TestTask> ) : TaskFactory<DefaultProgress, Result> { - override val config: TaskFactory.Config = Config() + override suspend fun createConfig(): TaskFactory.Config = Config() + override val taskProvider: () -> Task<DefaultProgress, Result> = { taskByDagger.get() } diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragmentViewModel.kt index 7b88f9bf5..82b274e9b 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragmentViewModel.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragmentViewModel.kt @@ -32,7 +32,7 @@ class TestTaskControllerFragmentViewModel @AssistedInject constructor( val factoryState: LiveData<FactoryState> = liveData(context = dispatcherProvider.Default) { val infoStrings = taskFactories.map { val taskLabel = it.key.simpleName - val collisionBehavior = it.value.config.collisionBehavior.toString() + val collisionBehavior = it.value.createConfig().collisionBehavior.toString() """ $taskLabel - Behavior: $collisionBehavior """.trimIndent() diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt index dae782737..41cca0d60 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/ui/main/MainActivityTestModule.kt @@ -8,6 +8,8 @@ import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment import de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragmentModule import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment import de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragmentModule +import de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragment +import de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragmentModule import de.rki.coronawarnapp.test.menu.ui.TestMenuFragment import de.rki.coronawarnapp.test.menu.ui.TestMenuFragmentModule import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment @@ -35,4 +37,7 @@ abstract class MainActivityTestModule { @ContributesAndroidInjector(modules = [DebugOptionsFragmentModule::class]) abstract fun debugOptions(): DebugOptionsFragment + + @ContributesAndroidInjector(modules = [KeyDownloadTestFragmentModule::class]) + abstract fun keyDownload(): KeyDownloadTestFragment } diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml index 628201557..867b86569 100644 --- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml @@ -32,18 +32,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> - <Switch - android:id="@+id/hourly_key_pkg_mode" - style="@style/body1" - android:layout_width="match_parent" - android:layout_height="0dp" - android:layout_marginTop="@dimen/spacing_small" - android:text="Hourly keyfile mode (last 24)" - android:theme="@style/switchBase" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/debug_container_title" /> - <Switch android:id="@+id/background_notifications_toggle" style="@style/body1" @@ -54,7 +42,7 @@ android:theme="@style/switchBase" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/hourly_key_pkg_mode" /> + app:layout_constraintTop_toBottomOf="@+id/debug_container_title" /> <Switch android:id="@+id/test_logfile_toggle" diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml index a72eafd72..a4eb49227 100644 --- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_for_a_p_i.xml @@ -253,89 +253,6 @@ android:text="Get Active Tracing Duration in Retention Period" /> </LinearLayout> - <LinearLayout - android:id="@+id/country_container" - style="@style/card" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="@dimen/spacing_tiny" - android:orientation="vertical"> - - <TextView - android:id="@+id/label_country_filter" - style="@style/headline6" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="Country Settings" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> - - <EditText - android:id="@+id/input_country_codes_editText" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_weight="1" /> - - <Button - android:id="@+id/button_filter_country_codes" - style="@style/buttonPrimary" - android:layout_width="wrap_content" - android:layout_height="wrap_content" - android:text="Apply" /> - </LinearLayout> - - <TextView - android:id="@+id/label_country_code_filter_status" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="Country filter applied for:"> - - </TextView> - - <TextView - android:id="@+id/label_test_api_measure" - style="@style/headline6" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="Statistics" /> - - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:gravity="center_vertical" - android:orientation="horizontal"> - - <EditText - android:id="@+id/input_measure_risk_key_repeat_count" - android:layout_width="90dp" - android:layout_height="wrap_content" - android:inputType="number" - android:text="1" /> - - <Button - android:id="@+id/button_retrieve_diagnosis_keys_and_calc_risk_level" - style="@style/buttonPrimary" - android:layout_width="0dp" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:layout_marginBottom="@dimen/spacing_normal" - android:layout_weight="1" - android:imeOptions="actionDone" - android:text="Measure: Calculate Risk Level / Key Retrieval" /> - - </LinearLayout> - - <TextView - android:id="@+id/label_test_api_measure_calc_key_status" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="Result: " /> - - </LinearLayout> - <de.rki.coronawarnapp.ui.calendar.CalendarView android:id="@+id/calendar_container" android:layout_width="match_parent" diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload.xml new file mode 100644 index 000000000..7c160bde7 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload.xml @@ -0,0 +1,88 @@ +<?xml version="1.0" encoding="utf-8"?> + +<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:ignore="HardcodedText"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_margin="8dp" + android:orientation="vertical" + android:paddingBottom="32dp"> + + <androidx.constraintlayout.widget.ConstraintLayout + style="@style/card" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="@dimen/spacing_tiny" + android:layout_marginStart="8dp" + android:layout_marginEnd="8dp" + android:orientation="vertical"> + + <TextView + android:id="@+id/info_metered_network" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Is metered network: ??" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <Switch + android:id="@+id/fake_metered_connection_toggle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Fake metered connection status" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/info_metered_network" /> + + <Button + android:id="@+id/clear_action" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Clear" + app:layout_constraintEnd_toStartOf="@+id/download_action" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/fake_metered_connection_toggle" /> + + <Button + android:id="@+id/download_action" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Download" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toEndOf="@+id/clear_action" + app:layout_constraintTop_toBottomOf="@+id/fake_metered_connection_toggle" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + + <TextView + style="@style/TextAppearance.AppCompat.Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:gravity="center" + android:text="Long press entries to delete them." /> + <TextView + android:id="@+id/cache_list_infos" + style="@style/TextAppearance.AppCompat.Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/spacing_tiny" + android:gravity="center" + tools:text="17 files, 14 days, 3 hours." /> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/cache_list" + android:layout_width="match_parent" + android:layout_height="match_parent" /> + + </LinearLayout> +</androidx.core.widget.NestedScrollView> \ No newline at end of file diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload_adapter_line.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload_adapter_line.xml new file mode 100644 index 000000000..ba86dfd17 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_keydownload_adapter_line.xml @@ -0,0 +1,77 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:background="?selectableItemBackground" + android:layout_height="wrap_content"> + <TextView + android:id="@+id/location_info" + style="@style/body1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginTop="8dp" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:text="EUR" /> + <TextView + android:id="@+id/type_info" + style="@style/TextAppearance.AppCompat.Caption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + app:layout_constraintBottom_toBottomOf="@+id/location_info" + app:layout_constraintEnd_toStartOf="@+id/creation_label" + app:layout_constraintHorizontal_bias="0.0" + app:layout_constraintStart_toEndOf="@id/location_info" + app:layout_constraintTop_toTopOf="@+id/location_info" + tools:text="Day Package" /> + + <TextView + android:id="@+id/time_info" + style="@style/body1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginBottom="8dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/type_info" + tools:text="2020-11-02 12:00" /> + + <TextView + android:id="@+id/creation_label" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + style="@style/TextAppearance.AppCompat.Caption" + android:layout_marginEnd="16dp" + android:text="Downloaded at" + app:layout_constraintBottom_toBottomOf="@+id/location_info" + app:layout_constraintEnd_toStartOf="@+id/progress_indicator" + app:layout_constraintTop_toTopOf="@+id/location_info" /> + + <TextView + android:id="@+id/creation_data" + style="@style/TextAppearance.AppCompat.Caption" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginStart="8dp" + android:layout_marginEnd="16dp" + app:layout_constraintBottom_toBottomOf="@+id/time_info" + app:layout_constraintEnd_toStartOf="@+id/progress_indicator" + app:layout_constraintHorizontal_bias="1.0" + app:layout_constraintStart_toEndOf="@+id/time_info" + app:layout_constraintTop_toTopOf="@+id/time_info" + tools:text="1111-11-11 11:11" /> + + <ProgressBar + android:id="@+id/progress_indicator" + android:layout_width="32dp" + android:layout_height="32dp" + android:layout_marginEnd="16dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml b/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml index 1c53c7772..226a618a7 100644 --- a/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml +++ b/Corona-Warn-App/src/deviceForTesters/res/navigation/test_nav_graph.xml @@ -28,6 +28,9 @@ <action android:id="@+id/action_test_menu_fragment_to_debugOptionsFragment" app:destination="@id/test_debugoptions_fragment" /> + <action + android:id="@+id/action_test_menu_fragment_to_keyDownloadTestFragment" + app:destination="@id/test_keydownload_fragment" /> </fragment> <fragment @@ -78,5 +81,10 @@ android:name="de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment" android:label="DebugOptionsFragment" tools:layout="@layout/fragment_test_debugoptions" /> + <fragment + android:id="@+id/test_keydownload_fragment" + android:name="de.rki.coronawarnapp.test.keydownload.ui.KeyDownloadTestFragment" + android:label="KeyDownloadTestFragment" + tools:layout="@layout/fragment_test_keydownload" /> </navigation> diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt index 82936e52b..19ae812f3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt @@ -6,8 +6,8 @@ import dagger.Provides import de.rki.coronawarnapp.appconfig.download.AppConfigApiV1 import de.rki.coronawarnapp.appconfig.download.AppConfigHttpCache import de.rki.coronawarnapp.appconfig.mapping.CWAConfigMapper -import de.rki.coronawarnapp.appconfig.mapping.DownloadConfigMapper import de.rki.coronawarnapp.appconfig.mapping.ExposureDetectionConfigMapper +import de.rki.coronawarnapp.appconfig.mapping.KeyDownloadParametersMapper import de.rki.coronawarnapp.appconfig.mapping.RiskCalculationConfigMapper import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl @@ -64,7 +64,7 @@ class AppConfigModule { fun cwaMapper(mapper: CWAConfigMapper): CWAConfig.Mapper = mapper @Provides - fun downloadMapper(mapper: DownloadConfigMapper): KeyDownloadConfig.Mapper = mapper + fun downloadMapper(mapper: KeyDownloadParametersMapper): KeyDownloadConfig.Mapper = mapper @Provides fun exposurMapper(mapper: ExposureDetectionConfigMapper): ExposureDetectionConfig.Mapper = diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt index 5281c51ed..be47c2f17 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt @@ -3,9 +3,14 @@ package de.rki.coronawarnapp.appconfig import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper import de.rki.coronawarnapp.server.protocols.internal.ExposureDetectionParameters +import org.joda.time.Duration interface ExposureDetectionConfig { + val maxExposureDetectionsPerUTCDay: Int + val minTimeBetweenDetections: Duration + val overallDetectionTimeout: Duration + val exposureDetectionConfiguration: ExposureConfiguration val exposureDetectionParameters: ExposureDetectionParameters.ExposureDetectionParametersAndroid diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt index d82a6cd3a..e1a019809 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt @@ -1,11 +1,33 @@ package de.rki.coronawarnapp.appconfig import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper -import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import org.joda.time.Duration +import org.joda.time.LocalDate +import org.joda.time.LocalTime interface KeyDownloadConfig { - val keyDownloadParameters: KeyDownloadParameters.KeyDownloadParametersAndroid + val individualDownloadTimeout: Duration + + val overallDownloadTimeout: Duration + + val invalidDayETags: Collection<InvalidatedKeyFile.Day> + + val invalidHourEtags: Collection<InvalidatedKeyFile.Hour> + + interface InvalidatedKeyFile { + val etag: String + val region: LocationCode + + interface Day : InvalidatedKeyFile { + val day: LocalDate + } + + interface Hour : Day, InvalidatedKeyFile { + val hour: LocalTime + } + } interface Mapper : ConfigMapper<KeyDownloadConfig> } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt deleted file mode 100644 index 752f41cb1..000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt +++ /dev/null @@ -1,21 +0,0 @@ -package de.rki.coronawarnapp.appconfig.mapping - -import dagger.Reusable -import de.rki.coronawarnapp.appconfig.KeyDownloadConfig -import de.rki.coronawarnapp.server.protocols.internal.AppConfig -import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters -import javax.inject.Inject - -@Reusable -class DownloadConfigMapper @Inject constructor() : KeyDownloadConfig.Mapper { - override fun map(rawConfig: AppConfig.ApplicationConfiguration): KeyDownloadConfig { - - return KeyDownloadConfigContainer( - keyDownloadParameters = rawConfig.androidKeyDownloadParameters - ) - } - - data class KeyDownloadConfigContainer( - override val keyDownloadParameters: KeyDownloadParameters.KeyDownloadParametersAndroid - ) : KeyDownloadConfig -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt index c010e25af..92473af00 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt @@ -6,22 +6,57 @@ import dagger.Reusable import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig import de.rki.coronawarnapp.server.protocols.internal.AppConfig import de.rki.coronawarnapp.server.protocols.internal.ExposureDetectionParameters.ExposureDetectionParametersAndroid +import org.joda.time.Duration import javax.inject.Inject @Reusable class ExposureDetectionConfigMapper @Inject constructor() : ExposureDetectionConfig.Mapper { - override fun map(rawConfig: AppConfig.ApplicationConfiguration): ExposureDetectionConfig = - ExposureDetectionConfigContainer( + override fun map(rawConfig: AppConfig.ApplicationConfiguration): ExposureDetectionConfig { + val exposureParams = rawConfig.androidExposureDetectionParameters + return ExposureDetectionConfigContainer( exposureDetectionConfiguration = rawConfig.mapRiskScoreToExposureConfiguration(), - exposureDetectionParameters = rawConfig.androidExposureDetectionParameters + exposureDetectionParameters = exposureParams, + maxExposureDetectionsPerUTCDay = exposureParams.maxExposureDetectionsPerDay(), + minTimeBetweenDetections = exposureParams.minTimeBetweenExposureDetections(), + overallDetectionTimeout = exposureParams.overAllDetectionTimeout() ) + } data class ExposureDetectionConfigContainer( override val exposureDetectionConfiguration: ExposureConfiguration, - override val exposureDetectionParameters: ExposureDetectionParametersAndroid + override val exposureDetectionParameters: ExposureDetectionParametersAndroid, + override val maxExposureDetectionsPerUTCDay: Int, + override val minTimeBetweenDetections: Duration, + override val overallDetectionTimeout: Duration ) : ExposureDetectionConfig } +// If we are outside the valid data range, fallback to default value. +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +fun ExposureDetectionParametersAndroid.overAllDetectionTimeout(): Duration = when { + overallTimeoutInSeconds > 3600 -> Duration.standardMinutes(15) + overallTimeoutInSeconds <= 0 -> Duration.standardMinutes(15) + else -> Duration.standardSeconds(overallTimeoutInSeconds.toLong()) +} + +// If we are outside the valid data range, fallback to default value. +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +fun ExposureDetectionParametersAndroid.maxExposureDetectionsPerDay(): Int = when { + maxExposureDetectionsPerInterval > 6 -> 6 + maxExposureDetectionsPerInterval < 0 -> 6 + else -> maxExposureDetectionsPerInterval +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +fun ExposureDetectionParametersAndroid.minTimeBetweenExposureDetections(): Duration { + val detectionsPerDay = maxExposureDetectionsPerDay() + return if (detectionsPerDay == 0) { + Duration.standardDays(99) + } else { + (24 / detectionsPerDay).let { Duration.standardHours(it.toLong()) } + } +} + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) fun AppConfig.ApplicationConfiguration.mapRiskScoreToExposureConfiguration(): ExposureConfiguration = ExposureConfiguration diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt new file mode 100644 index 000000000..e925a6052 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/KeyDownloadParametersMapper.kt @@ -0,0 +1,100 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import androidx.annotation.VisibleForTesting +import dagger.Reusable +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters.KeyDownloadParametersAndroid +import org.joda.time.Duration +import org.joda.time.LocalDate +import org.joda.time.LocalTime +import org.joda.time.format.DateTimeFormat +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class KeyDownloadParametersMapper @Inject constructor() : KeyDownloadConfig.Mapper { + override fun map(rawConfig: AppConfig.ApplicationConfiguration): KeyDownloadConfig { + val rawParameters = rawConfig.androidKeyDownloadParameters + + return KeyDownloadConfigContainer( + individualDownloadTimeout = rawParameters.individualTimeout(), + overallDownloadTimeout = rawParameters.overAllTimeout(), + invalidDayETags = rawParameters.mapDayEtags(), + invalidHourEtags = rawParameters.mapHourEtags() + ) + } + + // If we are outside the valid data range, fallback to default value. + private fun KeyDownloadParametersAndroid.individualTimeout(): Duration = when { + downloadTimeoutInSeconds > 1800 -> Duration.standardSeconds(60) + downloadTimeoutInSeconds <= 0 -> Duration.standardSeconds(60) + else -> Duration.standardSeconds(downloadTimeoutInSeconds.toLong()) + } + + // If we are outside the valid data range, fallback to default value. + private fun KeyDownloadParametersAndroid.overAllTimeout(): Duration = when { + overallTimeoutInSeconds > 1800 -> Duration.standardMinutes(8) + overallTimeoutInSeconds <= 0 -> Duration.standardMinutes(8) + else -> Duration.standardSeconds(overallTimeoutInSeconds.toLong()) + } + + private fun KeyDownloadParametersAndroid.mapDayEtags(): List<InvalidatedKeyFile.Day> = + this.cachedDayPackagesToUpdateOnETagMismatchList.mapNotNull { + try { + InvalidatedKeyFile.Day( + etag = it.etag, + region = LocationCode(it.region), + day = LocalDate.parse(it.date, DAY_FORMATTER) + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse invalidated day metadata: %s", it) + null + } + } + + private fun KeyDownloadParametersAndroid.mapHourEtags(): List<InvalidatedKeyFile.Hour> = + this.cachedHourPackagesToUpdateOnETagMismatchList.mapNotNull { + try { + InvalidatedKeyFile.Hour( + etag = it.etag, + region = LocationCode(it.region), + day = LocalDate.parse(it.date, DAY_FORMATTER), + hour = LocalTime.parse("${it.hour}", HOUR_FORMATTER) + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse invalidated hour metadata: %s", it) + null + } + } + + data class KeyDownloadConfigContainer( + override val individualDownloadTimeout: Duration, + override val overallDownloadTimeout: Duration, + override val invalidDayETags: Collection<KeyDownloadConfig.InvalidatedKeyFile.Day>, + override val invalidHourEtags: Collection<KeyDownloadConfig.InvalidatedKeyFile.Hour> + ) : KeyDownloadConfig + + companion object { + private val DAY_FORMATTER = DateTimeFormat.forPattern("yyyy-MM-dd") + private val HOUR_FORMATTER = DateTimeFormat.forPattern("H") + } +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +internal sealed class InvalidatedKeyFile : KeyDownloadConfig.InvalidatedKeyFile { + + data class Day( + override val etag: String, + override val region: LocationCode, + override val day: LocalDate + ) : InvalidatedKeyFile(), KeyDownloadConfig.InvalidatedKeyFile.Day + + data class Hour( + override val etag: String, + override val region: LocationCode, + override val day: LocalDate, + override val hour: LocalTime + ) : InvalidatedKeyFile(), KeyDownloadConfig.InvalidatedKeyFile.Hour +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt index 26018e3dd..48e6d3a8e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt @@ -28,7 +28,7 @@ class DeadmanNotificationTimeCalculation @Inject constructor( * If last success date time is null (eg: on application first start) - return [DEADMAN_NOTIFICATION_DELAY] */ suspend fun getDelay(): Long { - val lastSuccess = enfClient.latestFinishedCalculation().first()?.finishedAt + val lastSuccess = enfClient.lastSuccessfulTrackedExposureDetection().first()?.finishedAt return if (lastSuccess != null) { getHoursDiff(lastSuccess).toLong() } else { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt new file mode 100644 index 000000000..66a2ada54 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt @@ -0,0 +1,99 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.storage.DeviceStorage +import timber.log.Timber + +open class BaseKeyPackageSyncTool( + private val keyCache: KeyCacheRepository, + private val deviceStorage: DeviceStorage, + private val tag: String +) { + + internal suspend fun invalidateCachedKeys(invalidatedKeyFiles: Collection<KeyDownloadConfig.InvalidatedKeyFile>) { + if (invalidatedKeyFiles.isEmpty()) { + Timber.tag(tag).d("No invalid files to delete.") + return + } + + val badEtags = invalidatedKeyFiles.map { it.etag } + val toDelete = keyCache.getAllCachedKeys() + .filter { badEtags.contains(it.info.etag) } + + Timber.tag(tag).w("Deleting invalidated cached keys: %s", toDelete.joinToString("\n")) + keyCache.delete(toDelete.map { it.info }) + } + + internal suspend fun requireStorageSpace(data: List<LocationData>): DeviceStorage.CheckResult { + val requiredBytes = data.fold(0L) { acc, item -> + acc + item.approximateSizeInBytes + } + Timber.tag(tag).d("%dB are required for %s", requiredBytes, data) + return deviceStorage.requireSpacePrivateStorage(requiredBytes).also { + Timber.tag(tag).d("Storage check result: %s", it) + } + } + + // All cached files that are no longer on the server are considered stale + internal fun List<CachedKey>.findStaleData( + availableData: List<LocationData> + ): List<CachedKey> = filter { (cachedKey, _) -> + // Is there a day on the server that matches our cached keys day? + val serverHasMatchingDay = availableData + .mapNotNull { it as? LocationDays } + .any { it.dayData.contains(cachedKey.day) } + + when { + cachedKey.type == CachedKeyInfo.Type.LOCATION_DAY -> { + // If there is no matching day on the server, our cached key is stale + return@filter !serverHasMatchingDay + } + cachedKey.type == CachedKeyInfo.Type.LOCATION_HOUR && serverHasMatchingDay -> { + // A cached hour for which a server day exists, means we don't need the hour anymore + // If there is no match, then we can't decide yet, and need to check the server for hours + return@filter true // Stale + } + } + + // Is there an hour on the server that matches our cached hour? + val serverHasMatchingHour = availableData + .mapNotNull { it as? LocationHours } + .any { serverHours -> + serverHours.hourData.any { (day, hours) -> + cachedKey.day == day && hours.contains(cachedKey.hour) + } + } + + if (serverHasMatchingHour) { + // Our hour is still on the server + return@filter false // Not stale + } + + // If we couldn't find match against the server data, our cache entry is probably stale + return@filter true + } + + internal suspend fun getDownloadedCachedKeys( + location: LocationCode, + type: CachedKeyInfo.Type + ): List<CachedKey> = keyCache.getEntriesForType(type) + .filter { it.info.location == location } + .filter { key -> + val complete = key.info.isDownloadComplete + val exists = key.path.exists() + if (complete && !exists) { + Timber.tag(tag).v("Incomplete download, will overwrite: %s", key) + } + // We overwrite not completed ones + complete && exists + } + + data class SyncResult( + val successful: Boolean = true, + val newPackages: List<CachedKey> = emptyList() + ) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt new file mode 100644 index 000000000..4668c5660 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt @@ -0,0 +1,138 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import androidx.annotation.VisibleForTesting +import dagger.Reusable +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.storage.DeviceStorage +import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDate +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.joda.time.LocalDate +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class DayPackageSyncTool @Inject constructor( + deviceStorage: DeviceStorage, + private val keyServer: DiagnosisKeyServer, + private val keyCache: KeyCacheRepository, + private val downloadTool: KeyDownloadTool, + private val timeStamper: TimeStamper, + private val configProvider: AppConfigProvider, + private val dispatcherProvider: DispatcherProvider +) : BaseKeyPackageSyncTool( + keyCache = keyCache, + deviceStorage = deviceStorage, + tag = TAG +) { + + internal suspend fun syncMissingDayPackages( + targetLocations: List<LocationCode>, + forceIndexLookup: Boolean + ): SyncResult { + Timber.tag(TAG).v("syncMissingDays(targetLocations=%s)", targetLocations) + + val downloadConfig: KeyDownloadConfig = configProvider.getAppConfig() + invalidateCachedKeys(downloadConfig.invalidDayETags) + + val missingDays = targetLocations.mapNotNull { + determineMissingDayPackages(it, forceIndexLookup) + } + if (missingDays.isEmpty()) { + Timber.tag(TAG).i("There were no missing day packages.") + return SyncResult(successful = true, newPackages = emptyList()) + } + + Timber.tag(TAG).d("Downloading missing day packages: %s", missingDays) + requireStorageSpace(missingDays) + + val downloads = launchDownloads(missingDays, downloadConfig) + + Timber.tag(TAG).d("Waiting for %d missing day downloads.", downloads.size) + val downloadedDays = downloads.awaitAll().filterNotNull().also { + Timber.tag(TAG).v("Downloaded keyfile: %s", it.joinToString("\n")) + } + Timber.tag(TAG).i("Download success: ${downloadedDays.size}/${downloads.size}") + + return SyncResult( + successful = downloads.size == downloadedDays.size, + newPackages = downloadedDays + ) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun expectNewDayPackages(cachedDays: List<CachedKey>): Boolean { + val yesterday = timeStamper.nowUTC.toLocalDate().minusDays(1) + val newestDay = cachedDays.map { it.info.toDateTime() }.maxOrNull()?.toLocalDate() + + return yesterday != newestDay + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal suspend fun determineMissingDayPackages(location: LocationCode, forceIndexLookup: Boolean): LocationDays? { + val cachedDays = getDownloadedCachedKeys(location, Type.LOCATION_DAY) + + if (!forceIndexLookup && !expectNewDayPackages(cachedDays)) return null + + val availableDays = LocationDays(location, keyServer.getDayIndex(location)) + + val staleDays = cachedDays.findStaleData(listOf(availableDays)) + + if (staleDays.isNotEmpty()) { + Timber.tag(TAG).d("Deleting stale days (loation=%s): %s", location, staleDays) + keyCache.delete(staleDays.map { it.info }) + } + + val nonStaleDays = cachedDays.minus(staleDays) + + return availableDays.toMissingDays(nonStaleDays) // The missing days + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal suspend fun launchDownloads( + missingDayData: Collection<LocationDays>, + downloadConfig: KeyDownloadConfig + ): Collection<Deferred<CachedKey?>> { + val launcher: CoroutineScope.(LocationDays, LocalDate) -> Deferred<CachedKey?> = { locationData, targetDay -> + async { + val cachedKey = keyCache.createCacheEntry( + location = locationData.location, + dayIdentifier = targetDay, + hourIdentifier = null, + type = Type.LOCATION_DAY + ) + try { + downloadTool.downloadKeyFile(cachedKey, downloadConfig) + } catch (e: Exception) { + // We can't throw otherwise it cancels the other downloads too (awaitAll) + null + } + } + } + val downloads = missingDayData.flatMap { location -> + location.dayData.map { dayDate -> location to dayDate } + } + Timber.tag(TAG).d("Launching %d downloads.", downloads.size) + + return downloads.map { (locationData, targetDay) -> + withContext(context = dispatcherProvider.IO) { + launcher(locationData, targetDay) + } + } + } + + companion object { + private const val TAG = "DayPackageSyncTool" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt index 3d1aa4fa3..870c80617 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTask.kt @@ -1,26 +1,30 @@ package de.rki.coronawarnapp.diagnosiskeys.download import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode import de.rki.coronawarnapp.environment.EnvironmentSetup import de.rki.coronawarnapp.nearby.ENFClient import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient +import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection import de.rki.coronawarnapp.risk.RollbackItem import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.task.Task import de.rki.coronawarnapp.task.TaskCancellationException import de.rki.coronawarnapp.task.TaskFactory +import de.rki.coronawarnapp.task.TaskFactory.Config.CollisionBehavior import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.ui.toLazyString import de.rki.coronawarnapp.worker.BackgroundWorkHelper import kotlinx.coroutines.channels.ConflatedBroadcastChannel import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow +import kotlinx.coroutines.flow.first import org.joda.time.DateTime import org.joda.time.DateTimeZone import org.joda.time.Duration +import org.joda.time.Instant import timber.log.Timber -import java.io.File import java.util.Date import java.util.UUID import javax.inject.Inject @@ -30,7 +34,7 @@ class DownloadDiagnosisKeysTask @Inject constructor( private val enfClient: ENFClient, private val environmentSetup: EnvironmentSetup, private val appConfigProvider: AppConfigProvider, - private val keyFileDownloader: KeyFileDownloader, + private val keyPackageSyncTool: KeyPackageSyncTool, private val timeStamper: TimeStamper ) : Task<DownloadDiagnosisKeysTask.Progress, Task.Result> { @@ -39,6 +43,7 @@ class DownloadDiagnosisKeysTask @Inject constructor( private var isCanceled = false + @Suppress("LongMethod") override suspend fun run(arguments: Task.Arguments): Task.Result { val rollbackItems = mutableListOf<RollbackItem>() try { @@ -59,7 +64,7 @@ class DownloadDiagnosisKeysTask @Inject constructor( return object : Task.Result {} } - checkCancel() + throwIfCancelled() val currentDate = Date(timeStamper.nowUTC.millis) Timber.tag(TAG).d("Using $currentDate as current date in task.") @@ -67,21 +72,38 @@ class DownloadDiagnosisKeysTask @Inject constructor( * RETRIEVE TOKEN ****************************************************/ val token = retrieveToken(rollbackItems) - checkCancel() + throwIfCancelled() // RETRIEVE RISK SCORE PARAMETERS - val exposureConfiguration = appConfigProvider.getAppConfig().exposureDetectionConfiguration + val exposureConfig: ExposureDetectionConfig = appConfigProvider.getAppConfig() internalProgress.send(Progress.ApiSubmissionStarted) internalProgress.send(Progress.KeyFilesDownloadStarted) val requestedCountries = arguments.requestedCountries - val availableKeyFiles = getAvailableKeyFiles(requestedCountries) - checkCancel() + val keySyncResult = getAvailableKeyFiles(requestedCountries) + throwIfCancelled() - val totalFileSize = availableKeyFiles.fold(0L, { acc, file -> - file.length() + acc - }) + val trackedExposureDetections = enfClient.latestTrackedExposureDetection().first() + val now = timeStamper.nowUTC + + if (exposureConfig.maxExposureDetectionsPerUTCDay == 0) { + Timber.tag(TAG).w("Exposure detections are disabled! maxExposureDetectionsPerUTCDay=0") + return object : Task.Result {} + } + + if (wasLastDetectionPerformedRecently(now, exposureConfig, trackedExposureDetections)) { + // At most one detection every 6h + return object : Task.Result {} + } + + if (hasRecentDetectionAndNoNewFiles(now, keySyncResult, trackedExposureDetections)) { + // Last check was within 24h, and there are no new files. + return object : Task.Result {} + } + + val availableKeyFiles = keySyncResult.availableKeys.map { it.path } + val totalFileSize = availableKeyFiles.fold(0L, { acc, file -> file.length() + acc }) internalProgress.send( Progress.KeyFilesDownloadFinished( @@ -93,13 +115,13 @@ class DownloadDiagnosisKeysTask @Inject constructor( Timber.tag(TAG).d("Attempting submission to ENF") val isSubmissionSuccessful = enfClient.provideDiagnosisKeys( keyFiles = availableKeyFiles, - configuration = exposureConfiguration, + configuration = exposureConfig.exposureDetectionConfiguration, token = token ) Timber.tag(TAG).d("Diagnosis Keys provided (success=%s, token=%s)", isSubmissionSuccessful, token) internalProgress.send(Progress.ApiSubmissionFinished) - checkCancel() + throwIfCancelled() if (isSubmissionSuccessful) { saveTimestamp(currentDate, rollbackItems) @@ -118,6 +140,35 @@ class DownloadDiagnosisKeysTask @Inject constructor( } } + private fun wasLastDetectionPerformedRecently( + now: Instant, + exposureConfig: ExposureDetectionConfig, + trackedDetections: Collection<TrackedExposureDetection> + ): Boolean { + val lastDetection = trackedDetections.maxByOrNull { it.startedAt } + val nextDetectionAt = lastDetection?.startedAt?.plus(exposureConfig.minTimeBetweenDetections) + + return (nextDetectionAt != null && now.isBefore(nextDetectionAt)).also { + if (it) Timber.tag(TAG).w("Aborting. Last detection is recent: %s (now=%s)", lastDetection, now) + } + } + + private fun hasRecentDetectionAndNoNewFiles( + now: Instant, + keySyncResult: KeyPackageSyncTool.Result, + trackedDetections: Collection<TrackedExposureDetection> + ): Boolean { + // One forced detection every 24h, ignoring the sync results + val lastSuccessfulDetection = trackedDetections.filter { it.isSuccessful }.maxByOrNull { it.startedAt } + val nextForcedDetectionAt = lastSuccessfulDetection?.startedAt?.plus(Duration.standardDays(1)) + + val hasRecentDetection = nextForcedDetectionAt != null && now.isBefore(nextForcedDetectionAt) + + return (hasRecentDetection && keySyncResult.newKeys.isEmpty()).also { + if (it) Timber.tag(TAG).w("Aborting. Last detection is recent (<24h) and no new keyfiles.") + } + } + private fun saveTimestamp( currentDate: Date, rollbackItems: MutableList<RollbackItem> @@ -168,22 +219,17 @@ class DownloadDiagnosisKeysTask @Inject constructor( } } - private suspend fun getAvailableKeyFiles(requestedCountries: List<String>?): List<File> { - val availableKeyFiles = - keyFileDownloader.asyncFetchKeyFiles(if (environmentSetup.useEuropeKeyPackageFiles) { - listOf("EUR") - } else { - requestedCountries - ?: appConfigProvider.getAppConfig().supportedCountries - }.map { LocationCode(it) }) - - if (availableKeyFiles.isEmpty()) { - Timber.tag(TAG).w("No keyfiles were available!") - } - return availableKeyFiles + private suspend fun getAvailableKeyFiles(requestedCountries: List<String>?): KeyPackageSyncTool.Result { + val wantedLocations = if (environmentSetup.useEuropeKeyPackageFiles) { + listOf("EUR") + } else { + requestedCountries ?: appConfigProvider.getAppConfig().supportedCountries + }.map { LocationCode(it) } + + return keyPackageSyncTool.syncKeyFiles(wantedLocations) } - private fun checkCancel() { + private fun throwIfCancelled() { if (isCanceled) throw TaskCancellationException() } @@ -210,16 +256,19 @@ class DownloadDiagnosisKeysTask @Inject constructor( data class Config( override val executionTimeout: Duration = Duration.standardMinutes(8), // TODO unit-test that not > 9 min - override val collisionBehavior: TaskFactory.Config.CollisionBehavior = - TaskFactory.Config.CollisionBehavior.ENQUEUE + override val collisionBehavior: CollisionBehavior = CollisionBehavior.SKIP_IF_SIBLING_RUNNING ) : TaskFactory.Config class Factory @Inject constructor( - private val taskByDagger: Provider<DownloadDiagnosisKeysTask> + private val taskByDagger: Provider<DownloadDiagnosisKeysTask>, + private val appConfigProvider: AppConfigProvider ) : TaskFactory<Progress, Task.Result> { - override val config: TaskFactory.Config = Config() + override suspend fun createConfig(): TaskFactory.Config = Config( + executionTimeout = appConfigProvider.getAppConfig().overallDownloadTimeout + ) + override val taskProvider: () -> Task<Progress, Task.Result> = { taskByDagger.get() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt new file mode 100644 index 000000000..d1ab74d34 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt @@ -0,0 +1,166 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import androidx.annotation.VisibleForTesting +import dagger.Reusable +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.storage.DeviceStorage +import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDate +import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalTime +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext +import org.joda.time.Instant +import org.joda.time.LocalDate +import org.joda.time.LocalTime +import timber.log.Timber +import java.io.IOException +import javax.inject.Inject + +@Reusable +class HourPackageSyncTool @Inject constructor( + deviceStorage: DeviceStorage, + private val keyServer: DiagnosisKeyServer, + private val keyCache: KeyCacheRepository, + private val downloadTool: KeyDownloadTool, + private val timeStamper: TimeStamper, + private val configProvider: AppConfigProvider, + private val dispatcherProvider: DispatcherProvider +) : BaseKeyPackageSyncTool( + keyCache = keyCache, + deviceStorage = deviceStorage, + tag = TAG +) { + + internal suspend fun syncMissingHourPackages( + targetLocations: List<LocationCode>, + forceIndexLookup: Boolean + ): SyncResult { + Timber.tag(TAG).v("syncMissingHours(targetLocations=%s)", targetLocations) + + val downloadConfig: KeyDownloadConfig = configProvider.getAppConfig() + invalidateCachedKeys(downloadConfig.invalidHourEtags) + + val missingHours = targetLocations.mapNotNull { + determineMissingHours(it, forceIndexLookup) + } + if (missingHours.isEmpty()) { + Timber.tag(TAG).i("There were no missing hours.") + return SyncResult(successful = true, newPackages = emptyList()) + } + + Timber.tag(TAG).d("Downloading missing hours: %s", missingHours) + requireStorageSpace(missingHours) + + val hourDownloads = launchDownloads(missingHours, downloadConfig) + + Timber.tag(TAG).d("Waiting for %d missing hour downloads.", hourDownloads.size) + val downloadedHours = hourDownloads.awaitAll().filterNotNull().also { + Timber.tag(TAG).v("Downloaded keyfile: %s", it.joinToString("\n")) + } + Timber.tag(TAG).i("Download success: ${downloadedHours.size}/${hourDownloads.size}") + + return SyncResult( + successful = hourDownloads.size == downloadedHours.size, + newPackages = downloadedHours + ) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal suspend fun launchDownloads( + missingHours: Collection<LocationHours>, + downloadConfig: KeyDownloadConfig + ): Collection<Deferred<CachedKey?>> { + val launcher: CoroutineScope.(LocationHours, LocalDate, LocalTime) -> Deferred<CachedKey?> = + { locationData, targetDay, targetHour -> + async { + val cachedKey = keyCache.createCacheEntry( + location = locationData.location, + dayIdentifier = targetDay, + hourIdentifier = targetHour, + type = Type.LOCATION_HOUR + ) + + try { + downloadTool.downloadKeyFile(cachedKey, downloadConfig) + } catch (e: Exception) { + // We can't throw otherwise it cancels the other downloads too (awaitAll) + null + } + } + } + + val downloads = missingHours + .flatMap { location -> + location.hourData.map { Triple(location, it.key, it.value) } + } + .flatMap { (location, day, hours) -> + hours.map { Triple(location, day, it) } + } + Timber.tag(TAG).d("Launching %d downloads.", downloads.size) + + return downloads.map { (location, day, missingHour) -> + withContext(context = dispatcherProvider.IO) { + launcher(location, day, missingHour) + } + } + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun expectNewHourPackages(cachedHours: List<CachedKey>, now: Instant): Boolean { + val previousHour = now.toLocalTime().minusHours(1) + val newestHour = cachedHours.map { it.info.toDateTime() }.maxOrNull()?.toLocalTime() + + return previousHour.hourOfDay != newestHour?.hourOfDay + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal suspend fun determineMissingHours(location: LocationCode, forceIndexLookup: Boolean): LocationHours? { + val cachedHours = getDownloadedCachedKeys(location, Type.LOCATION_HOUR) + + val now = timeStamper.nowUTC + + if (!forceIndexLookup && !expectNewHourPackages(cachedHours, now)) return null + + val today = now.toLocalDate() + + val availableHours = run { + val hoursToday = try { + keyServer.getHourIndex(location, today) + } catch (e: IOException) { + Timber.tag(TAG).e(e, "failed to get today's hour index.") + emptyList() + } + LocationHours(location, mapOf(today to hoursToday)) + } + + // If we have hours in covered by a day, delete the hours + val cachedDays = getDownloadedCachedKeys(location, Type.LOCATION_DAY).map { + it.info.day + }.let { LocationDays(location, it) } + + val staleHours = cachedHours.findStaleData(listOf(cachedDays, availableHours)) + + if (staleHours.isNotEmpty()) { + Timber.tag(TAG).v("Deleting stale hours: %s", staleHours) + keyCache.delete(staleHours.map { it.info }) + } + + val nonStaleHours = cachedHours.minus(staleHours) + + return availableHours.toMissingHours(nonStaleHours) // The missing hours + } + + companion object { + private const val TAG = "HourPackageSyncTool" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadTool.kt new file mode 100644 index 000000000..c182eef18 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadTool.kt @@ -0,0 +1,68 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import dagger.Reusable +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer +import de.rki.coronawarnapp.diagnosiskeys.server.DownloadInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration +import kotlinx.coroutines.withTimeout +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class KeyDownloadTool @Inject constructor( + private val legacyKeyCache: LegacyKeyCacheMigration, + private val keyServer: DiagnosisKeyServer, + private val keyCache: KeyCacheRepository +) { + suspend fun downloadKeyFile( + cachedKey: CachedKey, + downloadConfig: KeyDownloadConfig + ): CachedKey = try { + val saveTo = cachedKey.path + val keyInfo = cachedKey.info + + val preconditionHook: suspend (DownloadInfo) -> Boolean = + { downloadInfo -> + /** + * To try legacy migration, we attempt to the etag as checksum. + * Removing the quotes, the etag can represent the file's MD5 checksum. + */ + val etagAsChecksum = downloadInfo.etag?.removePrefix("\"")?.removeSuffix("\"") + val continueDownload = !legacyKeyCache.tryMigration(etagAsChecksum, saveTo) + continueDownload // Continue download if no migration happened + } + + val downloadInfo = withTimeout(downloadConfig.individualDownloadTimeout.millis) { + keyServer.downloadKeyFile( + locationCode = keyInfo.location, + day = keyInfo.day, + hour = keyInfo.hour, + saveTo = saveTo, + precondition = preconditionHook + ) + } + Timber.tag(TAG).v("Download finished: %s -> %s", cachedKey, saveTo) + + /** + * If for some reason the server doesn't supply the etag, let's make our own. + * If it later gets used, it will not match. + * Worst case, we delete it and download the same file again, + * hopefully then with an etag in the header. + */ + val etag = requireNotNull(downloadInfo.etag) { "Server provided no ETAG!" } + keyCache.markKeyComplete(keyInfo, etag) + + cachedKey + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Download failed: %s", cachedKey) + keyCache.delete(listOf(cachedKey.info)) + throw e + } + + companion object { + private const val TAG = "${KeyPackageSyncTool.TAG}:DownloadTool" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloader.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloader.kt deleted file mode 100644 index 60f6aca34..000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloader.kt +++ /dev/null @@ -1,348 +0,0 @@ -package de.rki.coronawarnapp.diagnosiskeys.download - -import dagger.Reusable -import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer -import de.rki.coronawarnapp.diagnosiskeys.server.DownloadInfo -import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode -import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo -import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository -import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration -import de.rki.coronawarnapp.risk.TimeVariables -import de.rki.coronawarnapp.storage.DeviceStorage -import de.rki.coronawarnapp.storage.TestSettings -import de.rki.coronawarnapp.util.coroutine.DispatcherProvider -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.withContext -import org.joda.time.LocalTime -import timber.log.Timber -import java.io.File -import javax.inject.Inject - -/** - * Downloads new or missing key files from the CDN - */ -@Reusable -class KeyFileDownloader @Inject constructor( - private val deviceStorage: DeviceStorage, - private val keyServer: DiagnosisKeyServer, - private val keyCache: KeyCacheRepository, - private val legacyKeyCache: LegacyKeyCacheMigration, - private val testSettings: TestSettings, - private val dispatcherProvider: DispatcherProvider -) { - - private suspend fun requireStorageSpace(data: List<CountryData>): DeviceStorage.CheckResult { - val requiredBytes = data.fold(0L) { acc, item -> - acc + item.approximateSizeInBytes - } - Timber.d("%dB are required for %s", requiredBytes, data) - return deviceStorage.requireSpacePrivateStorage(requiredBytes).also { - Timber.tag(TAG).d("Storage check result: %s", it) - } - } - - private suspend fun getCompletedKeyFiles(type: CachedKeyInfo.Type): List<CachedKeyInfo> { - return keyCache - .getEntriesForType(type) - .filter { (keyInfo, file) -> - val complete = keyInfo.isDownloadComplete - val exists = file.exists() - if (complete && !exists) { - Timber.tag(TAG).v("Incomplete download, will overwrite: %s", keyInfo) - } - // We overwrite not completed ones - complete && exists - } - .map { it.first } - } - - /** - * Fetches all necessary Files from the Cached KeyFile Entries out of the [KeyCacheRepository] and - * adds to that all open Files currently available from the Server. - * - * Assumptions made about the implementation: - * - the app initializes with an empty cache and draws in every available data set in the beginning - * - the difference can only work properly if the date from the device is synchronized through the net - * - the difference in timezone is taken into account by using UTC in the Conversion from the Date to Server format - * - * @return list of all files from both the cache and the diff query - */ - suspend fun asyncFetchKeyFiles(wantedCountries: List<LocationCode>): List<File> = - withContext(dispatcherProvider.IO) { - val availableCountries = keyServer.getCountryIndex() - val filteredCountries = availableCountries.filter { wantedCountries.contains(it) } - Timber.tag(TAG).v( - "Available=%s; Wanted=%s; Intersect=%s", - availableCountries, wantedCountries, filteredCountries - ) - - val availableKeys = - if (testSettings.isHourKeyPkgMode) { - syncMissing3Hours(filteredCountries, DEBUG_HOUR_LIMIT) - keyCache.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR) - } else { - syncMissingDays(filteredCountries) - keyCache.getEntriesForType(CachedKeyInfo.Type.COUNTRY_DAY) - } - - return@withContext availableKeys - .filter { it.first.isDownloadComplete && it.second.exists() } - .mapNotNull { (keyInfo, path) -> - if (!path.exists()) { - Timber.tag(TAG).w("Missing keyfile for : %s", keyInfo) - null - } else { - Timber.tag(TAG).v("Providing available key: %s", keyInfo) - path - } - } - .also { Timber.tag(TAG).d("Returning %d available keyfiles", it.size) } - } - - private suspend fun determineMissingDays(availableCountries: List<LocationCode>): List<CountryDays> { - val availableDays = availableCountries.map { - val days = keyServer.getDayIndex(it) - CountryDays(it, days) - } - - val cachedDays = getCompletedKeyFiles(CachedKeyInfo.Type.COUNTRY_DAY) - - val staleDays = getStale(cachedDays, availableDays) - - if (staleDays.isNotEmpty()) { - Timber.tag(TAG).v("Deleting stale days: %s", staleDays) - keyCache.delete(staleDays) - } - - val nonStaleDays = cachedDays.minus(staleDays) - - // The missing days - return availableDays.mapNotNull { it.toMissingDays(nonStaleDays) } - } - - /** - * Fetches files given by serverDates by respecting countries - * @param availableCountries pair of dates per country code - */ - private suspend fun syncMissingDays( - availableCountries: List<LocationCode> - ) = withContext(dispatcherProvider.IO) { - val countriesWithMissingDays = determineMissingDays(availableCountries) - - requireStorageSpace(countriesWithMissingDays) - - Timber.tag(TAG).d("Downloading missing days: %s", countriesWithMissingDays) - val batchDownloadStart = System.currentTimeMillis() - val dayDownloads = countriesWithMissingDays - .flatMap { country -> - country.dayData.map { dayDate -> country to dayDate } - } - .map { (countryWrapper, dayDate) -> - async { - val (keyInfo, path) = keyCache.createCacheEntry( - location = countryWrapper.country, - dayIdentifier = dayDate, - hourIdentifier = null, - type = CachedKeyInfo.Type.COUNTRY_DAY - ) - - return@async downloadKeyFile(keyInfo, path) - } - } - - Timber.tag(TAG).d("Waiting for %d missing day downloads.", dayDownloads.size) - // execute the query plan - val downloadedDays = dayDownloads.awaitAll().filterNotNull() - - Timber.tag(TAG).d( - "Batch download (%d files) finished in %dms", - dayDownloads.size, - (System.currentTimeMillis() - batchDownloadStart) - ) - - downloadedDays.map { (keyInfo, path) -> - Timber.tag(TAG).v("Downloaded keyfile: %s to %s", keyInfo, path) - path - } - - return@withContext - } - - private suspend fun determineMissingHours( - availableCountries: List<LocationCode>, - itemLimit: Int - ): List<CountryHours> { - val availableHours = availableCountries.flatMap { location -> - var remainingItems = itemLimit - // Descending because we go backwards newest -> oldest - val indexWithToday = keyServer.getDayIndex(location).let { - val lastDayInIndex = it.maxOrNull() - Timber.tag(TAG).v("Last day in index: %s", lastDayInIndex) - if (lastDayInIndex != null) { - it.plus(lastDayInIndex.plusDays(1)) - } else { - it - } - } - Timber.tag(TAG).v("Day index with (fake) today entry: %s", indexWithToday) - - indexWithToday.sortedDescending().mapNotNull { day -> - // Limit reached, return null (filtered out) instead of new CountryHours object - if (remainingItems <= 0) return@mapNotNull null - - val hoursForDate = mutableListOf<LocalTime>() - for (hour in keyServer.getHourIndex(location, day).sortedDescending()) { - if (remainingItems <= 0) break - remainingItems-- - hoursForDate.add(hour) - } - - CountryHours(location, mapOf(day to hoursForDate)) - } - } - - val cachedHours = getCompletedKeyFiles(CachedKeyInfo.Type.COUNTRY_HOUR) - - val staleHours = getStale(cachedHours, availableHours) - - if (staleHours.isNotEmpty()) { - Timber.tag(TAG).v("Deleting stale hours: %s", staleHours) - keyCache.delete(staleHours) - } - - val nonStaleHours = cachedHours.minus(staleHours) - - // The missing hours - return availableHours.mapNotNull { it.toMissingHours(nonStaleHours) } - } - - // All cached files that are no longer on the server are considered stale - private fun getStale( - cachedKeys: List<CachedKeyInfo>, - availableData: List<CountryData> - ): List<CachedKeyInfo> = cachedKeys.filter { cachedKey -> - val availableCountry = availableData - .filter { it.country == cachedKey.location } - .singleOrNull { - when (cachedKey.type) { - CachedKeyInfo.Type.COUNTRY_DAY -> true - CachedKeyInfo.Type.COUNTRY_HOUR -> { - it as CountryHours - it.hourData.containsKey(cachedKey.day) - } - } - } - if (availableCountry == null) { - Timber.w("Unknown location %s, assuming stale hour.", cachedKey.location) - return@filter true // It's stale - } - - when (cachedKey.type) { - CachedKeyInfo.Type.COUNTRY_DAY -> { - availableCountry as CountryDays - availableCountry.dayData.none { date -> - cachedKey.day == date - } - } - CachedKeyInfo.Type.COUNTRY_HOUR -> { - availableCountry as CountryHours - val availableDay = availableCountry.hourData[cachedKey.day] - if (availableDay == null) { - Timber.d("Unknown day %s, assuming stale hour.", cachedKey.location) - return@filter true // It's stale - } - - availableDay.none { time -> - cachedKey.hour == time - } - } - } - } - - /** - * Fetches files given by serverDates by respecting countries - * @param availableCountries pair of dates per country code - * @param hourItemLimit how many hours to go back - */ - private suspend fun syncMissing3Hours( - availableCountries: List<LocationCode>, - hourItemLimit: Int - ) = withContext(dispatcherProvider.IO) { - Timber.tag(TAG).v( - "asyncHandleLast3HoursFilesFetch(availableCountries=%s, hourLimit=%d)", - availableCountries, hourItemLimit - ) - val missingHours = determineMissingHours(availableCountries, hourItemLimit) - Timber.tag(TAG).d("Downloading missing hours: %s", missingHours) - - requireStorageSpace(missingHours) - - val hourDownloads = missingHours.flatMap { country -> - country.hourData.flatMap { (day, missingHours) -> - missingHours.map { missingHour -> - async { - val (keyInfo, path) = keyCache.createCacheEntry( - location = country.country, - dayIdentifier = day, - hourIdentifier = missingHour, - type = CachedKeyInfo.Type.COUNTRY_HOUR - ) - - return@async downloadKeyFile(keyInfo, path) - } - } - } - } - - Timber.tag(TAG).d("Waiting for %d missing hour downloads.", hourDownloads.size) - val downloadedHours = hourDownloads.awaitAll().filterNotNull() - - downloadedHours.map { (keyInfo, path) -> - Timber.tag(TAG).d("Downloaded keyfile: %s to %s", keyInfo, path) - path - } - - return@withContext - } - - private suspend fun downloadKeyFile( - keyInfo: CachedKeyInfo, - saveTo: File - ): Pair<CachedKeyInfo, File>? = try { - val preconditionHook: suspend (DownloadInfo) -> Boolean = - { downloadInfo -> - val continueDownload = !legacyKeyCache.tryMigration( - downloadInfo.serverMD5, saveTo - ) - continueDownload // Continue download if no migration happened - } - - val dlInfo = keyServer.downloadKeyFile( - locationCode = keyInfo.location, - day = keyInfo.day, - hour = keyInfo.hour, - saveTo = saveTo, - precondition = preconditionHook - ) - - Timber.tag(TAG).v("Dowwnload finished: %s -> %s", keyInfo, saveTo) - - keyCache.markKeyComplete(keyInfo, dlInfo.serverMD5 ?: dlInfo.localMD5!!) - keyInfo to saveTo - } catch (e: Exception) { - Timber.tag(TAG).e(e, "Download failed: %s", keyInfo) - keyCache.delete(listOf(keyInfo)) - null - } - - companion object { - private val TAG: String? = KeyFileDownloader::class.simpleName - private const val DEBUG_HOUR_LIMIT = 24 - - // Daymode: ~512KB per day, ~14 days - // Hourmode: ~20KB per hour, 24 hours, also ~512KB - private val EXPECTED_STORAGE_PER_COUNTRY = - TimeVariables.getDefaultRetentionPeriodInDays() * 512 * 1024L - } -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncSettings.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncSettings.kt new file mode 100644 index 000000000..46499c312 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncSettings.kt @@ -0,0 +1,41 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import android.content.Context +import com.google.gson.Gson +import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.preferences.FlowPreference +import de.rki.coronawarnapp.util.serialization.BaseGson +import org.joda.time.Instant +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class KeyPackageSyncSettings @Inject constructor( + @AppContext private val context: Context, + @BaseGson private val gson: Gson +) { + + private val prefs by lazy { + context.getSharedPreferences("keysync_localdata", Context.MODE_PRIVATE) + } + + val lastDownloadDays = FlowPreference( + preferences = prefs, + key = "download.last.days", + reader = FlowPreference.gsonReader<LastDownload?>(gson, null), + writer = FlowPreference.gsonWriter(gson) + ) + val lastDownloadHours = FlowPreference( + preferences = prefs, + key = "download.last.hours", + reader = FlowPreference.gsonReader<LastDownload?>(gson, null), + writer = FlowPreference.gsonWriter(gson) + ) + + data class LastDownload( + val startedAt: Instant, + val finishedAt: Instant? = null, + val successful: Boolean = false, + val newData: Boolean = false + ) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncTool.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncTool.kt new file mode 100644 index 000000000..9c8189c90 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncTool.kt @@ -0,0 +1,140 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import dagger.Reusable +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.network.NetworkStateProvider +import kotlinx.coroutines.flow.first +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class KeyPackageSyncTool @Inject constructor( + private val keyCache: KeyCacheRepository, + private val dayPackageSyncTool: DayPackageSyncTool, + private val hourPackageSyncTool: HourPackageSyncTool, + private val syncSettings: KeyPackageSyncSettings, + private val timeStamper: TimeStamper, + private val networkStateProvider: NetworkStateProvider +) { + + suspend fun syncKeyFiles( + wantedLocations: List<LocationCode> = listOf(LocationCode("EUR")) + ): Result { + cleanUpStaleLocation(wantedLocations) + + val daySyncResult = runDaySync(wantedLocations) + + val isMeteredConnection = networkStateProvider.networkState.first().isMeteredConnection + + val hourSyncResult = if (!isMeteredConnection) { + Timber.tag(TAG).d("Running hour sync...") + runHourSync(wantedLocations) + } else { + Timber.tag(TAG).d("Hour sync skipped, we are on a metered connection.") + null + } + + val availableKeys = keyCache.getAllCachedKeys() + .filter { it.info.isDownloadComplete } + .filter { (keyInfo, path) -> + path.exists().also { + if (!it) Timber.tag(TAG).w("Missing keyfile for : %s", keyInfo) + } + } + .also { Timber.tag(TAG).i("Returning %d available keyfiles", it.size) } + .also { Timber.tag(TAG).d("Available keyfiles: %s", it.joinToString("\n")) } + + val newKeys = mutableListOf<CachedKey>() + newKeys.addAll(daySyncResult.newPackages) + hourSyncResult?.let { newKeys.addAll(it.newPackages) } + + return Result( + availableKeys = availableKeys, + newKeys = newKeys, + wasDaySyncSucccessful = daySyncResult.successful + ) + } + + private suspend fun cleanUpStaleLocation(acceptedLocations: List<LocationCode>) { + Timber.tag(TAG).d("Checking for stale location, acceptable is: %s", acceptedLocations) + + val staleLocationData = keyCache.getAllCachedKeys() + .map { it.info } + .filter { !acceptedLocations.contains(it.location) } + if (staleLocationData.isNotEmpty()) { + Timber.tag(TAG).i("Deleting stale location data: %s", staleLocationData.joinToString("\n")) + keyCache.delete(staleLocationData) + } else { + Timber.tag(TAG).d("No stale location data exists.") + } + } + + private suspend fun runDaySync(locations: List<LocationCode>): BaseKeyPackageSyncTool.SyncResult { + val lastDownload = syncSettings.lastDownloadDays.value + Timber.tag(TAG).d("Synchronizing available days (lastDownload=%s).", lastDownload) + + syncSettings.lastDownloadDays.update { + KeyPackageSyncSettings.LastDownload(startedAt = timeStamper.nowUTC) + } + + val syncResult = dayPackageSyncTool.syncMissingDayPackages( + targetLocations = locations, + forceIndexLookup = lastDownload == null || !lastDownload.successful + ) + + syncSettings.lastDownloadDays.update { + if (it == null) { + Timber.tag(TAG).e("lastDownloadDays is missing a download start!?") + null + } else { + it.copy(finishedAt = timeStamper.nowUTC, successful = syncResult.successful) + } + } + + return syncResult.also { + Timber.tag(TAG).d("runDaySync(locations=%s): syncResult=%s", locations, it) + } + } + + private suspend fun runHourSync(locations: List<LocationCode>): BaseKeyPackageSyncTool.SyncResult { + val lastDownload = syncSettings.lastDownloadHours.value + Timber.tag(TAG).d("Synchronizing available hours (lastDownload=%s).", lastDownload) + + syncSettings.lastDownloadHours.update { + KeyPackageSyncSettings.LastDownload( + startedAt = timeStamper.nowUTC + ) + } + + val syncResult = hourPackageSyncTool.syncMissingHourPackages( + targetLocations = locations, + forceIndexLookup = lastDownload == null || !lastDownload.successful + ) + + syncSettings.lastDownloadHours.update { + if (it == null) { + Timber.tag(TAG).e("lastDownloadHours is missing a download start!?") + null + } else { + it.copy(finishedAt = timeStamper.nowUTC, successful = syncResult.successful) + } + } + + return syncResult.also { + Timber.tag(TAG).d("runHourSync(locations=%s): syncResult=%s", locations, it) + } + } + + data class Result( + val availableKeys: Collection<CachedKey>, + val newKeys: Collection<CachedKey>, + val wasDaySyncSucccessful: Boolean + ) + + companion object { + internal const val TAG = "KeySync" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/LocationData.kt similarity index 58% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryData.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/LocationData.kt index 6f58466ca..4eec18362 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryData.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/LocationData.kt @@ -1,21 +1,21 @@ package de.rki.coronawarnapp.diagnosiskeys.download import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode -import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey import org.joda.time.LocalDate import org.joda.time.LocalTime -sealed class CountryData { +sealed class LocationData { - abstract val country: LocationCode + abstract val location: LocationCode abstract val approximateSizeInBytes: Long } -internal data class CountryDays( - override val country: LocationCode, +internal data class LocationDays( + override val location: LocationCode, val dayData: Collection<LocalDate> -) : CountryData() { +) : LocationData() { override val approximateSizeInBytes: Long by lazy { dayData.size * APPROX_DAY_SIZE @@ -24,25 +24,25 @@ internal data class CountryDays( /** * Return a filtered list that contains all dates which are part of this wrapper, but not in the parameter. */ - fun getMissingDays(cachedKeys: List<CachedKeyInfo>): Collection<LocalDate>? { - val cachedCountryDates = cachedKeys - .filter { it.location == country } - .map { it.day } + fun getMissingDays(cachedKeys: List<CachedKey>): Collection<LocalDate>? { + val cachedLocationDates = cachedKeys + .filter { it.info.location == location } + .map { it.info.day } return dayData.filter { date -> - !cachedCountryDates.contains(date) + !cachedLocationDates.contains(date) } } /** - * Create a new country object that only contains those elements, + * Create a new location object that only contains those elements, * that are part of this wrapper, but not in the cache. */ - fun toMissingDays(cachedKeys: List<CachedKeyInfo>): CountryDays? { + fun toMissingDays(cachedKeys: List<CachedKey>): LocationDays? { val missingDays = this.getMissingDays(cachedKeys) if (missingDays == null || missingDays.isEmpty()) return null - return CountryDays(this.country, missingDays) + return LocationDays(this.location, missingDays) } companion object { @@ -51,10 +51,10 @@ internal data class CountryDays( } } -internal data class CountryHours( - override val country: LocationCode, +internal data class LocationHours( + override val location: LocationCode, val hourData: Map<LocalDate, List<LocalTime>> -) : CountryData() { +) : LocationData() { override val approximateSizeInBytes: Long by lazy { hourData.values.fold(0L) { acc, hoursForDay -> @@ -62,23 +62,23 @@ internal data class CountryHours( } } - fun getMissingHours(cachedKeys: List<CachedKeyInfo>): Map<LocalDate, List<LocalTime>>? { + fun getMissingHours(cachedKeys: List<CachedKey>): Map<LocalDate, List<LocalTime>>? { val cachedHours = cachedKeys - .filter { it.location == country } + .filter { it.info.location == location } return hourData.mapNotNull { (day, dayHours) -> val missingHours = dayHours.filter { hour -> - cachedHours.none { it.day == day && it.hour == hour } + cachedHours.none { it.info.day == day && it.info.hour == hour } } if (missingHours.isEmpty()) null else day to missingHours }.toMap() } - fun toMissingHours(cachedKeys: List<CachedKeyInfo>): CountryHours? { + fun toMissingHours(cachedKeys: List<CachedKey>): LocationHours? { val missingHours = this.getMissingHours(cachedKeys) if (missingHours == null || missingHours.isEmpty()) return null - return CountryHours(this.country, missingHours) + return LocationHours(this.location, missingHours) } companion object { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiV1.kt index 258321c44..575e48703 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiV1.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiV1.kt @@ -9,7 +9,7 @@ import retrofit2.http.Streaming interface DiagnosisKeyApiV1 { // TODO Let retrofit format this to CountryCode @GET("/version/v1/diagnosis-keys/country") - suspend fun getCountryIndex(): List<String> + suspend fun getLocationIndex(): List<String> // TODO Let retrofit format this to LocalDate @GET("/version/v1/diagnosis-keys/country/{country}/date") diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt index decf192f7..bc73024a6 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServer.kt @@ -2,8 +2,6 @@ package de.rki.coronawarnapp.diagnosiskeys.server import dagger.Lazy import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry -import de.rki.coronawarnapp.util.HashExtensions.hashToMD5 -import de.rki.coronawarnapp.util.debug.measureTimeMillisWithResult import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.joda.time.LocalDate @@ -24,9 +22,9 @@ class DiagnosisKeyServer @Inject constructor( private val keyApi: DiagnosisKeyApiV1 get() = diagnosisKeyAPI.get() - suspend fun getCountryIndex(): List<LocationCode> = withContext(Dispatchers.IO) { + suspend fun getLocationIndex(): List<LocationCode> = withContext(Dispatchers.IO) { keyApi - .getCountryIndex() + .getLocationIndex() .map { LocationCode(it) } } @@ -58,7 +56,7 @@ class DiagnosisKeyServer @Inject constructor( precondition: suspend (DownloadInfo) -> Boolean = { true } ): DownloadInfo = withContext(Dispatchers.IO) { Timber.tag(TAG).v( - "Starting download: country=%s, day=%s, hour=%s -> %s.", + "Starting download: location=%s, day=%s, hour=%s -> %s.", locationCode, day, hour, saveTo ) @@ -82,7 +80,7 @@ class DiagnosisKeyServer @Inject constructor( ) } - var downloadInfo = DownloadInfo(response.headers()) + val downloadInfo = DownloadInfo(response.headers()) if (!precondition(downloadInfo)) { Timber.tag(TAG).d("Precondition is not met, aborting.") @@ -95,10 +93,6 @@ class DiagnosisKeyServer @Inject constructor( } } - val (localMD5, duration) = measureTimeMillisWithResult { saveTo.hashToMD5() } - Timber.v("Hashed to MD5 in %dms: %s", duration, saveTo) - - downloadInfo = downloadInfo.copy(localMD5 = localMD5) Timber.tag(TAG).v("Key file download successful: %s", downloadInfo) return@withContext downloadInfo diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfo.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfo.kt index fb1dcbeeb..1310f2a0a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfo.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfo.kt @@ -2,24 +2,7 @@ package de.rki.coronawarnapp.diagnosiskeys.server import okhttp3.Headers -data class DownloadInfo( - val headers: Headers, - val localMD5: String? = null -) { +data class DownloadInfo(val headers: Headers) { - val serverMD5 by lazy { headers.getPayloadChecksumMD5() } - - private fun Headers.getPayloadChecksumMD5(): String? { - - val fileMD5 = values("ETag").singleOrNull() -// TODO EXPOSUREBACK-178 -// var fileMD5 = headers.values("x-amz-meta-cwa-hash-md5").singleOrNull() -// if (fileMD5 == null) { -// headers.values("x-amz-meta-cwa-hash").singleOrNull() -// } -// if (fileMD5 == null) { // Fallback -// fileMD5 = headers.values("ETag").singleOrNull() -// } - return fileMD5?.removePrefix("\"")?.removeSuffix("\"") - } + val etag by lazy { headers.values("ETag").singleOrNull() } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKey.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKey.kt new file mode 100644 index 000000000..bba9bcf20 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKey.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.diagnosiskeys.storage + +import java.io.File + +data class CachedKey(val info: CachedKeyInfo, val path: File) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyInfo.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyInfo.kt index b77f11c7b..9dd0b8ada 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyInfo.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyInfo.kt @@ -6,6 +6,8 @@ import androidx.room.PrimaryKey import androidx.room.TypeConverter import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode import de.rki.coronawarnapp.util.HashExtensions.toSHA1 +import org.joda.time.DateTime +import org.joda.time.DateTimeZone import org.joda.time.Instant import org.joda.time.LocalDate import org.joda.time.LocalTime @@ -18,7 +20,7 @@ data class CachedKeyInfo( @ColumnInfo(name = "day") val day: LocalDate, // i.e. 2020-08-23 @ColumnInfo(name = "hour") val hour: LocalTime?, // i.e. 23 @ColumnInfo(name = "createdAt") val createdAt: Instant, - @ColumnInfo(name = "checksumMD5") val checksumMD5: String?, + @ColumnInfo(name = "checksumMD5") val etag: String?, // ETag @ColumnInfo(name = "completed") val isDownloadComplete: Boolean ) { @@ -35,19 +37,24 @@ data class CachedKeyInfo( hour = hour, type = type, createdAt = createdAt, - checksumMD5 = null, + etag = null, isDownloadComplete = false ) @Transient val fileName: String = "$id.zip" - fun toDownloadUpdate(checksumMD5: String?): DownloadUpdate = DownloadUpdate( + fun toDownloadUpdate(etag: String): DownloadUpdate = DownloadUpdate( id = id, - checksumMD5 = checksumMD5, - isDownloadComplete = checksumMD5 != null + etag = etag, + isDownloadComplete = true ) + fun toDateTime(): DateTime = when (type) { + Type.LOCATION_DAY -> day.toDateTimeAtStartOfDay(DateTimeZone.UTC) + Type.LOCATION_HOUR -> day.toDateTime(hour, DateTimeZone.UTC) + } + companion object { fun calcluateId( location: LocationCode, @@ -62,8 +69,8 @@ data class CachedKeyInfo( } enum class Type constructor(internal val typeValue: String) { - COUNTRY_DAY("country_day"), - COUNTRY_HOUR("country_hour"); + LOCATION_DAY("country_day"), + LOCATION_HOUR("country_hour"); class Converter { @TypeConverter @@ -78,7 +85,7 @@ data class CachedKeyInfo( @Entity data class DownloadUpdate( @PrimaryKey @ColumnInfo(name = "id") val id: String, - @ColumnInfo(name = "checksumMD5") val checksumMD5: String?, + @ColumnInfo(name = "checksumMD5") val etag: String?, @ColumnInfo(name = "completed") val isDownloadComplete: Boolean ) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabase.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabase.kt index ec23391b1..f9f97faf0 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabase.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheDatabase.kt @@ -13,6 +13,7 @@ import androidx.room.TypeConverters import androidx.room.Update import de.rki.coronawarnapp.util.database.CommonConverters import de.rki.coronawarnapp.util.di.AppContext +import kotlinx.coroutines.flow.Flow import javax.inject.Inject @Database( @@ -28,7 +29,7 @@ abstract class KeyCacheDatabase : RoomDatabase() { @Dao interface CachedKeyFileDao { @Query("SELECT * FROM keyfiles") - suspend fun getAllEntries(): List<CachedKeyInfo> + fun allEntries(): Flow<List<CachedKeyInfo>> @Query("SELECT * FROM keyfiles WHERE type = :type") suspend fun getEntriesForType(type: String): List<CachedKeyInfo> diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepository.kt index c6f42e84e..ed05838ea 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepository.kt @@ -24,6 +24,9 @@ import android.database.sqlite.SQLiteConstraintException import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.di.AppContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.joda.time.LocalDate @@ -74,23 +77,32 @@ class KeyCacheRepository @Inject constructor( } private suspend fun doHouseKeeping() { - val dirtyInfos = getDao().getAllEntries().filter { - it.isDownloadComplete && !getPathForKey(it).exists() + val dirtyInfos = getAllCachedKeys().filter { + it.info.isDownloadComplete && !it.path.exists() } Timber.v("HouseKeeping, deleting: %s", dirtyInfos) - delete(dirtyInfos) + delete(dirtyInfos.map { it.info }) } + private fun CachedKeyInfo.toCachedKey(): CachedKey = CachedKey( + info = this, + path = getPathForKey(this) + ) + fun getPathForKey(cachedKeyInfo: CachedKeyInfo): File { return File(storageDir, cachedKeyInfo.fileName) } - suspend fun getAllCachedKeys(): List<Pair<CachedKeyInfo, File>> { - return getDao().getAllEntries().map { it to getPathForKey(it) } + suspend fun getAllCachedKeys(): List<CachedKey> { + return allCachedKeys().first() + } + + suspend fun allCachedKeys(): Flow<List<CachedKey>> { + return getDao().allEntries().map { entries -> entries.map { it.toCachedKey() } } } - suspend fun getEntriesForType(type: CachedKeyInfo.Type): List<Pair<CachedKeyInfo, File>> { - return getDao().getEntriesForType(type.typeValue).map { it to getPathForKey(it) } + suspend fun getEntriesForType(type: CachedKeyInfo.Type): List<CachedKey> { + return getDao().getEntriesForType(type.typeValue).map { it.toCachedKey() } } suspend fun createCacheEntry( @@ -98,8 +110,8 @@ class KeyCacheRepository @Inject constructor( location: LocationCode, dayIdentifier: LocalDate, hourIdentifier: LocalTime? - ): Pair<CachedKeyInfo, File> { - val newKeyFile = CachedKeyInfo( + ): CachedKey { + val keyInfo = CachedKeyInfo( type = type, location = location, day = dayIdentifier, @@ -107,19 +119,19 @@ class KeyCacheRepository @Inject constructor( createdAt = timeStamper.nowUTC ) - val targetFile = getPathForKey(newKeyFile) + val targetFile = getPathForKey(keyInfo) try { - getDao().insertEntry(newKeyFile) + getDao().insertEntry(keyInfo) if (targetFile.exists()) { Timber.w("Target path despite no collision exists, deleting: %s", targetFile) } } catch (e: SQLiteConstraintException) { - Timber.e(e, "Insertion collision? Overwriting for %s", newKeyFile) - delete(listOf(newKeyFile)) + Timber.e(e, "Insertion collision? Overwriting for %s", keyInfo) + delete(listOf(keyInfo)) - Timber.d(e, "Retrying insertion for %s", newKeyFile) - getDao().insertEntry(newKeyFile) + Timber.d(e, "Retrying insertion for %s", keyInfo) + getDao().insertEntry(keyInfo) } // This can't be null unless our cache dir is root `/` @@ -129,11 +141,11 @@ class KeyCacheRepository @Inject constructor( targetParent.mkdirs() } - return newKeyFile to targetFile + return CachedKey(info = keyInfo, path = targetFile) } - suspend fun markKeyComplete(cachedKeyInfo: CachedKeyInfo, checksumMD5: String) { - val update = cachedKeyInfo.toDownloadUpdate(checksumMD5) + suspend fun markKeyComplete(cachedKeyInfo: CachedKeyInfo, etag: String) { + val update = cachedKeyInfo.toDownloadUpdate(etag) getDao().updateDownloadState(update) } @@ -149,6 +161,6 @@ class KeyCacheRepository @Inject constructor( suspend fun clear() { Timber.i("clear()") - delete(getDao().getAllEntries()) + delete(getDao().allEntries().first()) } } 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 index 98914070b..c3de7abd0 100644 --- 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 @@ -4,8 +4,8 @@ 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.calculationtracker.Calculation -import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider import de.rki.coronawarnapp.nearby.modules.locationless.ScanningSupport import de.rki.coronawarnapp.nearby.modules.tracing.TracingStatus @@ -23,7 +23,7 @@ class ENFClient @Inject constructor( private val diagnosisKeyProvider: DiagnosisKeyProvider, private val tracingStatus: TracingStatus, private val scanningSupport: ScanningSupport, - private val calculationTracker: CalculationTracker + private val exposureDetectionTracker: ExposureDetectionTracker ) : DiagnosisKeyProvider, TracingStatus, ScanningSupport { // TODO Remove this once we no longer need direct access to the ENF Client, @@ -46,7 +46,7 @@ class ENFClient @Inject constructor( true } else { Timber.d("Forwarding %d key files to our DiagnosisKeyProvider.", keyFiles.size) - calculationTracker.trackNewCalaculation(token) + exposureDetectionTracker.trackNewExposureDetection(token) diagnosisKeyProvider.provideDiagnosisKeys(keyFiles, configuration, token) } } @@ -57,14 +57,17 @@ class ENFClient @Inject constructor( override val isTracingEnabled: Flow<Boolean> get() = tracingStatus.isTracingEnabled - fun isCurrentlyCalculating(): Flow<Boolean> = calculationTracker.calculations + fun isPerformingExposureDetection(): Flow<Boolean> = exposureDetectionTracker.calculations .map { it.values } .map { values -> values.maxBy { it.startedAt }?.isCalculating == true } - fun latestFinishedCalculation(): Flow<Calculation?> = - calculationTracker.calculations.map { snapshot -> + fun latestTrackedExposureDetection(): Flow<Collection<TrackedExposureDetection>> = + exposureDetectionTracker.calculations.map { it.values } + + fun lastSuccessfulTrackedExposureDetection(): Flow<TrackedExposureDetection?> = + exposureDetectionTracker.calculations.map { snapshot -> snapshot.values .filter { !it.isCalculating && it.isSuccessful } .maxByOrNull { it.finishedAt ?: Instant.EPOCH } 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 index 1bf6f5dd7..9d98d5b33 100644 --- 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 @@ -5,8 +5,8 @@ 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.calculationtracker.CalculationTracker -import de.rki.coronawarnapp.nearby.modules.calculationtracker.DefaultCalculationTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.DefaultExposureDetectionTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DefaultDiagnosisKeyProvider import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider import de.rki.coronawarnapp.nearby.modules.locationless.DefaultScanningSupport @@ -41,6 +41,6 @@ class ENFModule { @Singleton @Provides - fun calculationTracker(calculationTracker: DefaultCalculationTracker): CalculationTracker = - calculationTracker + fun calculationTracker(exposureDetectionTracker: DefaultExposureDetectionTracker): ExposureDetectionTracker = + exposureDetectionTracker } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt deleted file mode 100644 index 30bbc1b25..000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTracker.kt +++ /dev/null @@ -1,11 +0,0 @@ -package de.rki.coronawarnapp.nearby.modules.calculationtracker - -import kotlinx.coroutines.flow.Flow - -interface CalculationTracker { - val calculations: Flow<Map<String, Calculation>> - - fun trackNewCalaculation(identifier: String) - - fun finishCalculation(identifier: String, result: Calculation.Result) -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTracker.kt similarity index 68% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTracker.kt index c94eea81e..d75e3d2c4 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTracker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTracker.kt @@ -1,6 +1,7 @@ -package de.rki.coronawarnapp.nearby.modules.calculationtracker +package de.rki.coronawarnapp.nearby.modules.detectiontracker -import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation.Result +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection.Result import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.coroutine.AppScope import de.rki.coronawarnapp.util.coroutine.DispatcherProvider @@ -21,35 +22,36 @@ import javax.inject.Singleton import kotlin.math.min @Singleton -class DefaultCalculationTracker @Inject constructor( +class DefaultExposureDetectionTracker @Inject constructor( @AppScope private val scope: CoroutineScope, private val dispatcherProvider: DispatcherProvider, - private val storage: CalculationTrackerStorage, - private val timeStamper: TimeStamper -) : CalculationTracker { + private val storage: ExposureDetectionTrackerStorage, + private val timeStamper: TimeStamper, + private val appConfigProvider: AppConfigProvider +) : ExposureDetectionTracker { init { Timber.v("init()") } - private val calculationStates: HotDataFlow<Map<String, Calculation>> by lazy { - val setupAutoSave: (HotDataFlow<Map<String, Calculation>>) -> Unit = { hd -> + private val detectionStates: HotDataFlow<Map<String, TrackedExposureDetection>> by lazy { + val setupAutoSave: (HotDataFlow<Map<String, TrackedExposureDetection>>) -> Unit = { hd -> hd.data - .onStart { Timber.v("Observing calculation changes.") } + .onStart { Timber.v("Observing detection changes.") } .onEach { storage.save(it) } .launchIn(scope = scope + dispatcherProvider.Default) } - val setupTimeoutEnforcer: (HotDataFlow<Map<String, Calculation>>) -> Unit = { hd -> + val setupTimeoutEnforcer: (HotDataFlow<Map<String, TrackedExposureDetection>>) -> Unit = { hd -> flow<Unit> { while (true) { hd.updateSafely { val timeNow = timeStamper.nowUTC Timber.v("Running timeout check (now=%s): %s", timeNow, values) - + val timeoutLimit = appConfigProvider.getAppConfig().overallDetectionTimeout mutate { values.filter { it.isCalculating }.toList().forEach { - if (timeNow.isAfter(it.startedAt.plus(TIMEOUT_LIMIT))) { + if (timeNow.isAfter(it.startedAt.plus(timeoutLimit))) { Timber.w("Calculation timeout on %s", it) this[it.identifier] = it.copy( finishedAt = timeStamper.nowUTC, @@ -76,13 +78,13 @@ class DefaultCalculationTracker @Inject constructor( } } - override val calculations: Flow<Map<String, Calculation>> by lazy { calculationStates.data } + override val calculations: Flow<Map<String, TrackedExposureDetection>> by lazy { detectionStates.data } - override fun trackNewCalaculation(identifier: String) { - Timber.i("trackNewCalaculation(token=%s)", identifier) - calculationStates.updateSafely { + override fun trackNewExposureDetection(identifier: String) { + Timber.i("trackNewExposureDetection(token=%s)", identifier) + detectionStates.updateSafely { mutate { - this[identifier] = Calculation( + this[identifier] = TrackedExposureDetection( identifier = identifier, startedAt = timeStamper.nowUTC ) @@ -90,16 +92,16 @@ class DefaultCalculationTracker @Inject constructor( } } - override fun finishCalculation(identifier: String, result: Result) { - Timber.i("finishCalculation(token=%s, result=%s)", identifier, result) - calculationStates.updateSafely { + override fun finishExposureDetection(identifier: String, result: Result) { + Timber.i("finishExposureDetection(token=%s, result=%s)", identifier, result) + detectionStates.updateSafely { mutate { val existing = this[identifier] if (existing != null) { if (existing.result == Result.TIMEOUT) { - Timber.w("Calculation is late, already hit timeout, still updating.") + Timber.w("Detection is late, already hit timeout, still updating.") } else if (existing.result != null) { - Timber.e("Duplicate callback. Result is already set for calculation!") + Timber.e("Duplicate callback. Result is already set for detection!") } this[identifier] = existing.copy( result = result, @@ -107,11 +109,11 @@ class DefaultCalculationTracker @Inject constructor( ) } else { Timber.e( - "Unknown calculation finished (token=%s, result=%s)", + "Unknown detection finished (token=%s, result=%s)", identifier, result ) - this[identifier] = Calculation( + this[identifier] = TrackedExposureDetection( identifier = identifier, result = result, startedAt = timeStamper.nowUTC, @@ -132,9 +134,8 @@ class DefaultCalculationTracker @Inject constructor( } companion object { - private const val TAG = "DefaultCalculationTracker" + private const val TAG = "DefaultExposureDetectionTracker" private const val MAX_ENTRY_SIZE = 5 private val TIMEOUT_CHECK_INTERVALL = Duration.standardMinutes(3) - private val TIMEOUT_LIMIT = Duration.standardMinutes(15) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTracker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTracker.kt new file mode 100644 index 000000000..4d9bcf0c6 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTracker.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.nearby.modules.detectiontracker + +import kotlinx.coroutines.flow.Flow + +interface ExposureDetectionTracker { + val calculations: Flow<Map<String, TrackedExposureDetection>> + + fun trackNewExposureDetection(identifier: String) + + fun finishExposureDetection(identifier: String, result: TrackedExposureDetection.Result) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorage.kt similarity index 70% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorage.kt index 18fb150e3..01e93c4b9 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorage.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorage.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.nearby.modules.calculationtracker +package de.rki.coronawarnapp.nearby.modules.detectiontracker import android.content.Context import com.google.gson.Gson @@ -16,7 +16,7 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CalculationTrackerStorage @Inject constructor( +class ExposureDetectionTrackerStorage @Inject constructor( @AppContext private val context: Context, @BaseGson gson: Gson ) { @@ -33,36 +33,36 @@ class CalculationTrackerStorage @Inject constructor( } } private val storageFile by lazy { File(storageDir, "calculations.json") } - private var lastCalcuationData: Map<String, Calculation>? = null + private var lastCalcuationData: Map<String, TrackedExposureDetection>? = null init { Timber.v("init()") } - suspend fun load(): Map<String, Calculation> = mutex.withLock { + suspend fun load(): Map<String, TrackedExposureDetection> = mutex.withLock { return@withLock try { if (!storageFile.exists()) return@withLock emptyMap() - gson.fromJson<Map<String, Calculation>>(storageFile).also { - Timber.v("Loaded calculation data: %s", it) + gson.fromJson<Map<String, TrackedExposureDetection>>(storageFile).also { + Timber.v("Loaded detection data: %s", it) lastCalcuationData = it } } catch (e: Exception) { - Timber.e(e, "Failed to load tracked calculations.") + Timber.e(e, "Failed to load tracked detections.") emptyMap() } } - suspend fun save(data: Map<String, Calculation>) = mutex.withLock { + suspend fun save(data: Map<String, TrackedExposureDetection>) = mutex.withLock { if (lastCalcuationData == data) { Timber.v("Data didn't change, skipping save.") return@withLock } - Timber.v("Storing calculation data: %s", data) + Timber.v("Storing detection data: %s", data) try { gson.toJson(data, storageFile) } catch (e: Exception) { - Timber.e(e, "Failed to save tracked calculations.") + Timber.e(e, "Failed to save tracked detections.") } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetection.kt similarity index 88% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetection.kt index 13779d6ba..20fdddefe 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/Calculation.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetection.kt @@ -1,11 +1,11 @@ -package de.rki.coronawarnapp.nearby.modules.calculationtracker +package de.rki.coronawarnapp.nearby.modules.detectiontracker import androidx.annotation.Keep import com.google.gson.annotations.SerializedName import org.joda.time.Instant @Keep -data class Calculation( +data class TrackedExposureDetection( @SerializedName("identifier") val identifier: String, @SerializedName("startedAt") val startedAt: Instant, @SerializedName("result") val result: Result? = null, diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt index 314a944a7..dab0da7f0 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiver.kt @@ -15,8 +15,8 @@ import de.rki.coronawarnapp.exception.NoTokenException import de.rki.coronawarnapp.exception.UnknownBroadcastException import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker -import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation -import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection import de.rki.coronawarnapp.util.coroutine.AppScope import de.rki.coronawarnapp.util.coroutine.DispatcherProvider import kotlinx.coroutines.CoroutineScope @@ -41,7 +41,7 @@ class ExposureStateUpdateReceiver : BroadcastReceiver() { @Inject @AppScope lateinit var scope: CoroutineScope @Inject lateinit var dispatcherProvider: DispatcherProvider - @Inject lateinit var calculationTracker: CalculationTracker + @Inject lateinit var exposureDetectionTracker: ExposureDetectionTracker lateinit var context: Context override fun onReceive(context: Context, intent: Intent) { @@ -87,9 +87,9 @@ class ExposureStateUpdateReceiver : BroadcastReceiver() { .build() .let { workManager.enqueue(it) } - calculationTracker.finishCalculation( + exposureDetectionTracker.finishExposureDetection( token, - Calculation.Result.UPDATED_STATE + TrackedExposureDetection.Result.UPDATED_STATE ) } @@ -98,9 +98,9 @@ class ExposureStateUpdateReceiver : BroadcastReceiver() { val token = intent.requireToken() - calculationTracker.finishCalculation( + exposureDetectionTracker.finishExposureDetection( token, - Calculation.Result.NO_MATCHES + TrackedExposureDetection.Result.NO_MATCHES ) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt index edc963957..81959b959 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/RiskLevelTask.kt @@ -114,8 +114,7 @@ class RiskLevelTask @Inject constructor( InternalExposureNotificationClient.asyncGetExposureSummary(googleToken) return exposureSummary.also { - Timber.tag(TAG) - .v("Generated new exposure summary with $googleToken") + Timber.tag(TAG).v("Generated new exposure summary with $googleToken") } } @@ -124,18 +123,15 @@ class RiskLevelTask @Inject constructor( } private suspend fun backgroundJobsEnabled() = - backgroundModeStatus.isAutoModeEnabled.first().also { - if (it) { - Timber.tag(TAG) - .v("diagnosis keys outdated and active tracing time is above threshold") - Timber.tag(TAG) - .v("manual mode not active (background jobs enabled)") - } else { - Timber.tag(TAG) - .v("diagnosis keys outdated and active tracing time is above threshold") - Timber.tag(TAG).v("manual mode active (background jobs disabled)") - } + backgroundModeStatus.isAutoModeEnabled.first().also { + if (it) { + Timber.tag(TAG).v("diagnosis keys outdated and active tracing time is above threshold") + Timber.tag(TAG).v("manual mode not active (background jobs enabled)") + } else { + Timber.tag(TAG).v("diagnosis keys outdated and active tracing time is above threshold") + Timber.tag(TAG).v("manual mode active (background jobs disabled)") } + } override suspend fun cancel() { Timber.w("cancel() called.") @@ -161,7 +157,7 @@ class RiskLevelTask @Inject constructor( private val taskByDagger: Provider<RiskLevelTask> ) : TaskFactory<DefaultProgress, Result> { - override val config: TaskFactory.Config = Config() + override suspend fun createConfig(): TaskFactory.Config = Config() override val taskProvider: () -> Task<DefaultProgress, Result> = { taskByDagger.get() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TestSettings.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TestSettings.kt index 11152b8ff..261fd5fca 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TestSettings.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TestSettings.kt @@ -1,9 +1,8 @@ package de.rki.coronawarnapp.storage import android.content.Context -import androidx.core.content.edit -import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.preferences.createFlowPreference import javax.inject.Inject import javax.inject.Singleton @@ -15,16 +14,8 @@ class TestSettings @Inject constructor( context.getSharedPreferences("test_settings", Context.MODE_PRIVATE) } - var isHourKeyPkgMode: Boolean - get() { - val value = prefs.getBoolean(PKEY_HOURLY_TESTING_MODE, false) - return value && CWADebug.isDeviceForTestersBuild - } - set(value) = prefs.edit { - putBoolean(PKEY_HOURLY_TESTING_MODE, value) - } - - companion object { - private const val PKEY_HOURLY_TESTING_MODE = "diagnosiskeys.hourlytestmode" - } + val fakeMeteredConnection = prefs.createFlowPreference( + key = "connections.metered.fake", + defaultValue = false + ) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt index 86baa1239..0658b1060 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/TracingRepository.kt @@ -69,7 +69,7 @@ class TracingRepository @Inject constructor( } val tracingProgress: Flow<TracingProgress> = combine( internalIsRefreshing, - enfClient.isCurrentlyCalculating() + enfClient.isPerformingExposureDetection() ) { isDownloading, isCalculating -> when { isDownloading -> TracingProgress.Downloading diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt index b414b923a..2bff73211 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/SubmissionTask.kt @@ -96,7 +96,7 @@ class SubmissionTask @Inject constructor( private val taskByDagger: Provider<SubmissionTask> ) : TaskFactory<DefaultProgress, Task.Result> { - override val config: TaskFactory.Config = Config() + override suspend fun createConfig(): TaskFactory.Config = Config() override val taskProvider: () -> Task<DefaultProgress, Task.Result> = { taskByDagger.get() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt index 46b3964b9..932fb9beb 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskController.kt @@ -89,7 +89,7 @@ class TaskController @Inject constructor( requireNotNull(taskFactory) { "No factory available for $newRequest" } Timber.tag(TAG).v("Initiating task data for request: %s", newRequest) - val taskConfig = taskFactory.config + val taskConfig = taskFactory.createConfig() val task = taskFactory.taskProvider() val deferred = taskScope.async(start = CoroutineStart.LAZY) { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskFactory.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskFactory.kt index 8c694e86f..652bf1b58 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskFactory.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/TaskFactory.kt @@ -21,7 +21,7 @@ interface TaskFactory< } } - val config: Config + suspend fun createConfig(): Config val taskProvider: () -> Task<ProgressType, ResultType> } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/example/QueueingTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/example/QueueingTask.kt index f7af281fa..d34f0e889 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/example/QueueingTask.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/example/QueueingTask.kt @@ -71,7 +71,7 @@ open class QueueingTask @Inject constructor() : Task<DefaultProgress, QueueingTa private val taskByDagger: Provider<QueueingTask> ) : TaskFactory<DefaultProgress, Result> { - override val config: TaskFactory.Config = Config() + override suspend fun createConfig(): TaskFactory.Config = Config() override val taskProvider: () -> Task<DefaultProgress, Result> = { taskByDagger.get() } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt index 7983ab3cd..aba0e9850 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt @@ -12,6 +12,7 @@ import de.rki.coronawarnapp.util.DialogHelper import de.rki.coronawarnapp.util.ExternalActionHelper import de.rki.coronawarnapp.util.di.AutoInject import de.rki.coronawarnapp.util.errors.RecoveryByResetDialogFactory +import de.rki.coronawarnapp.util.network.NetworkStateProvider import de.rki.coronawarnapp.util.ui.doNavigate import de.rki.coronawarnapp.util.ui.observe2 import de.rki.coronawarnapp.util.ui.viewBindingLazy @@ -37,6 +38,7 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject { @Inject lateinit var homeMenu: HomeMenu @Inject lateinit var tracingExplanationDialog: TracingExplanationDialog + @Inject lateinit var networkStateProvider: NetworkStateProvider override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -93,7 +95,9 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject { } vm.showLoweredRiskLevelDialog.observe2(this) { - if (it) { showRiskLevelLoweredDialog() } + if (it) { + showRiskLevelLoweredDialog() + } } lifecycleScope.launch { vm.observeTestResultToSchedulePositiveTestResultReminder() } @@ -102,7 +106,6 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject { override fun onResume() { super.onResume() vm.refreshRequiredData() - binding.mainScrollview.sendAccessibilityEvent(AccessibilityEvent.TYPE_ANNOUNCEMENT) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt index 665a94a67..68c427d28 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt @@ -7,6 +7,7 @@ import org.joda.time.DateTimeZone import org.joda.time.Days import org.joda.time.Instant import org.joda.time.LocalDate +import org.joda.time.LocalTime import org.joda.time.chrono.GJChronology import org.joda.time.format.DateTimeFormat import timber.log.Timber @@ -80,5 +81,7 @@ object TimeAndDateExtensions { fun LocalDate.ageInDays(now: LocalDate) = Days.daysBetween(this, now).days - fun Instant.toLocalDate() = this.toDateTime(DateTimeZone.UTC).toLocalDate() + fun Instant.toLocalDate(): LocalDate = this.toDateTime(DateTimeZone.UTC).toLocalDate() + + fun Instant.toLocalTime(): LocalTime = this.toDateTime(DateTimeZone.UTC).toLocalTime() } 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 71c8f4f7a..af2cb89a0 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 @@ -11,7 +11,6 @@ import de.rki.coronawarnapp.bugreporting.BugReporter import de.rki.coronawarnapp.bugreporting.BugReportingModule import de.rki.coronawarnapp.diagnosiskeys.DiagnosisKeysModule import de.rki.coronawarnapp.diagnosiskeys.DownloadDiagnosisKeysTaskModule -import de.rki.coronawarnapp.diagnosiskeys.download.KeyFileDownloader import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository import de.rki.coronawarnapp.environment.EnvironmentModule import de.rki.coronawarnapp.http.HttpModule @@ -80,7 +79,6 @@ interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { val settingsRepository: SettingsRepository val keyCacheRepository: KeyCacheRepository - val keyFileDownloader: KeyFileDownloader val appConfigProvider: AppConfigProvider diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt index 557f98341..701dc3239 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/FlowExtensions.kt @@ -3,6 +3,7 @@ package de.rki.coronawarnapp.util.flow import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onCompletion @@ -17,12 +18,23 @@ import timber.log.Timber * The flow collector will just wait for the first value */ fun <T> Flow<T>.shareLatest( - tag: String, + tag: String? = null, scope: CoroutineScope, - started: SharingStarted = SharingStarted.WhileSubscribed() -) = onStart { Timber.v("$tag FLOW start") } - .onEach { Timber.v("$tag FLOW emission: %s", it) } - .onCompletion { Timber.v("$tag FLOW completed.") } + started: SharingStarted = SharingStarted.WhileSubscribed(replayExpirationMillis = 0) +) = this + .onStart { + if (tag != null) Timber.tag(tag).v("shareLatest(...) start") + } + .onEach { + if (tag != null) Timber.tag(tag).v("shareLatest(...) emission: %s", it) + } + .onCompletion { + if (tag != null) Timber.tag(tag).v("shareLatest(...) completed.") + } + .catch { + if (tag != null) Timber.tag(tag).w(it, "shareLatest(...) catch()!.") + throw it + } .stateIn( scope = scope, started = started, diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/BindableVH.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/BindableVH.kt new file mode 100644 index 000000000..e8db4b808 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/BindableVH.kt @@ -0,0 +1,12 @@ +package de.rki.coronawarnapp.util.lists + +import androidx.viewbinding.ViewBinding + +interface BindableVH<ItemT, ViewBindingT : ViewBinding> { + + val viewBinding: Lazy<ViewBindingT> + + val onBindData: ViewBindingT.(item: ItemT) -> Unit + + fun bind(item: ItemT) = with(viewBinding.value) { onBindData(item) } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/HasStableId.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/HasStableId.kt new file mode 100644 index 000000000..096cdebe1 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/HasStableId.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.util.lists + +interface HasStableId { + val stableId: Long +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/HasPayloadDiffer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/HasPayloadDiffer.kt new file mode 100644 index 000000000..cafc44da3 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/HasPayloadDiffer.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.util.lists.diffutil + +interface HasPayloadDiffer { + fun diffPayload(old: Any, new: Any): Any? +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/SmartDiffUtil.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/SmartDiffUtil.kt new file mode 100644 index 000000000..52f30d628 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/lists/diffutil/SmartDiffUtil.kt @@ -0,0 +1,58 @@ +package de.rki.coronawarnapp.util.lists.diffutil + +import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.RecyclerView +import de.rki.coronawarnapp.util.lists.HasStableId + +interface AsyncDiffUtilAdapter<T : HasStableId> { + + val data: List<T> + get() = asyncDiffer.currentList + + val asyncDiffer: AsyncDiffer<T> +} + +fun <X, T> X.update( + newData: List<T>?, + notify: Boolean = true +) where X : AsyncDiffUtilAdapter<T>, X : RecyclerView.Adapter<*> { + + if (notify) asyncDiffer.submitUpdate(newData ?: emptyList()) +} + +class AsyncDiffer<T : HasStableId>( + adapter: RecyclerView.Adapter<*>, + compareItem: (T, T) -> Boolean = { i1, i2 -> i1.stableId == i2.stableId }, + compareItemContent: (T, T) -> Boolean = { i1, i2 -> i1 == i2 }, + determinePayload: (T, T) -> Any? = { i1, i2 -> + when { + i1 is HasPayloadDiffer && i1::class.java.isInstance(i2) -> i1.diffPayload(i1, i2) + else -> null + } + } +) { + private val callback = object : DiffUtil.ItemCallback<T>() { + override fun areItemsTheSame(oldItem: T, newItem: T): Boolean = compareItem(oldItem, newItem) + override fun areContentsTheSame(oldItem: T, newItem: T): Boolean = compareItemContent(oldItem, newItem) + override fun getChangePayload(oldItem: T, newItem: T): Any? = determinePayload(oldItem, newItem) + } + + private val listDiffer = AsyncListDiffer(adapter, callback) + private val internalList = mutableListOf<T>() + val currentList: List<T> + get() = synchronized(internalList) { internalList } + + init { + adapter.setHasStableIds(true) + } + + fun submitUpdate(newData: List<T>) { + listDiffer.submitList(newData) { + synchronized(internalList) { + internalList.clear() + internalList.addAll(newData) + } + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkRequestBuilderProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkRequestBuilderProvider.kt new file mode 100644 index 000000000..9202e1bd6 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkRequestBuilderProvider.kt @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.util.network + +import android.net.NetworkRequest +import javax.inject.Inject +import javax.inject.Provider + +class NetworkRequestBuilderProvider @Inject constructor() : Provider<NetworkRequest.Builder> { + override fun get(): NetworkRequest.Builder = NetworkRequest.Builder() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkStateProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkStateProvider.kt new file mode 100644 index 000000000..f30ff1b74 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/network/NetworkStateProvider.kt @@ -0,0 +1,96 @@ +package de.rki.coronawarnapp.util.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET +import android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED +import de.rki.coronawarnapp.storage.TestSettings +import de.rki.coronawarnapp.util.coroutine.AppScope +import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.flow.shareLatest +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.launch +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class NetworkStateProvider @Inject constructor( + @AppContext private val context: Context, + @AppScope private val appScope: CoroutineScope, + private val testSettings: TestSettings, + private val networkRequestBuilderProvider: NetworkRequestBuilderProvider +) { + private val manager: ConnectivityManager + get() = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + val networkState: Flow<State> = callbackFlow { + send(currentState) + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + Timber.tag(TAG).v("onAvailable(network=%s)", network) + appScope.launch { send(currentState) } + } + + override fun onUnavailable() { + Timber.tag(TAG).v("onUnavailable()") + appScope.launch { send(currentState) } + } + } + + val request = networkRequestBuilderProvider.get() + .addCapability(NET_CAPABILITY_INTERNET) + .build() + manager.registerNetworkCallback(request, callback) + + val fakeConnectionSubscriber = launch { + testSettings.fakeMeteredConnection.flow.drop(1) + .collect { + Timber.v("fakeMeteredConnection=%b", it) + send(currentState) + } + } + + awaitClose { + Timber.tag(TAG).v("unregisterNetworkCallback()") + manager.unregisterNetworkCallback(callback) + fakeConnectionSubscriber.cancel() + } + } + .shareLatest( + tag = TAG, + scope = appScope + ) + + private val currentState: State + get() = manager.activeNetwork.let { network -> + State( + activeNetwork = network, + capabilities = network?.let { manager.getNetworkCapabilities(it) }, + linkProperties = network?.let { manager.getLinkProperties(it) }, + isFakeMeteredConnection = testSettings.fakeMeteredConnection.value + ) + } + + data class State( + val activeNetwork: Network?, + val capabilities: NetworkCapabilities?, + val linkProperties: LinkProperties?, + private val isFakeMeteredConnection: Boolean = false + ) { + val isMeteredConnection: Boolean + get() = isFakeMeteredConnection || !(capabilities?.hasCapability(NET_CAPABILITY_NOT_METERED) ?: false) + } + + companion object { + private const val TAG = "NetworkStateProvider" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/preferences/FlowPreference.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/preferences/FlowPreference.kt new file mode 100644 index 000000000..c64cbedfd --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/preferences/FlowPreference.kt @@ -0,0 +1,77 @@ +package de.rki.coronawarnapp.util.preferences + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.google.gson.Gson +import de.rki.coronawarnapp.util.serialization.fromJson +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FlowPreference<T> constructor( + private val preferences: SharedPreferences, + private val key: String, + private val reader: SharedPreferences.(key: String) -> T, + private val writer: SharedPreferences.Editor.(key: String, value: T) -> Unit +) { + + private val flowInternal = MutableStateFlow(internalValue) + val flow: Flow<T> = flowInternal + + private var internalValue: T + get() = reader(preferences, key) + set(newValue) { + preferences.edit { + writer(key, newValue) + } + flowInternal.value = internalValue + } + val value: T + get() = internalValue + + fun update(update: (T) -> T) { + internalValue = update(internalValue) + } + + companion object { + inline fun <reified T> gsonReader( + gson: Gson, + defaultValue: T + ): SharedPreferences.(key: String) -> T = { key -> + getString(key, null)?.let { gson.fromJson<T>(it) } ?: defaultValue + } + + inline fun <reified T> gsonWriter( + gson: Gson + ): SharedPreferences.Editor.(key: String, value: T) -> Unit = { key, value -> + putString(key, value?.let { gson.toJson(it) }) + } + + inline fun <reified T> basicReader(defaultValue: T): SharedPreferences.(key: String) -> T = + { key -> + (this.all[key] ?: defaultValue) as T + } + + inline fun <reified T> basicWriter(): SharedPreferences.Editor.(key: String, value: T) -> Unit = + { key, value -> + when (value) { + is Boolean -> putBoolean(key, value) + is String -> putString(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Float -> putFloat(key, value) + null -> remove(key) + else -> throw NotImplementedError() + } + } + } +} + +inline fun <reified T : Any?> SharedPreferences.createFlowPreference( + key: String, + defaultValue: T = null as T +) = FlowPreference( + preferences = this, + key = key, + reader = FlowPreference.basicReader(defaultValue), + writer = FlowPreference.basicWriter() +) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt index d8ce3fd27..d28fe47c6 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt @@ -1,19 +1,62 @@ package de.rki.coronawarnapp.appconfig.mapping +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters import io.kotest.matchers.shouldBe +import org.joda.time.LocalDate +import org.joda.time.LocalTime import org.junit.jupiter.api.Test import testhelpers.BaseTest class DownloadConfigMapperTest : BaseTest() { - private fun createInstance() = DownloadConfigMapper() + private fun createInstance() = KeyDownloadParametersMapper() @Test - fun `simple creation`() { + fun `parse etag missmatch for hours`() { + val builder = KeyDownloadParameters.KeyDownloadParametersAndroid.newBuilder().apply { + KeyDownloadParameters.DayPackageMetadata.newBuilder().apply { + etag = "\"GoodMorningEtag\"" + region = "EUR" + date = "2020-11-09" + }.let { addCachedDayPackagesToUpdateOnETagMismatch(it) } + } + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setAndroidKeyDownloadParameters(builder) .build() + + createInstance().map(rawConfig).apply { + invalidDayETags.first().apply { + etag shouldBe "\"GoodMorningEtag\"" + region shouldBe LocationCode("EUR") + day shouldBe LocalDate.parse("2020-11-09") + } + } + } + + @Test + fun `parse etag missmatch for days`() { + val builder = KeyDownloadParameters.KeyDownloadParametersAndroid.newBuilder().apply { + KeyDownloadParameters.HourPackageMetadata.newBuilder().apply { + etag = "\"GoodMorningEtag\"" + region = "EUR" + date = "2020-11-09" + hour = 8 + }.let { addCachedHourPackagesToUpdateOnETagMismatch(it) } + } + + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setAndroidKeyDownloadParameters(builder) + .build() + createInstance().map(rawConfig).apply { - keyDownloadParameters shouldBe rawConfig.androidKeyDownloadParameters + invalidHourEtags.first().apply { + etag shouldBe "\"GoodMorningEtag\"" + region shouldBe LocationCode("EUR") + day shouldBe LocalDate.parse("2020-11-09") + hour shouldBe LocalTime.parse("08:00") + } } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt index 2552a72dc..7812c23ab 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt @@ -1,7 +1,9 @@ package de.rki.coronawarnapp.appconfig.mapping import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import de.rki.coronawarnapp.server.protocols.internal.ExposureDetectionParameters.ExposureDetectionParametersAndroid import io.kotest.matchers.shouldBe +import org.joda.time.Duration import org.junit.jupiter.api.Test import testhelpers.BaseTest @@ -19,4 +21,60 @@ class ExposureDetectionConfigMapperTest : BaseTest() { exposureDetectionParameters shouldBe rawConfig.androidExposureDetectionParameters } } + + @Test + fun `detection interval 0 defaults to almost infinite delay`() { + val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder() + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setMinRiskScore(1) + .setAndroidExposureDetectionParameters(exposureDetectionParameters) + .build() + createInstance().map(rawConfig).apply { + minTimeBetweenDetections shouldBe Duration.standardDays(99) + maxExposureDetectionsPerUTCDay shouldBe 0 + } + } + + @Test + fun `detection interval is mapped correctly`() { + val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder().apply { + maxExposureDetectionsPerInterval = 3 + } + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setMinRiskScore(1) + .setAndroidExposureDetectionParameters(exposureDetectionParameters) + .build() + createInstance().map(rawConfig).apply { + minTimeBetweenDetections shouldBe Duration.standardHours(24 / 3) + maxExposureDetectionsPerUTCDay shouldBe 3 + } + } + + @Test + fun `detection timeout is mapped correctly`() { + val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder().apply { + overallTimeoutInSeconds = 10 * 60 + } + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setMinRiskScore(1) + .setAndroidExposureDetectionParameters(exposureDetectionParameters) + .build() + createInstance().map(rawConfig).apply { + overallDetectionTimeout shouldBe Duration.standardMinutes(10) + } + } + + @Test + fun `detection timeout can not be 0`() { + val exposureDetectionParameters = ExposureDetectionParametersAndroid.newBuilder().apply { + overallTimeoutInSeconds = 0 + } + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setMinRiskScore(1) + .setAndroidExposureDetectionParameters(exposureDetectionParameters) + .build() + createInstance().map(rawConfig).apply { + overallDetectionTimeout shouldBe Duration.standardMinutes(15) + } + } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt index ae93b2979..a446d6e31 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt @@ -1,7 +1,7 @@ package de.rki.coronawarnapp.deadman import de.rki.coronawarnapp.nearby.ENFClient -import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation +import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection import de.rki.coronawarnapp.util.TimeStamper import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations @@ -21,13 +21,13 @@ class DeadmanNotificationTimeCalculationTest : BaseTest() { @MockK lateinit var timeStamper: TimeStamper @MockK lateinit var enfClient: ENFClient - @MockK lateinit var mockCalculation: Calculation + @MockK lateinit var mockExposureDetection: TrackedExposureDetection @BeforeEach fun setup() { MockKAnnotations.init(this) every { timeStamper.nowUTC } returns Instant.parse("2020-08-01T23:00:00.000Z") - every { enfClient.latestFinishedCalculation() } returns flowOf(mockCalculation) + every { enfClient.lastSuccessfulTrackedExposureDetection() } returns flowOf(mockExposureDetection) } @AfterEach @@ -64,40 +64,40 @@ class DeadmanNotificationTimeCalculationTest : BaseTest() { @Test fun `12 hours delay`() = runBlockingTest { every { timeStamper.nowUTC } returns Instant.parse("2020-08-28T14:00:00.000Z") - every { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z") + every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z") createTimeCalculator().getDelay() shouldBe 720 - verify(exactly = 1) { enfClient.latestFinishedCalculation() } + verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() } } @Test fun `negative delay`() = runBlockingTest { every { timeStamper.nowUTC } returns Instant.parse("2020-08-30T14:00:00.000Z") - every { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z") + every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z") createTimeCalculator().getDelay() shouldBe -2160 - verify(exactly = 1) { enfClient.latestFinishedCalculation() } + verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() } } @Test fun `success in future delay`() = runBlockingTest { every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z") - every { mockCalculation.finishedAt } returns Instant.parse("2020-08-27T15:00:00.000Z") + every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T15:00:00.000Z") createTimeCalculator().getDelay() shouldBe 2220 - verify(exactly = 1) { enfClient.latestFinishedCalculation() } + verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() } } @Test fun `initial delay - no successful calculations yet`() = runBlockingTest { every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z") - every { enfClient.latestFinishedCalculation() } returns flowOf(null) + every { enfClient.lastSuccessfulTrackedExposureDetection() } returns flowOf(null) createTimeCalculator().getDelay() shouldBe 2160 - verify(exactly = 1) { enfClient.latestFinishedCalculation() } + verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncToolTest.kt new file mode 100644 index 000000000..ebb49928f --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncToolTest.kt @@ -0,0 +1,315 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.storage.DeviceStorage +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.Instant +import org.joda.time.LocalDate +import org.joda.time.LocalTime +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File + +class BaseKeyPackageSyncToolTest : BaseIOTest() { + + @MockK lateinit var keyCache: KeyCacheRepository + @MockK lateinit var deviceStorage: DeviceStorage + + private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) + + private val String.loc get() = LocationCode(this) + private val String.day get() = LocalDate.parse(this) + private val String.hour get() = LocalTime.parse(this) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + + coEvery { deviceStorage.requireSpacePrivateStorage(any()) } returns mockk() + coEvery { keyCache.delete(any()) } just Runs + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + class TestSyncTool( + keyCache: KeyCacheRepository, + deviceStorage: DeviceStorage + ) : BaseKeyPackageSyncTool( + keyCache = keyCache, + deviceStorage = deviceStorage, + "tag" + ) { + fun findStaleData(keys: List<CachedKey>, available: List<LocationData>): List<CachedKey> = + keys.findStaleData(available) + } + + fun createInstance() = TestSyncTool( + keyCache = keyCache, + deviceStorage = deviceStorage + ) + + @Test + fun `key invalidation based on ETags`() = runBlockingTest { + val invalidatedDay = mockk<KeyDownloadConfig.InvalidatedKeyFile>().apply { + every { etag } returns "etag-badday" + } + val invalidatedHour = mockk<KeyDownloadConfig.InvalidatedKeyFile>().apply { + every { etag } returns "etag-badhour" + } + + val badDayInfo = mockk<CachedKeyInfo>().apply { + every { etag } returns "etag-badday" + } + val badDay = mockk<CachedKey>().apply { + every { info } returns badDayInfo + } + val goodDay = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { etag } returns "etag-goodday" + } + } + + val badHourInfo = mockk<CachedKeyInfo>().apply { + every { etag } returns "etag-badhour" + } + val badHour = mockk<CachedKey>().apply { + every { info } returns badHourInfo + } + val goodHour = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { etag } returns "etag-goodhour" + } + } + + coEvery { keyCache.getAllCachedKeys() } returns listOf(badDay, goodDay, badHour, goodHour) + + val instance = createInstance() + instance.invalidateCachedKeys(listOf(invalidatedDay, invalidatedHour)) + + coVerify { keyCache.delete(listOf(badDayInfo, badHourInfo)) } + } + + @Test + fun `filtering out stale day data`() { + val staleKey = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_DAY, + location = "EUR".loc, + day = "2020-09-01".day, + hour = null, + createdAt = Instant.EPOCH + ), + path = File("") + ) + + val freshKey = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_DAY, + location = "EUR".loc, + day = "2020-09-02".day, + hour = null, + createdAt = Instant.EPOCH + ), + path = File("") + ) + + val availableCountryDay = LocationDays( + LocationCode("EUR"), + listOf("2020-09-02".day) + ) + + val toFilter = listOf(staleKey, freshKey) + val availableData = listOf(availableCountryDay) + + val instance = createInstance() + instance.findStaleData(toFilter, availableData) shouldBe listOf(staleKey) + } + + @Test + fun `filtering out stale hour data`() { + val staleHour = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_HOUR, + location = "EUR".loc, + day = "2020-09-01".day, + hour = "01".hour, + createdAt = Instant.EPOCH + ), + path = File("") + ) + val freshHour = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_HOUR, + location = "EUR".loc, + day = "2020-09-02".day, + hour = "02".hour, + createdAt = Instant.EPOCH + ), + path = File("") + ) + val availableCountryDay = LocationHours( + LocationCode("EUR"), + mapOf("2020-09-02".day to listOf("02".hour)) + ) + + val toFilter = listOf(freshHour, staleHour) + val availableData = listOf(availableCountryDay) + + val instance = createInstance() + instance.findStaleData(toFilter, availableData) shouldBe listOf(staleHour) + } + + @Test + fun `filtering out stale mixed data`() { + val staleHour = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_HOUR, + location = "EUR".loc, + day = "2020-09-01".day, + hour = "01".hour, + createdAt = Instant.EPOCH + ), + path = File("") + ) + val staleHourReplacedByDay = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_HOUR, + location = "EUR".loc, + day = "2020-09-02".day, + hour = "01".hour, + createdAt = Instant.EPOCH + ), + path = File("") + ) + val freshHour = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_HOUR, + location = "EUR".loc, + day = "2020-09-01".day, + hour = "02".hour, + createdAt = Instant.EPOCH + ), + path = File("") + ) + val availableHour = LocationHours( + LocationCode("EUR"), + mapOf( + "2020-09-01".day to listOf("02".hour), + "2020-09-02".day to listOf("01".hour) + ) + ) + + val staleDay = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_DAY, + location = "EUR".loc, + day = "2020-09-01".day, + hour = null, + createdAt = Instant.EPOCH + ), + path = File("") + ) + val freshDay = CachedKey( + info = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_DAY, + location = "EUR".loc, + day = "2020-09-02".day, + hour = null, + createdAt = Instant.EPOCH + ), + path = File("") + ) + val availableDay = LocationDays( + LocationCode("EUR"), + listOf("2020-09-02".day) + ) + + val toFilter = listOf(freshDay, staleDay, freshHour, staleHour, staleHourReplacedByDay) + val availableData = listOf(availableDay, availableHour) + + val instance = createInstance() + instance.findStaleData(toFilter, availableData) shouldBe listOf(staleDay, staleHour, staleHourReplacedByDay) + } + + @Test + fun `required storage check`() = runBlockingTest { + val instance = createInstance() + val countryDay = mockk<LocationDays>().apply { + every { approximateSizeInBytes } returns 9000L + } + val countryHour = mockk<LocationHours>().apply { + every { approximateSizeInBytes } returns 1337L + } + instance.requireStorageSpace(listOf(countryDay, countryHour)) + + coVerify { deviceStorage.requireSpacePrivateStorage(10337L) } + } + + @Test + fun `getting completed keys`() = runBlockingTest { + val key1 = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { isDownloadComplete } returns false + every { location } returns LocationCode("EUR") + } + every { path } returns mockk<File>().apply { every { exists() } returns true } + } + val key2 = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { isDownloadComplete } returns true + every { location } returns LocationCode("EUR") + } + every { path } returns mockk<File>().apply { every { exists() } returns false } + } + val key3 = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { isDownloadComplete } returns true + every { location } returns LocationCode("EUR") + } + every { path } returns mockk<File>().apply { every { exists() } returns true } + } + val key4 = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { isDownloadComplete } returns true + every { location } returns LocationCode("DE") + } + every { path } returns mockk<File>().apply { every { exists() } returns true } + } + coEvery { keyCache.getEntriesForType(any()) } returns listOf(key1, key2, key3, key4) + + val instance = createInstance() + instance.getDownloadedCachedKeys( + LocationCode("EUR"), + CachedKeyInfo.Type.LOCATION_DAY + ) shouldBe listOf(key3) + coVerify { keyCache.getEntriesForType(CachedKeyInfo.Type.LOCATION_DAY) } + + instance.getDownloadedCachedKeys( + LocationCode("EUR"), + CachedKeyInfo.Type.LOCATION_HOUR + ) shouldBe listOf(key3) + coVerify { keyCache.getEntriesForType(CachedKeyInfo.Type.LOCATION_HOUR) } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CommonSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CommonSyncToolTest.kt new file mode 100644 index 000000000..5cb2f0254 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CommonSyncToolTest.kt @@ -0,0 +1,154 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.ConfigData +import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.storage.DeviceStorage +import de.rki.coronawarnapp.util.TimeStamper +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.joda.time.DateTimeZone +import org.joda.time.Instant +import org.joda.time.LocalDate +import org.joda.time.LocalTime +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import testhelpers.BaseIOTest +import timber.log.Timber +import java.io.File + +abstract class CommonSyncToolTest : BaseIOTest() { + + @MockK lateinit var deviceStorage: DeviceStorage + @MockK lateinit var keyCache: KeyCacheRepository + @MockK lateinit var keyServer: DiagnosisKeyServer + @MockK lateinit var downloadTool: KeyDownloadTool + @MockK lateinit var timeStamper: TimeStamper + @MockK lateinit var configProvider: AppConfigProvider + + @MockK lateinit var downloadConfig: ConfigData + + private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) + + internal val String.loc get() = LocationCode(this) + internal val String.day get() = LocalDate.parse(this) + internal val String.hour get() = LocalTime.parse(this) + val keyRepoData = mutableMapOf<String, CachedKey>() + + @BeforeEach + open fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + + coEvery { configProvider.getAppConfig() } returns downloadConfig + + coEvery { keyCache.getEntriesForType(any()) } answers { + keyRepoData + .filter { it.value.info.type == arg(0) } + .map { it.value } + } + + coEvery { keyServer.getDayIndex(any()) } returns listOf( + "2020-01-01".day, "2020-01-02".day, "2020-01-03".day + ) + coEvery { keyServer.getHourIndex(any(), "2020-01-04".day) } returns listOf( + "00:00".hour, "01:00".hour, "02:00".hour + ) + + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T03:15:00.000Z") + + coEvery { deviceStorage.requireSpacePrivateStorage(any()) } returns mockk() + + coEvery { + keyCache.createCacheEntry(CachedKeyInfo.Type.LOCATION_DAY, any(), any(), null) + } answers { + mockCachedDay(arg(1), arg(2)) + } + coEvery { + keyCache.createCacheEntry(CachedKeyInfo.Type.LOCATION_HOUR, any(), any(), any()) + } answers { + mockCachedHour(arg(1), arg(2), arg(3)) + } + coEvery { keyCache.getAllCachedKeys() } answers { keyRepoData.values.toList() } + coEvery { keyCache.delete(any()) } answers { + val toDelete: List<CachedKeyInfo> = arg(0) + toDelete.forEach { + keyRepoData.remove(it.id) + } + Unit + } + + coEvery { downloadTool.downloadKeyFile(any(), any()) } answers { + arg(0) + } + } + + @AfterEach + open fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + internal fun mockCachedDay( + location: LocationCode, + dayIdentifier: LocalDate, + isComplete: Boolean = true + ): CachedKey = mockCacheEntry( + location, dayIdentifier, null, isComplete + ) + + internal fun mockCachedHour( + location: LocationCode, + dayIdentifier: LocalDate, + hourIdentifier: LocalTime, + isComplete: Boolean = true + ): CachedKey = mockCacheEntry( + location, dayIdentifier, hourIdentifier, isComplete + ) + + private fun mockCacheEntry( + location: LocationCode, + dayIdentifier: LocalDate, + hourIdentifier: LocalTime?, + isComplete: Boolean = true + ): CachedKey { + var keyInfo = CachedKeyInfo( + type = when (hourIdentifier) { + null -> CachedKeyInfo.Type.LOCATION_DAY + else -> CachedKeyInfo.Type.LOCATION_HOUR + }, + location = location, + day = dayIdentifier, + hour = hourIdentifier, + createdAt = when (hourIdentifier) { + null -> dayIdentifier.toLocalDateTime(LocalTime.MIDNIGHT).toDateTime(DateTimeZone.UTC).toInstant() + else -> dayIdentifier.toLocalDateTime(hourIdentifier).toDateTime(DateTimeZone.UTC).toInstant() + } + ) + if (isComplete) { + keyInfo = keyInfo.copy( + etag = when (hourIdentifier) { + null -> "$location-$dayIdentifier" + else -> "$location-$dayIdentifier-$hourIdentifier" + }, + isDownloadComplete = true + ) + } + Timber.i("mockKeyCacheCreateEntry(...): %s", keyInfo) + val file = File(testDir, keyInfo.id) + file.createNewFile() + return CachedKey(keyInfo, file).also { + keyRepoData[it.info.id] = it + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryDataTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryDataTest.kt index 52f54f51b..434e3a7db 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryDataTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/CountryDataTest.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.diagnosiskeys.download import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo import io.kotest.matchers.shouldBe import io.mockk.every @@ -13,11 +14,13 @@ import testhelpers.BaseTest class CountryDataTest : BaseTest() { private val locationCode = LocationCode("DE") - private fun createCachedKey(dayString: String, hourString: String? = null): CachedKeyInfo { - return mockk<CachedKeyInfo>().apply { - every { location } returns locationCode - every { day } returns LocalDate.parse(dayString) - every { hour } returns hourString?.let { LocalTime.parse(it) } + private fun createCachedKey(dayString: String, hourString: String? = null): CachedKey { + return mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { location } returns locationCode + every { day } returns LocalDate.parse(dayString) + every { hour } returns hourString?.let { LocalTime.parse(it) } + } } } @@ -26,7 +29,7 @@ class CountryDataTest : BaseTest() { val availableDates = listOf( "2222-12-30", "2222-12-31" ).map { LocalDate.parse(it) } - val cd = CountryDays(locationCode, availableDates) + val cd = LocationDays(locationCode, availableDates) cd.dayData shouldBe availableDates @@ -43,7 +46,7 @@ class CountryDataTest : BaseTest() { @Test fun `missing days empty day data`() { val availableDates = emptyList<LocalDate>() - val cd = CountryDays(locationCode, availableDates) + val cd = LocationDays(locationCode, availableDates) cd.dayData shouldBe availableDates @@ -61,11 +64,11 @@ class CountryDataTest : BaseTest() { val availableDates = listOf( "2222-11-28", "2222-11-29" ).map { LocalDate.parse(it) } - val cd = CountryDays(locationCode, availableDates) + val cd = LocationDays(locationCode, availableDates) cd.dayData shouldBe availableDates - val cachedDays = emptyList<CachedKeyInfo>() + val cachedDays = emptyList<CachedKey>() cd.getMissingDays(cachedDays) shouldBe availableDates cd.toMissingDays(cachedDays) shouldBe cd @@ -76,7 +79,7 @@ class CountryDataTest : BaseTest() { val availableDates = listOf( "2222-11-28", "2222-11-29" ).map { LocalDate.parse(it) } - val cd = CountryDays(locationCode, availableDates) + val cd = LocationDays(locationCode, availableDates) cd.dayData shouldBe availableDates @@ -94,7 +97,7 @@ class CountryDataTest : BaseTest() { val availableDates = listOf( "2222-12-30", "2222-12-31" ).map { LocalDate.parse(it) } - val cd = CountryDays(locationCode, availableDates) + val cd = LocationDays(locationCode, availableDates) cd.dayData shouldBe availableDates @@ -117,7 +120,7 @@ class CountryDataTest : BaseTest() { LocalTime.parse("22:00"), LocalTime.parse("23:00") ) ) - val cd = CountryHours(locationCode, availableHours) + val cd = LocationHours(locationCode, availableHours) cd.hourData shouldBe availableHours @@ -138,7 +141,7 @@ class CountryDataTest : BaseTest() { @Test fun `missing hours empty available hour data`() { val availableHours: Map<LocalDate, List<LocalTime>> = emptyMap() - val cd = CountryHours(locationCode, availableHours) + val cd = LocationHours(locationCode, availableHours) cd.hourData shouldBe availableHours @@ -156,7 +159,7 @@ class CountryDataTest : BaseTest() { val availableHours = mapOf( LocalDate.parse("2222-12-30") to emptyList<LocalTime>() ) - val cd = CountryHours(locationCode, availableHours) + val cd = LocationHours(locationCode, availableHours) cd.hourData shouldBe availableHours @@ -179,11 +182,11 @@ class CountryDataTest : BaseTest() { LocalTime.parse("22:00"), LocalTime.parse("23:00") ) ) - val cd = CountryHours(locationCode, availableHours) + val cd = LocationHours(locationCode, availableHours) cd.hourData shouldBe availableHours - val cachedHours = emptyList<CachedKeyInfo>() + val cachedHours = emptyList<CachedKey>() cd.getMissingHours(cachedHours) shouldBe availableHours cd.toMissingHours(cachedHours) shouldBe cd.copy(hourData = availableHours) @@ -199,7 +202,7 @@ class CountryDataTest : BaseTest() { LocalTime.parse("22:00"), LocalTime.parse("23:00") ) ) - val cd = CountryHours(locationCode, availableHours) + val cd = LocationHours(locationCode, availableHours) cd.hourData shouldBe availableHours @@ -222,7 +225,7 @@ class CountryDataTest : BaseTest() { LocalTime.parse("22:00"), LocalTime.parse("23:00") ) ) - val cd = CountryHours(locationCode, availableHours) + val cd = LocationHours(locationCode, availableHours) cd.hourData shouldBe availableHours @@ -239,12 +242,12 @@ class CountryDataTest : BaseTest() { @Test fun `calculate approximate required space for day data`() { - CountryDays(LocationCode("DE"), emptyList()).approximateSizeInBytes shouldBe 0 - CountryDays( + LocationDays(LocationCode("DE"), emptyList()).approximateSizeInBytes shouldBe 0 + LocationDays( LocationCode("DE"), listOf(LocalDate.parse("2222-12-30")) ).approximateSizeInBytes shouldBe 512 * 1024L - CountryDays( + LocationDays( LocationCode("DE"), listOf(LocalDate.parse("2222-12-30"), LocalDate.parse("2222-12-31")) ).approximateSizeInBytes shouldBe 2 * 512 * 1024L @@ -252,12 +255,12 @@ class CountryDataTest : BaseTest() { @Test fun `calculate approximate required space for day hour`() { - CountryHours(LocationCode("DE"), emptyMap()).approximateSizeInBytes shouldBe 0 - CountryHours( + LocationHours(LocationCode("DE"), emptyMap()).approximateSizeInBytes shouldBe 0 + LocationHours( LocationCode("DE"), mapOf(LocalDate.parse("2222-12-30") to listOf(LocalTime.parse("23:00"))) ).approximateSizeInBytes shouldBe 22 * 1024L - CountryHours( + LocationHours( LocationCode("DE"), mapOf( LocalDate.parse("2222-12-30") to listOf(LocalTime.parse("23:00")), diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt new file mode 100644 index 000000000..cd31387ef --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt @@ -0,0 +1,183 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.mapping.InvalidatedKeyFile +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerifySequence +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.DateTimeZone +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.TestDispatcherProvider +import java.io.IOException + +class DayPackageSyncToolTest : CommonSyncToolTest() { + + @BeforeEach + override fun setup() { + super.setup() + + every { downloadConfig.invalidDayETags } returns emptyList() + } + + @AfterEach + override fun teardown() { + super.teardown() + } + + fun createInstance() = DayPackageSyncTool( + deviceStorage = deviceStorage, + keyServer = keyServer, + keyCache = keyCache, + downloadTool = downloadTool, + timeStamper = timeStamper, + dispatcherProvider = TestDispatcherProvider, + configProvider = configProvider + ) + + @Test + fun `successful sync`() = runBlockingTest { + // Today is the 4th + mockCachedDay("EUR".loc, "2020-01-01".day) + + val instance = createInstance() + instance.syncMissingDayPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult( + successful = true, + newPackages = keyRepoData.values.filterNot { it.info.day == "2020-01-01".day } + ) + + coVerifySequence { + configProvider.getAppConfig() + keyCache.getEntriesForType(Type.LOCATION_DAY) + timeStamper.nowUTC + keyServer.getDayIndex("EUR".loc) + keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-02".day, null) + downloadTool.downloadKeyFile(any(), downloadConfig) + keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-03".day, null) + downloadTool.downloadKeyFile(any(), downloadConfig) + } + } + + @Test + fun `determine missing days checks EXPECT NEW DAYS`() = runBlockingTest { + mockCachedDay("EUR".loc, "2020-01-01".day) + mockCachedDay("EUR".loc, "2020-01-02".day) + + val instance = createInstance() + + every { timeStamper.nowUTC } returns Instant.parse("2020-01-03T12:12:12.000Z") + instance.determineMissingDayPackages("EUR".loc, false) shouldBe null + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T12:12:12.000Z") + instance.determineMissingDayPackages("EUR".loc, false) shouldBe LocationDays( + location = "EUR".loc, + dayData = listOf("2020-01-03".day) + ) + } + + @Test + fun `determine missing days forcesync ignores EXPECT NEW DAYS`() = runBlockingTest { + mockCachedDay("EUR".loc, "2020-01-01".day) + mockCachedDay("EUR".loc, "2020-01-02".day) + + val instance = createInstance() + + every { timeStamper.nowUTC } returns Instant.parse("2020-01-02T12:12:12.000Z") + instance.determineMissingDayPackages("EUR".loc, true) shouldBe LocationDays( + location = "EUR".loc, + dayData = listOf("2020-01-03".day) + ) + } + + @Test + fun `EXPECT_NEW_DAY_PACKAGES evaluation`() = runBlockingTest { + val cachedKey1 = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { toDateTime() } returns Instant.parse("2020-10-30T01:02:03.000Z").toDateTime(DateTimeZone.UTC) + } + } + val cachedKey2 = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { toDateTime() } returns Instant.parse("2020-10-31T01:02:03.000Z").toDateTime(DateTimeZone.UTC) + } + } + + val instance = createInstance() + + every { timeStamper.nowUTC } returns Instant.parse("2020-11-01T01:02:03.000Z") + instance.expectNewDayPackages(listOf(cachedKey1)) shouldBe true + instance.expectNewDayPackages(listOf(cachedKey1, cachedKey2)) shouldBe false + + every { timeStamper.nowUTC } returns Instant.parse("2020-10-31T01:02:03.000Z") + instance.expectNewDayPackages(listOf(cachedKey1, cachedKey2)) shouldBe true + } + + @Test + fun `download errors do not abort the whole sync`() = runBlockingTest { + var counter = 0 + coEvery { downloadTool.downloadKeyFile(any(), any()) } answers { + if (++counter == 2) throw IOException() + arg(0) + } + + val instance = createInstance() + instance.syncMissingDayPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult( + successful = false, + newPackages = keyRepoData.values.filterNot { it.info.day == "2020-01-02".day } + ) + + coVerifySequence { + configProvider.getAppConfig() + keyCache.getEntriesForType(Type.LOCATION_DAY) + timeStamper.nowUTC + keyServer.getDayIndex("EUR".loc) + keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-01".day, null) + downloadTool.downloadKeyFile(any(), downloadConfig) + keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-02".day, null) + downloadTool.downloadKeyFile(any(), downloadConfig) + keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-03".day, null) + downloadTool.downloadKeyFile(any(), downloadConfig) + } + } + + @Test + fun `app config can invalidate cached days`() = runBlockingTest { + mockCachedDay("EUR".loc, "2020-01-01".day) + mockCachedDay("EUR".loc, "2020-01-02".day) + val invalidDay = mockCachedDay("EUR".loc, "2020-01-03".day) + + every { downloadConfig.invalidDayETags } returns listOf( + InvalidatedKeyFile.Day( + day = invalidDay.info.day, + region = invalidDay.info.location, + etag = invalidDay.info.etag!! + ) + ) + + val instance = createInstance() + instance.syncMissingDayPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult( + successful = true, + newPackages = keyRepoData.values.filter { it.info.day == "2020-01-03".day } + ) + + coVerifySequence { + configProvider.getAppConfig() + + keyCache.getAllCachedKeys() + keyCache.delete(listOf(invalidDay.info)) + + keyCache.getEntriesForType(Type.LOCATION_DAY) + timeStamper.nowUTC + keyServer.getDayIndex("EUR".loc) + + keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-03".day, null) + downloadTool.downloadKeyFile(any(), downloadConfig) + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt new file mode 100644 index 000000000..4aec1691f --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DownloadDiagnosisKeysTaskTest.kt @@ -0,0 +1,39 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.environment.EnvironmentSetup +import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.util.TimeStamper +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import testhelpers.BaseTest + +class DownloadDiagnosisKeysTaskTest : BaseTest() { + + @MockK lateinit var enfClient: ENFClient + @MockK lateinit var environmentSetup: EnvironmentSetup + @MockK lateinit var appConfigProvider: AppConfigProvider + @MockK lateinit var keyPackageSyncTool: KeyPackageSyncTool + @MockK lateinit var timeStamper: TimeStamper + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + fun createInstance() = DownloadDiagnosisKeysTask( + enfClient = enfClient, + environmentSetup = environmentSetup, + appConfigProvider = appConfigProvider, + keyPackageSyncTool = keyPackageSyncTool, + timeStamper = timeStamper + ) +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt new file mode 100644 index 000000000..dbaa90ab0 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt @@ -0,0 +1,205 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.mapping.InvalidatedKeyFile +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type +import io.kotest.matchers.shouldBe +import io.mockk.coEvery +import io.mockk.coVerifySequence +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.DateTimeZone +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.TestDispatcherProvider +import java.io.IOException + +class HourPackageSyncToolTest : CommonSyncToolTest() { + + @BeforeEach + override fun setup() { + super.setup() + + every { downloadConfig.invalidHourEtags } returns emptyList() + } + + @AfterEach + override fun teardown() { + super.teardown() + } + + fun createInstance() = HourPackageSyncTool( + deviceStorage = deviceStorage, + keyServer = keyServer, + keyCache = keyCache, + downloadTool = downloadTool, + timeStamper = timeStamper, + configProvider = configProvider, + dispatcherProvider = TestDispatcherProvider + ) + + @Test + fun `successful sync`() = runBlockingTest { + // Today is the 4th, 02:15:00 + mockCachedDay("EUR".loc, "2020-01-01".day) + mockCachedDay("EUR".loc, "2020-01-02".day) + mockCachedDay("EUR".loc, "2020-01-03".day) + + val staleHour = mockCachedHour("EUR".loc, "2020-01-03".day, "01:00".hour) + mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour) + + val instance = createInstance() + instance.syncMissingHourPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult( + successful = true, + newPackages = keyRepoData.values.filter { it.info.type == Type.LOCATION_HOUR && it.info.hour != "01:00".hour } + ) + + coVerifySequence { + configProvider.getAppConfig() + keyCache.getEntriesForType(Type.LOCATION_HOUR) // Get all cached hours + timeStamper.nowUTC // Timestamp for `expectNewHourPackages` and server index + keyServer.getHourIndex("EUR".loc, "2020-01-04".day) + + keyCache.getEntriesForType(Type.LOCATION_DAY) // Which hours are covered by days already + + keyCache.delete(listOf(staleHour.info)) + + keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "00:00".hour) + downloadTool.downloadKeyFile(any(), downloadConfig) + keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "02:00".hour) + downloadTool.downloadKeyFile(any(), downloadConfig) + } + } + + @Test + fun `app config can invalidate cached hours`() = runBlockingTest { + // Today is the 4th, 02:15:00 + mockCachedDay("EUR".loc, "2020-01-01".day) + mockCachedDay("EUR".loc, "2020-01-02".day) + mockCachedDay("EUR".loc, "2020-01-03".day) + + val invalidHour = mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour) + mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour) + + every { downloadConfig.invalidHourEtags } returns listOf( + InvalidatedKeyFile.Hour( + day = invalidHour.info.day, + hour = invalidHour.info.hour!!, + region = invalidHour.info.location, + etag = invalidHour.info.etag!! + ) + ) + + val instance = createInstance() + instance.syncMissingHourPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult( + successful = true, + newPackages = keyRepoData.values.filter { it.info.type == Type.LOCATION_HOUR && it.info.hour != "01:00".hour } + ) + + coVerifySequence { + configProvider.getAppConfig() + + keyCache.getAllCachedKeys() + keyCache.delete(listOf(invalidHour.info)) + + keyCache.getEntriesForType(Type.LOCATION_HOUR) // Get all cached hours + timeStamper.nowUTC // Timestamp for `expectNewHourPackages` and server index + keyServer.getHourIndex("EUR".loc, "2020-01-04".day) + + keyCache.getEntriesForType(Type.LOCATION_DAY) // Which hours are covered by days already + + keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "00:00".hour) + downloadTool.downloadKeyFile(any(), downloadConfig) + keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "02:00".hour) + downloadTool.downloadKeyFile(any(), downloadConfig) + } + } + + @Test + fun `determine missing hours checks EXPECT NEW HOURS`() = runBlockingTest { + mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour) + mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour) + + val instance = createInstance() + + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z") + instance.determineMissingHours("EUR".loc, false) shouldBe null + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T03:00:00.000Z") + instance.determineMissingHours("EUR".loc, false) shouldBe LocationHours( + location = "EUR".loc, + hourData = mapOf("2020-01-04".day to listOf("02:00".hour)) + ) + } + + @Test + fun `determine missing hours forcesync ignores EXPECT NEW HOURS`() = runBlockingTest { + mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour) + mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour) + + val instance = createInstance() + + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z") + instance.determineMissingHours("EUR".loc, true) shouldBe LocationHours( + location = "EUR".loc, + hourData = mapOf("2020-01-04".day to listOf("02:00".hour)) + ) + } + + @Test + fun `download errors do not abort the whole sync`() = runBlockingTest { + var counter = 0 + coEvery { downloadTool.downloadKeyFile(any(), any()) } answers { + if (++counter == 2) throw IOException() + arg(0) + } + + val instance = createInstance() + instance.syncMissingHourPackages(listOf("EUR".loc), false) shouldBe BaseKeyPackageSyncTool.SyncResult( + successful = false, + newPackages = keyRepoData.values.filter { it.info.type == Type.LOCATION_HOUR && it.info.hour != "01:00".hour } + ) + + coVerifySequence { + configProvider.getAppConfig() + keyCache.getEntriesForType(Type.LOCATION_HOUR) + timeStamper.nowUTC + keyServer.getHourIndex("EUR".loc, "2020-01-04".day) + + keyCache.getEntriesForType(Type.LOCATION_DAY) + + keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "00:00".hour) + downloadTool.downloadKeyFile(any(), downloadConfig) + keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "01:00".hour) + downloadTool.downloadKeyFile(any(), downloadConfig) + keyCache.createCacheEntry(Type.LOCATION_HOUR, "EUR".loc, "2020-01-04".day, "02:00".hour) + downloadTool.downloadKeyFile(any(), downloadConfig) + } + } + + @Test + fun `EXPECT_NEW_HOUR_PACKAGES evaluation`() = runBlockingTest { + val cachedKey1 = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { toDateTime() } returns Instant.parse("2020-01-01T00:00:03.000Z").toDateTime(DateTimeZone.UTC) + } + } + val cachedKey2 = mockk<CachedKey>().apply { + every { info } returns mockk<CachedKeyInfo>().apply { + every { toDateTime() } returns Instant.parse("2020-01-01T01:00:03.000Z").toDateTime(DateTimeZone.UTC) + } + } + + val instance = createInstance() + + var now = Instant.parse("2020-01-01T02:00:03.000Z") + instance.expectNewHourPackages(listOf(cachedKey1), now) shouldBe true + instance.expectNewHourPackages(listOf(cachedKey1, cachedKey2), now) shouldBe false + + now = Instant.parse("2020-01-01T03:00:03.000Z") + instance.expectNewHourPackages(listOf(cachedKey1, cachedKey2), now) shouldBe true + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadToolTest.kt new file mode 100644 index 000000000..a5d3b1868 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyDownloadToolTest.kt @@ -0,0 +1,142 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer +import de.rki.coronawarnapp.diagnosiskeys.server.DownloadInfo +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.runBlockingTest +import okhttp3.Headers +import org.joda.time.Duration +import org.joda.time.Instant +import org.joda.time.LocalDate +import org.joda.time.LocalTime +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File +import java.io.IOException + +class KeyDownloadToolTest : BaseIOTest() { + + private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) + private val testFile = File(testDir, "testfile") + + @MockK private lateinit var legacyKeyCache: LegacyKeyCacheMigration + @MockK private lateinit var keyServer: DiagnosisKeyServer + @MockK private lateinit var keyCache: KeyCacheRepository + @MockK private lateinit var downloadConfig: KeyDownloadConfig + @MockK private lateinit var cachedKey: CachedKey + + private val cachedKeyInfo = CachedKeyInfo( + type = CachedKeyInfo.Type.LOCATION_DAY, + location = LocationCode("EUR"), + day = LocalDate.parse("2000-01-01"), + hour = LocalTime.parse("20:00"), + createdAt = Instant.EPOCH + ) + private val downloadInfo = DownloadInfo( + headers = Headers.headersOf("ETag", "I'm an ETag :).") + ) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + + coEvery { keyServer.downloadKeyFile(any(), any(), any(), any(), any()) } returns downloadInfo + + every { cachedKey.path } returns testFile + every { cachedKey.info } returns cachedKeyInfo + + every { downloadConfig.individualDownloadTimeout } returns Duration.millis(9000L) + + coEvery { keyCache.markKeyComplete(any(), any()) } just Runs + coEvery { keyCache.delete(any()) } just Runs + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createInstance() = KeyDownloadTool( + legacyKeyCache = legacyKeyCache, + keyServer = keyServer, + keyCache = keyCache + ) + + @Test + fun `etag from header is stored`() = runBlockingTest { + val instance = createInstance() + + instance.downloadKeyFile(cachedKey, downloadConfig) + + coVerify { keyCache.markKeyComplete(cachedKeyInfo, "I'm an ETag :).") } + } + + @Test + fun `if the etag is missing we throw an exception`() = runBlockingTest { + coEvery { keyServer.downloadKeyFile(any(), any(), any(), any(), any()) } returns DownloadInfo( + headers = Headers.headersOf() + ) + + testFile.writeText("Good Morning") + + val instance = createInstance() + + shouldThrow<IllegalArgumentException> { + instance.downloadKeyFile(cachedKey, downloadConfig) + } + } + + @Test + fun `invididual downloads timeout based on appconfig`() = runBlockingTest { + coEvery { keyServer.downloadKeyFile(any(), any(), any(), any(), any()) } coAnswers { + delay(10 * 1000) + mockk() + } + + val instance = createInstance() + + advanceUntilIdle() + + shouldThrow<TimeoutCancellationException> { + instance.downloadKeyFile(cachedKey, downloadConfig) + } + } + + @Test + fun `failed downloads are deleted`() = runBlockingTest { + coEvery { keyServer.downloadKeyFile(any(), any(), any(), any(), any()) } throws IOException() + + val instance = createInstance() + + advanceUntilIdle() + + shouldThrow<IOException> { + instance.downloadKeyFile(cachedKey, downloadConfig) + } + + coVerify { keyCache.delete(listOf(cachedKeyInfo)) } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloaderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloaderTest.kt deleted file mode 100644 index b105a1b36..000000000 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyFileDownloaderTest.kt +++ /dev/null @@ -1,757 +0,0 @@ -package de.rki.coronawarnapp.diagnosiskeys.download - -import android.database.SQLException -import de.rki.coronawarnapp.diagnosiskeys.server.DiagnosisKeyServer -import de.rki.coronawarnapp.diagnosiskeys.server.DownloadInfo -import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode -import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo -import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo.Type -import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository -import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration -import de.rki.coronawarnapp.storage.DeviceStorage -import de.rki.coronawarnapp.storage.InsufficientStorageException -import de.rki.coronawarnapp.storage.TestSettings -import io.kotest.assertions.throwables.shouldThrow -import io.kotest.matchers.shouldBe -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.ExperimentalCoroutinesApi -import kotlinx.coroutines.runBlocking -import org.joda.time.Instant -import org.joda.time.LocalDate -import org.joda.time.LocalTime -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import testhelpers.BaseIOTest -import testhelpers.TestDispatcherProvider -import timber.log.Timber -import java.io.File -import java.io.IOException -import kotlin.time.ExperimentalTime - -/** - * CachedKeyFileHolder test. - */ -@ExperimentalTime -@ExperimentalCoroutinesApi -class KeyFileDownloaderTest : BaseIOTest() { - - @MockK - private lateinit var keyCache: KeyCacheRepository - - @MockK - private lateinit var legacyMigration: LegacyKeyCacheMigration - - @MockK - private lateinit var server: DiagnosisKeyServer - - @MockK - private lateinit var deviceStorage: DeviceStorage - - @MockK - private lateinit var testSettings: TestSettings - - private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) - private val keyRepoData = mutableMapOf<String, CachedKeyInfo>() - - private val String.loc get() = LocationCode(this) - private val String.day get() = LocalDate.parse(this) - private val String.hour get() = LocalTime.parse(this) - - @BeforeEach - fun setup() { - MockKAnnotations.init(this) - testDir.mkdirs() - testDir.exists() shouldBe true - - every { testSettings.isHourKeyPkgMode } returns false - - coEvery { server.getCountryIndex() } returns listOf("DE".loc, "NL".loc) - coEvery { deviceStorage.requireSpacePrivateStorage(any()) } returns mockk<DeviceStorage.CheckResult>().apply { - every { isSpaceAvailable } returns true - } - - coEvery { server.getDayIndex("DE".loc) } returns listOf( - "2020-09-01".day, "2020-09-02".day - ) - coEvery { - server.getHourIndex("DE".loc, "2020-09-01".day) - } returns (0..23).map { "$it".hour } - coEvery { - server.getHourIndex("DE".loc, "2020-09-02".day) - } returns (0..23).map { "$it".hour } - coEvery { - server.getHourIndex("DE".loc, "2020-09-03".day) - } returns (0..12).map { "$it".hour } - - coEvery { server.getDayIndex("NL".loc) } returns listOf( - "2020-09-01".day, "2020-09-02".day - ) - coEvery { - server.getHourIndex("NL".loc, "2020-09-01".day) - } returns (0..23).map { "$it".hour } - coEvery { - server.getHourIndex("NL".loc, "2020-09-02".day) - } returns (0..23).map { "$it".hour } - coEvery { - server.getHourIndex("NL".loc, "2020-09-03".day) - } returns (0..12).map { "$it".hour } - - coEvery { server.downloadKeyFile(any(), any(), any(), any(), any()) } answers { - mockDownloadServerDownload( - locationCode = arg(0), - day = arg(1), - hour = arg(2), - saveTo = arg(3) - ) - } - - coEvery { keyCache.createCacheEntry(any(), any(), any(), any()) } answers { - mockKeyCacheCreateEntry(arg(0), arg(1), arg(2), arg(3)) - } - coEvery { keyCache.markKeyComplete(any(), any()) } answers { - mockKeyCacheUpdateComplete(arg(0), arg(1)) - } - coEvery { keyCache.getEntriesForType(any()) } answers { - val type = arg<Type>(0) - keyRepoData.values.filter { it.type == type }.map { it to File(testDir, it.id) } - } - coEvery { keyCache.getAllCachedKeys() } answers { - keyRepoData.values.map { - it to File(testDir, it.id) - } - } - coEvery { keyCache.delete(any()) } answers { - val keyInfos = arg<List<CachedKeyInfo>>(0) - keyInfos.forEach { - keyRepoData.remove(it.id) - } - } - } - - @AfterEach - fun teardown() { - clearAllMocks() - keyRepoData.clear() - testDir.deleteRecursively() - } - - private fun mockKeyCacheCreateEntry( - type: Type, - location: LocationCode, - dayIdentifier: LocalDate, - hourIdentifier: LocalTime? - ): Pair<CachedKeyInfo, File> { - val keyInfo = CachedKeyInfo( - type = type, - location = location, - day = dayIdentifier, - hour = hourIdentifier, - createdAt = Instant.now() - ) - Timber.i("mockKeyCacheCreateEntry(...): %s", keyInfo) - val file = File(testDir, keyInfo.id) - keyRepoData[keyInfo.id] = keyInfo - return keyInfo to file - } - - private fun mockKeyCacheUpdateComplete( - keyInfo: CachedKeyInfo, - checksum: String - ) { - keyRepoData[keyInfo.id] = keyInfo.copy( - isDownloadComplete = true, - checksumMD5 = checksum - ) - } - - private fun mockDownloadServerDownload( - locationCode: LocationCode, - day: LocalDate, - hour: LocalTime? = null, - saveTo: File, - checksumServerMD5: String? = "serverMD5", - checksumLocalMD5: String? = "localMD5" - ): DownloadInfo { - saveTo.writeText("$locationCode.$day.$hour") - return mockk<DownloadInfo>().apply { - every { serverMD5 } returns checksumServerMD5 - every { localMD5 } returns checksumLocalMD5 - } - } - - private fun mockAddData( - type: Type, - location: LocationCode, - day: LocalDate, - hour: LocalTime?, - isCompleted: Boolean - ): Pair<CachedKeyInfo, File> { - val (keyInfo, file) = mockKeyCacheCreateEntry(type, location, day, hour) - if (isCompleted) { - mockDownloadServerDownload( - locationCode = location, - day = day, - hour = hour, - saveTo = file - ) - mockKeyCacheUpdateComplete(keyInfo, "serverMD5") - } - return keyRepoData[keyInfo.id]!! to file - } - - private fun createDownloader(): KeyFileDownloader { - val downloader = KeyFileDownloader( - deviceStorage = deviceStorage, - keyServer = server, - keyCache = keyCache, - legacyKeyCache = legacyMigration, - testSettings = testSettings, - dispatcherProvider = TestDispatcherProvider - ) - Timber.i("createDownloader(): %s", downloader) - return downloader - } - - @Test - fun `wanted country list is empty, day mode`() { - val downloader = createDownloader() - runBlocking { - downloader.asyncFetchKeyFiles(emptyList()) shouldBe emptyList() - } - } - - @Test - fun `wanted country list is empty, hour mode`() { - every { testSettings.isHourKeyPkgMode } returns true - - val downloader = createDownloader() - runBlocking { - downloader.asyncFetchKeyFiles(emptyList()) shouldBe emptyList() - } - } - - @Test - fun `fetching is aborted in day if not enough free storage`() { - coEvery { deviceStorage.requireSpacePrivateStorage(1048576L) } throws InsufficientStorageException( - mockk(relaxed = true) - ) - - val downloader = createDownloader() - - runBlocking { - shouldThrow<InsufficientStorageException> { - downloader.asyncFetchKeyFiles(listOf("DE".loc)) - } - } - } - - @Test - fun `fetching is aborted in hour if not enough free storage`() { - every { testSettings.isHourKeyPkgMode } returns true - - coEvery { deviceStorage.requireSpacePrivateStorage(540672L) } throws InsufficientStorageException( - mockk(relaxed = true) - ) - - val downloader = createDownloader() - - runBlocking { - shouldThrow<InsufficientStorageException> { - downloader.asyncFetchKeyFiles(listOf("DE".loc)) - } - } - } - - @Test - fun `error during country index fetch`() { - coEvery { server.getCountryIndex() } throws IOException() - - val downloader = createDownloader() - - runBlocking { - shouldThrow<IOException> { - downloader.asyncFetchKeyFiles(listOf("DE".loc)) - } - } - } - - @Test - fun `day fetch without prior data`() { - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 4 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "DE".loc, - dayIdentifier = "2020-09-01".day, - hourIdentifier = null - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "DE".loc, - dayIdentifier = "2020-09-02".day, - hourIdentifier = null - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "NL".loc, - dayIdentifier = "2020-09-01".day, - hourIdentifier = null - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "NL".loc, - dayIdentifier = "2020-09-02".day, - hourIdentifier = null - ) - } - keyRepoData.size shouldBe 4 - keyRepoData.values.forEach { it.isDownloadComplete shouldBe true } - coVerify { deviceStorage.requireSpacePrivateStorage(2097152L) } - } - - @Test - fun `day fetch with existing data`() { - mockAddData( - type = Type.COUNTRY_DAY, - location = "DE".loc, - day = "2020-09-01".day, - hour = null, - isCompleted = true - ) - mockAddData( - type = Type.COUNTRY_DAY, - location = "NL".loc, - day = "2020-09-02".day, - hour = null, - isCompleted = true - ) - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 4 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "DE".loc, - dayIdentifier = "2020-09-02".day, - hourIdentifier = null - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "NL".loc, - dayIdentifier = "2020-09-01".day, - hourIdentifier = null - ) - } - - coVerify(exactly = 2) { keyCache.createCacheEntry(any(), any(), any(), any()) } - coVerify(exactly = 2) { keyCache.markKeyComplete(any(), any()) } - - coVerify { deviceStorage.requireSpacePrivateStorage(1048576L) } - } - - @Test - fun `day fetch deletes stale data`() { - coEvery { server.getDayIndex("DE".loc) } returns listOf("2020-09-02".day) - val (staleKeyInfo, _) = mockAddData( - type = Type.COUNTRY_DAY, - location = "DE".loc, - day = "2020-09-01".day, - hour = null, - isCompleted = true - ) - - mockAddData( - type = Type.COUNTRY_DAY, - location = "NL".loc, - day = "2020-09-02".day, - hour = null, - isCompleted = true - ) - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 3 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "DE".loc, - dayIdentifier = "2020-09-02".day, - hourIdentifier = null - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "NL".loc, - dayIdentifier = "2020-09-01".day, - hourIdentifier = null - ) - } - coVerify(exactly = 1) { keyCache.delete(listOf(staleKeyInfo)) } - coVerify(exactly = 2) { keyCache.createCacheEntry(any(), any(), any(), any()) } - coVerify(exactly = 2) { keyCache.markKeyComplete(any(), any()) } - } - - @Test - fun `day fetch skips single download failures`() { - var dlCounter = 0 - coEvery { server.downloadKeyFile(any(), any(), any(), any(), any()) } answers { - dlCounter++ - if (dlCounter == 2) throw IOException("Timeout") - mockDownloadServerDownload( - locationCode = arg(0), - day = arg(1), - hour = arg(2), - saveTo = arg(3) - ) - } - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 3 - } - - // We delete the entry for the failed download - coVerify(exactly = 1) { keyCache.delete(any()) } - } - - @Test - fun `last3Hours fetch without prior data`() { - every { testSettings.isHourKeyPkgMode } returns true - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 48 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "12".hour - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "11".hour - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "10".hour - ) - - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "12".hour - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "11".hour - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "10".hour - ) - } - coVerify(exactly = 48) { keyCache.markKeyComplete(any(), any()) } - - keyRepoData.size shouldBe 48 - keyRepoData.values.forEach { it.isDownloadComplete shouldBe true } - - coVerify { deviceStorage.requireSpacePrivateStorage(1081344L) } - } - - @Test - fun `last3Hours fetch with prior data`() { - every { testSettings.isHourKeyPkgMode } returns true - - mockAddData( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - day = "2020-09-03".day, - hour = "11".hour, - isCompleted = true - ) - mockAddData( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - day = "2020-09-03".day, - hour = "11".hour, - isCompleted = true - ) - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 48 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "12".hour - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - dayIdentifier = "2020-09-02".day, - hourIdentifier = "13".hour - ) - - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "12".hour - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - dayIdentifier = "2020-09-02".day, - hourIdentifier = "13".hour - ) - } - coVerify(exactly = 46) { - server.downloadKeyFile(any(), any(), any(), any(), any()) - } - coVerify { deviceStorage.requireSpacePrivateStorage(1036288L) } - } - - @Test - fun `last3Hours fetch deletes stale data`() { - every { testSettings.isHourKeyPkgMode } returns true - - val (staleKey1, _) = mockAddData( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - day = "2020-09-02".day, - hour = "01".hour, // Stale hour - isCompleted = true - ) - - val (staleKey2, _) = mockAddData( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - day = "2020-09-02".day, // Stale day - hour = "01".hour, - isCompleted = true - ) - - mockAddData( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - day = "2020-09-03".day, - hour = "10".hour, - isCompleted = true - ) - mockAddData( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - day = "2020-09-03".day, - hour = "10".hour, - isCompleted = true - ) - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 48 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "12".hour - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "DE".loc, - dayIdentifier = "2020-09-02".day, - hourIdentifier = "13".hour - ) - - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - dayIdentifier = "2020-09-03".day, - hourIdentifier = "12".hour - ) - keyCache.createCacheEntry( - type = Type.COUNTRY_HOUR, - location = "NL".loc, - dayIdentifier = "2020-09-02".day, - hourIdentifier = "13".hour - ) - } - coVerify(exactly = 46) { - server.downloadKeyFile(any(), any(), any(), any(), any()) - } - coVerify(exactly = 1) { keyCache.delete(listOf(staleKey1, staleKey2)) } - } - - @Test - fun `last3Hours fetch skips single download failures`() { - every { testSettings.isHourKeyPkgMode } returns true - - var dlCounter = 0 - coEvery { server.downloadKeyFile(any(), any(), any(), any(), any()) } answers { - dlCounter++ - if (dlCounter % 3 == 0) throw IOException("Timeout") - mockDownloadServerDownload( - locationCode = arg(0), - day = arg(1), - hour = arg(2), - saveTo = arg(3) - ) - } - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 32 - } - - // We delete the entry for the failed download - coVerify(exactly = 16) { keyCache.delete(any()) } - } - - @Test - fun `not completed cache entries are overwritten`() { - mockAddData( - type = Type.COUNTRY_DAY, - location = "DE".loc, - day = "2020-09-01".day, - hour = null, - isCompleted = false - ) - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 4 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "DE".loc, - dayIdentifier = "2020-09-01".day, - hourIdentifier = null - ) - } - } - - @Test - fun `database errors do not abort the whole process`() { - var completionCounter = 0 - coEvery { keyCache.markKeyComplete(any(), any()) } answers { - completionCounter++ - if (completionCounter == 2) throw SQLException(":)") - mockKeyCacheUpdateComplete(arg(0), arg(1)) - } - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc, "NL".loc)).size shouldBe 3 - } - - coVerify(exactly = 4) { - server.downloadKeyFile(any(), any(), any(), any(), any()) - } - } - - @Test - fun `store server md5`() { - coEvery { server.getCountryIndex() } returns listOf("DE".loc) - coEvery { server.getDayIndex("DE".loc) } returns listOf("2020-09-01".day) - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc)).size shouldBe 1 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "DE".loc, - dayIdentifier = "2020-09-01".day, - hourIdentifier = null - ) - } - keyRepoData.size shouldBe 1 - keyRepoData.values.forEach { - it.isDownloadComplete shouldBe true - it.checksumMD5 shouldBe "serverMD5" - } - } - - @Test - fun `use local MD5 as fallback if there is none available from the server`() { - coEvery { server.getCountryIndex() } returns listOf("DE".loc) - coEvery { server.getDayIndex("DE".loc) } returns listOf("2020-09-01".day) - coEvery { server.downloadKeyFile(any(), any(), any(), any(), any()) } answers { - mockDownloadServerDownload( - locationCode = arg(0), - day = arg(1), - hour = arg(2), - saveTo = arg(3), - checksumServerMD5 = null - ) - } - - val downloader = createDownloader() - - runBlocking { - downloader.asyncFetchKeyFiles(listOf("DE".loc)).size shouldBe 1 - } - - coVerify { - keyCache.createCacheEntry( - type = Type.COUNTRY_DAY, - location = "DE".loc, - dayIdentifier = "2020-09-01".day, - hourIdentifier = null - ) - } - keyRepoData.size shouldBe 1 - keyRepoData.values.forEach { - it.isDownloadComplete shouldBe true - it.checksumMD5 shouldBe "localMD5" - } - } -} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncToolTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncToolTest.kt new file mode 100644 index 000000000..457982822 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/KeyPackageSyncToolTest.kt @@ -0,0 +1,316 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKey +import de.rki.coronawarnapp.diagnosiskeys.storage.CachedKeyInfo +import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.network.NetworkStateProvider +import de.rki.coronawarnapp.util.preferences.FlowPreference +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.coEvery +import io.mockk.coVerifySequence +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runBlockingTest +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.BaseIOTest +import testhelpers.preferences.mockFlowPreference +import java.io.File + +class KeyPackageSyncToolTest : BaseIOTest() { + + private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) + + @MockK lateinit var keyCache: KeyCacheRepository + @MockK lateinit var dayPackageSyncTool: DayPackageSyncTool + @MockK lateinit var hourPackageSyncTool: HourPackageSyncTool + @MockK lateinit var syncSettings: KeyPackageSyncSettings + @MockK lateinit var timeStamper: TimeStamper + @MockK lateinit var networkStateProvider: NetworkStateProvider + @MockK lateinit var networkState: NetworkStateProvider.State + private val lastDownloadDays: FlowPreference<KeyPackageSyncSettings.LastDownload?> = mockFlowPreference( + KeyPackageSyncSettings.LastDownload( + startedAt = Instant.EPOCH, + finishedAt = Instant.EPOCH, + successful = true + ) + ) + private val lastDownloadHours: FlowPreference<KeyPackageSyncSettings.LastDownload?> = mockFlowPreference( + KeyPackageSyncSettings.LastDownload( + startedAt = Instant.EPOCH, + finishedAt = Instant.EPOCH, + successful = true + ) + ) + + private val cachedDayKey = CachedKey( + info = mockk(), + path = mockk() + ) + private val cachedHourKey = CachedKey( + info = mockk(), + path = mockk() + ) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + + coEvery { keyCache.getAllCachedKeys() } returns listOf() + coEvery { keyCache.delete(any()) } just Runs + coEvery { syncSettings.lastDownloadDays } returns lastDownloadDays + coEvery { syncSettings.lastDownloadHours } returns lastDownloadHours + + coEvery { dayPackageSyncTool.syncMissingDayPackages(any(), any()) } returns BaseKeyPackageSyncTool.SyncResult( + successful = true, + newPackages = listOf(cachedDayKey) + ) + coEvery { hourPackageSyncTool.syncMissingHourPackages(any(), any()) } returns BaseKeyPackageSyncTool.SyncResult( + successful = true, + newPackages = listOf(cachedHourKey) + ) + + every { timeStamper.nowUTC } returns Instant.EPOCH.plus(Duration.standardDays(1)) + + every { networkStateProvider.networkState } returns flowOf(networkState) + every { networkState.isMeteredConnection } returns false + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + fun createInstance(): KeyPackageSyncTool = KeyPackageSyncTool( + keyCache = keyCache, + dayPackageSyncTool = dayPackageSyncTool, + hourPackageSyncTool = hourPackageSyncTool, + syncSettings = syncSettings, + timeStamper = timeStamper, + networkStateProvider = networkStateProvider + ) + + @Test + fun `normal call sequence`() = runBlockingTest { + val instance = createInstance() + + instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result( + availableKeys = emptyList(), + newKeys = listOf(cachedDayKey, cachedHourKey), + wasDaySyncSucccessful = true + ) + + coVerifySequence { + keyCache.getAllCachedKeys() // To clean up stale locations + + lastDownloadDays.value + lastDownloadDays.update(any()) + dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), false) + lastDownloadDays.update(any()) + + networkStateProvider.networkState // Check metered + lastDownloadHours.value + lastDownloadHours.update(any()) + hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), false) + lastDownloadHours.update(any()) + + keyCache.getAllCachedKeys() + } + } + + @Test + fun `failed day sync is reflected in results property`() = runBlockingTest { + coEvery { dayPackageSyncTool.syncMissingDayPackages(any(), any()) } returns BaseKeyPackageSyncTool.SyncResult( + successful = false, + newPackages = listOf(cachedDayKey) + ) + val instance = createInstance() + + instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result( + availableKeys = emptyList(), + newKeys = listOf(cachedDayKey, cachedHourKey), + wasDaySyncSucccessful = false + ) + + coVerifySequence { + keyCache.getAllCachedKeys() // To clean up stale locations + + lastDownloadDays.value + lastDownloadDays.update(any()) + dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), false) + lastDownloadDays.update(any()) + + networkStateProvider.networkState // Check metered + lastDownloadHours.value + lastDownloadHours.update(any()) + hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), false) + lastDownloadHours.update(any()) + + keyCache.getAllCachedKeys() + } + } + + @Test + fun `missing last download causes force sync`() = runBlockingTest { + lastDownloadDays.update { null } + lastDownloadHours.update { null } + + val instance = createInstance() + + instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result( + availableKeys = emptyList(), + newKeys = listOf(cachedDayKey, cachedHourKey), + wasDaySyncSucccessful = true + ) + + coVerifySequence { + // Initial reset + lastDownloadDays.update(any()) + lastDownloadHours.update(any()) + + keyCache.getAllCachedKeys() // To clean up stale locations + + lastDownloadDays.value + lastDownloadDays.update(any()) + dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), true) + lastDownloadDays.update(any()) + + networkStateProvider.networkState // Check metered + lastDownloadHours.value + lastDownloadHours.update(any()) + hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), true) + lastDownloadHours.update(any()) + + keyCache.getAllCachedKeys() + } + } + + @Test + fun `failed last download causes force sync`() = runBlockingTest { + lastDownloadDays.update { + KeyPackageSyncSettings.LastDownload( + startedAt = Instant.EPOCH, + finishedAt = Instant.EPOCH, + successful = false + ) + } + lastDownloadHours.update { + KeyPackageSyncSettings.LastDownload( + startedAt = Instant.EPOCH, + finishedAt = Instant.EPOCH, + successful = false + ) + } + val instance = createInstance() + + instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result( + availableKeys = emptyList(), + newKeys = listOf(cachedDayKey, cachedHourKey), + wasDaySyncSucccessful = true + ) + + coVerifySequence { + // Initial reset + lastDownloadDays.update(any()) + lastDownloadHours.update(any()) + + keyCache.getAllCachedKeys() // To clean up stale locations + + lastDownloadDays.value + lastDownloadDays.update(any()) + dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), true) + lastDownloadDays.update(any()) + + networkStateProvider.networkState // Check metered + lastDownloadHours.value + lastDownloadHours.update(any()) + hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), true) + lastDownloadHours.update(any()) + + keyCache.getAllCachedKeys() + } + } + + @Test + fun `hourly download does not happen on metered connections`() = runBlockingTest { + every { networkState.isMeteredConnection } returns true + val instance = createInstance() + + instance.syncKeyFiles() shouldBe KeyPackageSyncTool.Result( + availableKeys = emptyList(), + newKeys = listOf(cachedDayKey), + wasDaySyncSucccessful = true + ) + + coVerifySequence { + keyCache.getAllCachedKeys() // To clean up stale locations + + lastDownloadDays.value + lastDownloadDays.update(any()) + dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), false) + lastDownloadDays.update(any()) + + networkStateProvider.networkState // Check metered + + keyCache.getAllCachedKeys() + } + } + + @Test + fun `we clean up stale location data`() = runBlockingTest { + val badLocation = CachedKey( + info = mockk<CachedKeyInfo>().apply { + every { location } returns LocationCode("NOT-EUR") + every { isDownloadComplete } returns true + }, + path = mockk<File>().apply { + every { exists() } returns true + } + ) + val goodLocation = CachedKey( + info = mockk<CachedKeyInfo>().apply { + every { location } returns LocationCode("EUR") + every { isDownloadComplete } returns true + }, + path = mockk<File>().apply { + every { exists() } returns true + } + ) + coEvery { keyCache.getAllCachedKeys() } returns listOf(badLocation, goodLocation) + val instance = createInstance() + + instance.syncKeyFiles() + + coVerifySequence { + keyCache.getAllCachedKeys() // To clean up stale locations + keyCache.delete(listOf(badLocation.info)) + + lastDownloadDays.value + lastDownloadDays.update(any()) + dayPackageSyncTool.syncMissingDayPackages(listOf(LocationCode("EUR")), false) + lastDownloadDays.update(any()) + + networkStateProvider.networkState // Check metered + lastDownloadHours.value + lastDownloadHours.update(any()) + hourPackageSyncTool.syncMissingHourPackages(listOf(LocationCode("EUR")), false) + lastDownloadHours.update(any()) + + keyCache.getAllCachedKeys() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt index a8ed796f6..85ab7828a 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyApiTest.kt @@ -56,7 +56,7 @@ class DiagnosisKeyApiTest : BaseIOTest() { webServer.enqueue(MockResponse().setBody("[\"DE\",\"NL\"]")) runBlocking { - api.getCountryIndex() shouldBe listOf("DE", "NL") + api.getLocationIndex() shouldBe listOf("DE", "NL") } val request = webServer.takeRequest(5, TimeUnit.SECONDS)!! diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServerTest.kt index 08aed5e5a..9f2fea42a 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServerTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DiagnosisKeyServerTest.kt @@ -50,15 +50,15 @@ class DiagnosisKeyServerTest : BaseIOTest() { @Test fun `download country index`() { val downloadServer = createDownloadServer() - coEvery { api.getCountryIndex() } returns listOf("DE", "NL") + coEvery { api.getLocationIndex() } returns listOf("DE", "NL") runBlocking { - downloadServer.getCountryIndex() shouldBe listOf( + downloadServer.getLocationIndex() shouldBe listOf( LocationCode("DE"), LocationCode("NL") ) } - coVerify { api.getCountryIndex() } + coVerify { api.getLocationIndex() } } @Test diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfoTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfoTest.kt index cceb7f2d5..d24005087 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfoTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/server/DownloadInfoTest.kt @@ -10,10 +10,8 @@ class DownloadInfoTest : BaseTest() { @Test fun `extract server MD5`() { val info = DownloadInfo( - headers = Headers.headersOf("ETAG", "serverMD5"), - localMD5 = "localMD5" + headers = Headers.headersOf("ETag", "\"etag\"") ) - info.serverMD5 shouldBe "serverMD5" - info.localMD5 shouldBe "localMD5" + info.etag shouldBe "\"etag\"" } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyFileTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyFileTest.kt index cf1d1ebe0..6b399b506 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyFileTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/CachedKeyFileTest.kt @@ -9,7 +9,7 @@ import org.junit.jupiter.api.Test import testhelpers.BaseTest class CachedKeyFileTest : BaseTest() { - private val type = CachedKeyInfo.Type.COUNTRY_DAY + private val type = CachedKeyInfo.Type.LOCATION_DAY private val location = LocationCode("DE") private val day = LocalDate.parse("2222-12-31") private val hour = LocalTime.parse("23:59") @@ -20,7 +20,7 @@ class CachedKeyFileTest : BaseTest() { val key = CachedKeyInfo(type, location, day, hour, now) key.id shouldBe CachedKeyInfo.calcluateId(location, day, hour, type) - key.checksumMD5 shouldBe null + key.etag shouldBe null key.isDownloadComplete shouldBe false } @@ -42,21 +42,13 @@ class CachedKeyFileTest : BaseTest() { downloadCompleteUpdate shouldBe CachedKeyInfo.DownloadUpdate( id = downloadCompleteUpdate.id, isDownloadComplete = true, - checksumMD5 = testChecksum - ) - - val resetDownloadUpdate = key.toDownloadUpdate(null) - - resetDownloadUpdate shouldBe CachedKeyInfo.DownloadUpdate( - id = downloadCompleteUpdate.id, - isDownloadComplete = false, - checksumMD5 = null + etag = testChecksum ) } @Test fun `trip changed typing`() { - CachedKeyInfo.Type.COUNTRY_DAY.typeValue shouldBe "country_day" - CachedKeyInfo.Type.COUNTRY_HOUR.typeValue shouldBe "country_hour" + CachedKeyInfo.Type.LOCATION_DAY.typeValue shouldBe "country_day" + CachedKeyInfo.Type.LOCATION_HOUR.typeValue shouldBe "country_hour" } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepositoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepositoryTest.kt index 95fe51504..2b1b205ee 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepositoryTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/storage/KeyCacheRepositoryTest.kt @@ -10,6 +10,7 @@ import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.joda.time.Instant import org.joda.time.LocalDate @@ -50,7 +51,7 @@ class KeyCacheRepositoryTest : BaseIOTest() { every { databaseFactory.create() } returns database every { database.cachedKeyFiles() } returns keyfileDAO - coEvery { keyfileDAO.getAllEntries() } returns emptyList() + coEvery { keyfileDAO.allEntries() } returns flowOf(emptyList()) } @AfterEach @@ -71,18 +72,18 @@ class KeyCacheRepositoryTest : BaseIOTest() { location = LocationCode("DE"), day = LocalDate.now(), hour = LocalTime.now(), - type = CachedKeyInfo.Type.COUNTRY_HOUR, + type = CachedKeyInfo.Type.LOCATION_HOUR, createdAt = Instant.now() ).copy( isDownloadComplete = true, - checksumMD5 = "checksum" + etag = "checksum" ) val existingKey = CachedKeyInfo( location = LocationCode("NL"), day = LocalDate.now(), hour = LocalTime.now(), - type = CachedKeyInfo.Type.COUNTRY_HOUR, + type = CachedKeyInfo.Type.LOCATION_HOUR, createdAt = Instant.now() ) @@ -91,7 +92,7 @@ class KeyCacheRepositoryTest : BaseIOTest() { createNewFile() } - coEvery { keyfileDAO.getAllEntries() } returns listOf(lostKey, existingKey) + coEvery { keyfileDAO.allEntries() } returns flowOf(listOf(lostKey, existingKey)) coEvery { keyfileDAO.updateDownloadState(any()) } returns Unit coEvery { keyfileDAO.deleteEntry(lostKey) } returns Unit @@ -101,7 +102,7 @@ class KeyCacheRepositoryTest : BaseIOTest() { runBlocking { repo.getAllCachedKeys() - coVerify(exactly = 2) { keyfileDAO.getAllEntries() } + coVerify(exactly = 2) { keyfileDAO.allEntries() } coVerify { keyfileDAO.deleteEntry(lostKey) } } } @@ -117,7 +118,7 @@ class KeyCacheRepositoryTest : BaseIOTest() { location = LocationCode("NL"), dayIdentifier = LocalDate.parse("2020-09-09"), hourIdentifier = LocalTime.parse("23:00"), - type = CachedKeyInfo.Type.COUNTRY_HOUR + type = CachedKeyInfo.Type.LOCATION_HOUR ) path shouldBe File(context.cacheDir, "diagnosis_keys/${keyFile.id}.zip") @@ -138,7 +139,7 @@ class KeyCacheRepositoryTest : BaseIOTest() { location = LocationCode("NL"), dayIdentifier = LocalDate.parse("2020-09-09"), hourIdentifier = LocalTime.parse("23:00"), - type = CachedKeyInfo.Type.COUNTRY_HOUR + type = CachedKeyInfo.Type.LOCATION_HOUR ) repo.markKeyComplete(keyFile, "checksum") @@ -162,7 +163,7 @@ class KeyCacheRepositoryTest : BaseIOTest() { location = LocationCode("NL"), dayIdentifier = LocalDate.parse("2020-09-09"), hourIdentifier = LocalTime.parse("23:00"), - type = CachedKeyInfo.Type.COUNTRY_HOUR + type = CachedKeyInfo.Type.LOCATION_HOUR ) path.createNewFile() shouldBe true @@ -184,11 +185,11 @@ class KeyCacheRepositoryTest : BaseIOTest() { location = LocationCode("DE"), day = LocalDate.now(), hour = LocalTime.now(), - type = CachedKeyInfo.Type.COUNTRY_HOUR, + type = CachedKeyInfo.Type.LOCATION_HOUR, createdAt = Instant.now() ) - coEvery { keyfileDAO.getAllEntries() } returns listOf(keyFileToClear) + coEvery { keyfileDAO.allEntries() } returns flowOf(listOf(keyFileToClear)) coEvery { keyfileDAO.deleteEntry(any()) } returns Unit val keyFilePath = repo.getPathForKey(keyFileToClear) 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 index 541eedf62..4880b66aa 100644 --- 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 @@ -2,8 +2,8 @@ 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.calculationtracker.Calculation -import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection import de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider.DiagnosisKeyProvider import de.rki.coronawarnapp.nearby.modules.locationless.ScanningSupport import de.rki.coronawarnapp.nearby.modules.tracing.TracingStatus @@ -37,13 +37,13 @@ class ENFClientTest : BaseTest() { @MockK lateinit var diagnosisKeyProvider: DiagnosisKeyProvider @MockK lateinit var tracingStatus: TracingStatus @MockK lateinit var scanningSupport: ScanningSupport - @MockK lateinit var calculationTracker: CalculationTracker + @MockK lateinit var exposureDetectionTracker: ExposureDetectionTracker @BeforeEach fun setup() { MockKAnnotations.init(this) coEvery { diagnosisKeyProvider.provideDiagnosisKeys(any(), any(), any()) } returns true - every { calculationTracker.trackNewCalaculation(any()) } just Runs + every { exposureDetectionTracker.trackNewExposureDetection(any()) } just Runs } @AfterEach @@ -56,7 +56,7 @@ class ENFClientTest : BaseTest() { diagnosisKeyProvider = diagnosisKeyProvider, tracingStatus = tracingStatus, scanningSupport = scanningSupport, - calculationTracker = calculationTracker + exposureDetectionTracker = exposureDetectionTracker ) @Test @@ -135,14 +135,14 @@ class ENFClientTest : BaseTest() { @Test fun `calculation state depends on the last started calculation`() { runBlocking { - every { calculationTracker.calculations } returns flowOf( + every { exposureDetectionTracker.calculations } returns flowOf( mapOf( - "1" to Calculation( + "1" to TrackedExposureDetection( identifier = "1", startedAt = Instant.EPOCH, finishedAt = Instant.EPOCH ), - "2" to Calculation( + "2" to TrackedExposureDetection( identifier = "2", startedAt = Instant.EPOCH, finishedAt = Instant.EPOCH.plus(1) @@ -150,17 +150,17 @@ class ENFClientTest : BaseTest() { ) ) - createClient().isCurrentlyCalculating().first() shouldBe false + createClient().isPerformingExposureDetection().first() shouldBe false } runBlocking { - every { calculationTracker.calculations } returns flowOf( + every { exposureDetectionTracker.calculations } returns flowOf( mapOf( - "1" to Calculation( + "1" to TrackedExposureDetection( identifier = "1", startedAt = Instant.EPOCH.plus(5) ), - "2" to Calculation( + "2" to TrackedExposureDetection( identifier = "2", startedAt = Instant.EPOCH.plus(4), finishedAt = Instant.EPOCH.plus(1) @@ -168,101 +168,101 @@ class ENFClientTest : BaseTest() { ) ) - createClient().isCurrentlyCalculating().first() shouldBe true + createClient().isPerformingExposureDetection().first() shouldBe true } runBlocking { - every { calculationTracker.calculations } returns flowOf( + every { exposureDetectionTracker.calculations } returns flowOf( mapOf( - "1" to Calculation( + "1" to TrackedExposureDetection( identifier = "1", startedAt = Instant.EPOCH ), - "2" to Calculation( + "2" to TrackedExposureDetection( identifier = "2", startedAt = Instant.EPOCH, finishedAt = Instant.EPOCH.plus(2) ), - "3" to Calculation( + "3" to TrackedExposureDetection( identifier = "3", startedAt = Instant.EPOCH.plus(1) ) ) ) - createClient().isCurrentlyCalculating().first() shouldBe true + createClient().isPerformingExposureDetection().first() shouldBe true } } @Test fun `validate that we only get the last finished calcluation`() { runBlocking { - every { calculationTracker.calculations } returns flowOf( + every { exposureDetectionTracker.calculations } returns flowOf( mapOf( - "1" to Calculation( + "1" to TrackedExposureDetection( identifier = "1", startedAt = Instant.EPOCH, - result = Calculation.Result.UPDATED_STATE, + result = TrackedExposureDetection.Result.UPDATED_STATE, finishedAt = Instant.EPOCH ), - "2" to Calculation( + "2" to TrackedExposureDetection( identifier = "2", startedAt = Instant.EPOCH, - result = Calculation.Result.UPDATED_STATE, + result = TrackedExposureDetection.Result.UPDATED_STATE, finishedAt = Instant.EPOCH.plus(1) ), - "2-timeout" to Calculation( + "2-timeout" to TrackedExposureDetection( identifier = "2-timeout", startedAt = Instant.EPOCH, - result = Calculation.Result.TIMEOUT, + result = TrackedExposureDetection.Result.TIMEOUT, finishedAt = Instant.EPOCH.plus(2) ), - "3" to Calculation( + "3" to TrackedExposureDetection( identifier = "3", - result = Calculation.Result.UPDATED_STATE, + result = TrackedExposureDetection.Result.UPDATED_STATE, startedAt = Instant.EPOCH.plus(2) ) ) ) - createClient().latestFinishedCalculation().first()!!.identifier shouldBe "2" + createClient().lastSuccessfulTrackedExposureDetection().first()!!.identifier shouldBe "2" } runBlocking { - every { calculationTracker.calculations } returns flowOf( + every { exposureDetectionTracker.calculations } returns flowOf( mapOf( - "0" to Calculation( + "0" to TrackedExposureDetection( identifier = "1", - result = Calculation.Result.UPDATED_STATE, + result = TrackedExposureDetection.Result.UPDATED_STATE, startedAt = Instant.EPOCH.plus(3) ), - "1-timeout" to Calculation( + "1-timeout" to TrackedExposureDetection( identifier = "1-timeout", startedAt = Instant.EPOCH, - result = Calculation.Result.TIMEOUT, + result = TrackedExposureDetection.Result.TIMEOUT, finishedAt = Instant.EPOCH.plus(3) ), - "1" to Calculation( + "1" to TrackedExposureDetection( identifier = "1", startedAt = Instant.EPOCH, - result = Calculation.Result.UPDATED_STATE, + result = TrackedExposureDetection.Result.UPDATED_STATE, finishedAt = Instant.EPOCH.plus(2) ), - "2" to Calculation( + "2" to TrackedExposureDetection( identifier = "2", - result = Calculation.Result.UPDATED_STATE, + result = TrackedExposureDetection.Result.UPDATED_STATE, startedAt = Instant.EPOCH ), - "3" to Calculation( + "3" to TrackedExposureDetection( identifier = "3", startedAt = Instant.EPOCH, - result = Calculation.Result.UPDATED_STATE, + result = TrackedExposureDetection.Result.UPDATED_STATE, finishedAt = Instant.EPOCH ) ) ) - createClient().latestFinishedCalculation().first()!!.identifier shouldBe "1" + createClient().lastSuccessfulTrackedExposureDetection().first()!!.identifier shouldBe "1" } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTrackerTest.kt similarity index 77% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTrackerTest.kt index 976ba6a4b..f40ea545e 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/DefaultCalculationTrackerTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/DefaultExposureDetectionTrackerTest.kt @@ -1,5 +1,7 @@ -package de.rki.coronawarnapp.nearby.modules.calculationtracker +package de.rki.coronawarnapp.nearby.modules.detectiontracker +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.ConfigData import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.mutate import io.kotest.matchers.shouldBe @@ -27,10 +29,12 @@ import testhelpers.TestDispatcherProvider import testhelpers.coroutines.runBlockingTest2 import java.util.UUID -class DefaultCalculationTrackerTest : BaseTest() { +class DefaultExposureDetectionTrackerTest : BaseTest() { - @MockK lateinit var storage: CalculationTrackerStorage + @MockK lateinit var storage: ExposureDetectionTrackerStorage @MockK lateinit var timeStamper: TimeStamper + @MockK lateinit var configProvider: AppConfigProvider + @MockK lateinit var appConfigData: ConfigData @BeforeEach fun setup() { @@ -39,6 +43,9 @@ class DefaultCalculationTrackerTest : BaseTest() { every { timeStamper.nowUTC } returns Instant.EPOCH coEvery { storage.load() } returns emptyMap() coEvery { storage.save(any()) } just Runs + + coEvery { configProvider.getAppConfig() } returns appConfigData + every { appConfigData.overallDetectionTimeout } returns Duration.standardMinutes(15) } @AfterEach @@ -46,11 +53,12 @@ class DefaultCalculationTrackerTest : BaseTest() { clearAllMocks() } - private fun createInstance(scope: CoroutineScope) = DefaultCalculationTracker( + private fun createInstance(scope: CoroutineScope) = DefaultExposureDetectionTracker( scope = scope, dispatcherProvider = TestDispatcherProvider, storage = storage, - timeStamper = timeStamper + timeStamper = timeStamper, + appConfigProvider = configProvider ) @Test @@ -62,7 +70,7 @@ class DefaultCalculationTrackerTest : BaseTest() { @Test fun `data is restored from storage`() = runBlockingTest2(ignoreActive = true) { - val calcData = Calculation( + val calcData = TrackedExposureDetection( identifier = UUID.randomUUID().toString(), startedAt = Instant.EPOCH ) @@ -76,7 +84,7 @@ class DefaultCalculationTrackerTest : BaseTest() { fun `tracking a new calculation`() = runBlockingTest2(ignoreActive = true) { createInstance(scope = this).apply { val expectedIdentifier = UUID.randomUUID().toString() - trackNewCalaculation(expectedIdentifier) + trackNewExposureDetection(expectedIdentifier) advanceUntilIdle() @@ -84,7 +92,7 @@ class DefaultCalculationTrackerTest : BaseTest() { calculationData.entries.single().apply { key shouldBe expectedIdentifier - value shouldBe Calculation( + value shouldBe TrackedExposureDetection( identifier = expectedIdentifier, startedAt = Instant.EPOCH ) @@ -98,11 +106,13 @@ class DefaultCalculationTrackerTest : BaseTest() { } advanceUntilIdle() } + + coVerify { configProvider.getAppConfig() } } @Test fun `finish an existing calcluation`() = runBlockingTest2(ignoreActive = true) { - val calcData = Calculation( + val calcData = TrackedExposureDetection( identifier = UUID.randomUUID().toString(), startedAt = Instant.EPOCH ) @@ -112,14 +122,14 @@ class DefaultCalculationTrackerTest : BaseTest() { val expectedData = initialData.mutate { this[calcData.identifier] = this[calcData.identifier]!!.copy( finishedAt = Instant.EPOCH.plus(1), - result = Calculation.Result.UPDATED_STATE + result = TrackedExposureDetection.Result.UPDATED_STATE ) } every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1) createInstance(scope = this).apply { - finishCalculation(calcData.identifier, Calculation.Result.UPDATED_STATE) + finishExposureDetection(calcData.identifier, TrackedExposureDetection.Result.UPDATED_STATE) advanceUntilIdle() @@ -137,11 +147,11 @@ class DefaultCalculationTrackerTest : BaseTest() { @Test fun `a late calculation overwrites timeout state`() = runBlockingTest2(ignoreActive = true) { - val calcData = Calculation( + val calcData = TrackedExposureDetection( identifier = UUID.randomUUID().toString(), startedAt = Instant.EPOCH, finishedAt = Instant.EPOCH.plus(1), - result = Calculation.Result.TIMEOUT + result = TrackedExposureDetection.Result.TIMEOUT ) val initialData = mapOf(calcData.identifier to calcData) coEvery { storage.load() } returns initialData @@ -151,12 +161,12 @@ class DefaultCalculationTrackerTest : BaseTest() { val expectedData = initialData.mutate { this[calcData.identifier] = this[calcData.identifier]!!.copy( finishedAt = Instant.EPOCH.plus(2), - result = Calculation.Result.UPDATED_STATE + result = TrackedExposureDetection.Result.UPDATED_STATE ) } createInstance(scope = this).apply { - finishCalculation(calcData.identifier, Calculation.Result.UPDATED_STATE) + finishExposureDetection(calcData.identifier, TrackedExposureDetection.Result.UPDATED_STATE) advanceUntilIdle() @@ -167,7 +177,7 @@ class DefaultCalculationTrackerTest : BaseTest() { @Test fun `no more than 10 calcluations are tracked`() = runBlockingTest2(ignoreActive = true) { val calcData = (1..15L).map { - val calcData = Calculation( + val calcData = TrackedExposureDetection( identifier = "$it", startedAt = Instant.EPOCH.plus(it) ) @@ -178,7 +188,7 @@ class DefaultCalculationTrackerTest : BaseTest() { every { timeStamper.nowUTC } returns Instant.EPOCH.plus(1) createInstance(scope = this).apply { - finishCalculation("7", Calculation.Result.UPDATED_STATE) + finishExposureDetection("7", TrackedExposureDetection.Result.UPDATED_STATE) advanceUntilIdle() @@ -195,32 +205,32 @@ class DefaultCalculationTrackerTest : BaseTest() { .plus(2) // First half will be in the timeout, last half will be ok - val timeoutOnRunningCalc = Calculation( + val timeoutOnRunningCalc = TrackedExposureDetection( identifier = "0", startedAt = Instant.EPOCH ) - val timeoutonRunningCalc2 = Calculation( + val timeoutonRunningCalc2 = TrackedExposureDetection( identifier = "1", startedAt = Instant.EPOCH.plus(1) ) // We shouldn't care for timeouts on finished calculations - val timeoutIgnoresFinishedCalcs = Calculation( + val timeoutIgnoresFinishedCalcs = TrackedExposureDetection( identifier = "2", startedAt = Instant.EPOCH.plus(1), finishedAt = Instant.EPOCH.plus(15) ) // This one is right on the edge, testing <= behavior - val timeoutRunningOnEdge = Calculation( + val timeoutRunningOnEdge = TrackedExposureDetection( identifier = "3", startedAt = Instant.EPOCH.plus(2) ) - val noTimeoutCalcRunning = Calculation( + val noTimeoutCalcRunning = TrackedExposureDetection( identifier = "4", startedAt = Instant.EPOCH.plus(4) ) - val noTimeOutCalcFinished = Calculation( + val noTimeOutCalcFinished = TrackedExposureDetection( identifier = "5", startedAt = Instant.EPOCH.plus(5), finishedAt = Instant.EPOCH.plus(15) @@ -245,11 +255,11 @@ class DefaultCalculationTrackerTest : BaseTest() { this["0"] shouldBe timeoutOnRunningCalc.copy( finishedAt = timeStamper.nowUTC, - result = Calculation.Result.TIMEOUT + result = TrackedExposureDetection.Result.TIMEOUT ) this["1"] shouldBe timeoutonRunningCalc2.copy( finishedAt = timeStamper.nowUTC, - result = Calculation.Result.TIMEOUT + result = TrackedExposureDetection.Result.TIMEOUT ) this["2"] shouldBe timeoutIgnoresFinishedCalcs diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorageTest.kt similarity index 87% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorageTest.kt index 4a7c2a019..fee9f63fa 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTrackerStorageTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/ExposureDetectionTrackerStorageTest.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.nearby.modules.calculationtracker +package de.rki.coronawarnapp.nearby.modules.detectiontracker import android.content.Context import com.google.gson.GsonBuilder @@ -17,7 +17,7 @@ import org.junit.jupiter.api.Test import testhelpers.BaseIOTest import java.io.File -class CalculationTrackerStorageTest : BaseIOTest() { +class ExposureDetectionTrackerStorageTest : BaseIOTest() { @MockK private lateinit var context: Context @@ -50,15 +50,15 @@ class CalculationTrackerStorageTest : BaseIOTest() { """.trimIndent() private val demoData = run { - val calculation1 = Calculation( + val calculation1 = TrackedExposureDetection( identifier = "b2b98400-058d-43e6-b952-529a5255248b", startedAt = Instant.ofEpochMilli(1234) ) - val calculation2 = Calculation( + val calculation2 = TrackedExposureDetection( identifier = "aeb15509-fb34-42ce-8795-7a9ae0c2f389", startedAt = Instant.ofEpochMilli(5678), finishedAt = Instant.ofEpochMilli(1603473968125), - result = Calculation.Result.UPDATED_STATE + result = TrackedExposureDetection.Result.UPDATED_STATE ) mapOf( calculation1.identifier to calculation1, @@ -78,7 +78,7 @@ class CalculationTrackerStorageTest : BaseIOTest() { testDir.deleteRecursively() } - private fun createStorage() = CalculationTrackerStorage( + private fun createStorage() = ExposureDetectionTrackerStorage( context = context, gson = SerializationModule().baseGson() ) @@ -117,7 +117,7 @@ class CalculationTrackerStorageTest : BaseIOTest() { createStorage().save(demoData) storageFile.exists() shouldBe true - val storedData: Map<String, Calculation> = gson.fromJson(storageFile) + val storedData: Map<String, TrackedExposureDetection> = gson.fromJson(storageFile) storedData shouldBe demoData gson.toJson(storedData) shouldBe demoJsonString @@ -126,7 +126,7 @@ class CalculationTrackerStorageTest : BaseIOTest() { @Test fun `gson does weird things to property initialization`() { // This makes sure we are using val-getters, otherwise gson inits our @Ŧransient properties to false - val storedData: Map<String, Calculation> = gson.fromJson(demoJsonString) + val storedData: Map<String, TrackedExposureDetection> = gson.fromJson(demoJsonString) storedData.getValue("b2b98400-058d-43e6-b952-529a5255248b").isCalculating shouldBe true storedData.getValue("aeb15509-fb34-42ce-8795-7a9ae0c2f389").isCalculating shouldBe false } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetectionTest.kt similarity index 81% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetectionTest.kt index 7afb7e551..d30014e3c 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/calculationtracker/CalculationTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/nearby/modules/detectiontracker/TrackedExposureDetectionTest.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.nearby.modules.calculationtracker +package de.rki.coronawarnapp.nearby.modules.detectiontracker import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations @@ -9,7 +9,7 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import testhelpers.BaseTest -class CalculationTest : BaseTest() { +class TrackedExposureDetectionTest : BaseTest() { @BeforeEach fun setup() { @@ -23,7 +23,7 @@ class CalculationTest : BaseTest() { @Test fun `isCalculating flag depends on finishedAt`() { - val initial = Calculation( + val initial = TrackedExposureDetection( identifier = "123", startedAt = Instant.EPOCH ) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt index f0802e4c3..fbc170105 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/receiver/ExposureStateUpdateReceiverTest.kt @@ -8,8 +8,8 @@ import androidx.work.WorkRequest import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient import dagger.android.AndroidInjector import dagger.android.HasAndroidInjector -import de.rki.coronawarnapp.nearby.modules.calculationtracker.Calculation -import de.rki.coronawarnapp.nearby.modules.calculationtracker.CalculationTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.ExposureDetectionTracker +import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection import de.rki.coronawarnapp.util.di.AppInjector import io.mockk.MockKAnnotations import io.mockk.clearAllMocks @@ -35,7 +35,7 @@ class ExposureStateUpdateReceiverTest : BaseTest() { @MockK private lateinit var intent: Intent @MockK private lateinit var workManager: WorkManager - @MockK private lateinit var calculationTracker: CalculationTracker + @MockK private lateinit var exposureDetectionTracker: ExposureDetectionTracker private val scope = TestCoroutineScope() class TestApp : Application(), HasAndroidInjector { @@ -57,7 +57,7 @@ class ExposureStateUpdateReceiverTest : BaseTest() { every { context.applicationContext } returns application val broadcastReceiverInjector = AndroidInjector<Any> { it as ExposureStateUpdateReceiver - it.calculationTracker = calculationTracker + it.exposureDetectionTracker = exposureDetectionTracker it.dispatcherProvider = TestDispatcherProvider it.scope = scope } @@ -79,7 +79,7 @@ class ExposureStateUpdateReceiverTest : BaseTest() { verifySequence { workManager.enqueue(any<WorkRequest>()) - calculationTracker.finishCalculation("token", Calculation.Result.UPDATED_STATE) + exposureDetectionTracker.finishExposureDetection("token", TrackedExposureDetection.Result.UPDATED_STATE) } } @@ -89,7 +89,7 @@ class ExposureStateUpdateReceiverTest : BaseTest() { ExposureStateUpdateReceiver().onReceive(context, intent) verifySequence { - calculationTracker.finishCalculation("token", Calculation.Result.NO_MATCHES) + exposureDetectionTracker.finishExposureDetection("token", TrackedExposureDetection.Result.NO_MATCHES) } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/TestSettingsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/TestSettingsTest.kt index 6468dda95..f904964bf 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/TestSettingsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/storage/TestSettingsTest.kt @@ -2,7 +2,6 @@ package de.rki.coronawarnapp.storage import android.content.Context import de.rki.coronawarnapp.util.CWADebug -import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations import io.mockk.clearAllMocks import io.mockk.every @@ -10,7 +9,6 @@ import io.mockk.impl.annotations.MockK import io.mockk.mockkObject import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test import testhelpers.BaseTest import testhelpers.preferences.MockSharedPreferences @@ -39,31 +37,4 @@ class TestSettingsTest : BaseTest() { private fun buildInstance(): TestSettings = TestSettings( context = context ) - - @Test - fun `hourly keypkg testing mode`() { - buildInstance().apply { - every { CWADebug.isDeviceForTestersBuild } returns true - - isHourKeyPkgMode shouldBe false - isHourKeyPkgMode = true - isHourKeyPkgMode shouldBe true - mockPreferences.dataMapPeek.values.single() shouldBe true - - isHourKeyPkgMode = false - isHourKeyPkgMode shouldBe false - mockPreferences.dataMapPeek.values.single() shouldBe false - - isHourKeyPkgMode = true - } - - buildInstance().apply { - isHourKeyPkgMode shouldBe true - - // In normal builds this should default to false - every { CWADebug.isDeviceForTestersBuild } returns false - - isHourKeyPkgMode shouldBe false - } - } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt index 1b8b83751..581eab4dc 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/TaskControllerTest.kt @@ -16,11 +16,11 @@ import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.instanceOf import io.mockk.MockKAnnotations import io.mockk.clearAllMocks +import io.mockk.coVerifySequence import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk import io.mockk.spyk -import io.mockk.verifySequence import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay @@ -173,8 +173,8 @@ class TaskControllerTest : BaseIOTest() { } } - verifySequence { - queueingFactory.config + coVerifySequence { + queueingFactory.createConfig() queueingFactory.taskProvider } @@ -379,10 +379,10 @@ class TaskControllerTest : BaseIOTest() { arguments.path.length() shouldBe 720L - verifySequence { - queueingFactory.config + coVerifySequence { + queueingFactory.createConfig() queueingFactory.taskProvider - skippingFactory.config + skippingFactory.createConfig() skippingFactory.taskProvider } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt index f04714e34..cb0848c9f 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/SkippingTask.kt @@ -21,8 +21,8 @@ class SkippingTask : QueueingTask() { private val taskByDagger: Provider<QueueingTask> ) : TaskFactory<DefaultProgress, Result> { - override val config: TaskFactory.Config = - Config() + override suspend fun createConfig(): Config = Config() + override val taskProvider: () -> Task<DefaultProgress, Result> = { taskByDagger.get() } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt index d5e513abb..bafa28a2c 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/BaseTimeoutTask.kt @@ -42,7 +42,7 @@ abstract class BaseTimeoutTask : Task<DefaultProgress, TimeoutTaskResult> { private val taskByDagger: Provider<BaseTimeoutTask> ) : TaskFactory<DefaultProgress, TimeoutTaskResult> { - override val config: TaskFactory.Config = TimeoutTaskConfig() + override suspend fun createConfig(): TaskFactory.Config = TimeoutTaskConfig() override val taskProvider: () -> Task<DefaultProgress, TimeoutTaskResult> = { taskByDagger.get() } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt index 927c107ed..a2a9be3c2 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/TanTest.kt @@ -52,7 +52,7 @@ class TanTest : BaseTest() { "9A3B578UMG", "DEU7TKSV3H", "PTPHM35RP4", "V923D59AT8", "H9NC5CQ34E" ) for (tan in validTans) { - Tan.allCharactersValid(tan) shouldBe true + Tan.allCharactersValid(tan) shouldBe true Tan.isChecksumValid(tan) shouldBe true (tan.length == Tan.MAX_LENGTH) shouldBe true } @@ -62,7 +62,7 @@ class TanTest : BaseTest() { "ABÖAA1", "-1234", "PTPHM15RP4", "aAASd A" ) for (tan in invalidTans) { - Tan.allCharactersValid(tan) shouldBe false + Tan.allCharactersValid(tan) shouldBe false } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/network/NetworkStateProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/network/NetworkStateProviderTest.kt new file mode 100644 index 000000000..2a9e92c6c --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/network/NetworkStateProviderTest.kt @@ -0,0 +1,215 @@ +package de.rki.coronawarnapp.util.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.LinkProperties +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import de.rki.coronawarnapp.storage.TestSettings +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import io.mockk.Called +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.verify +import io.mockk.verifySequence +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestCoroutineScope +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.coroutines.runBlockingTest2 +import testhelpers.coroutines.test +import testhelpers.preferences.mockFlowPreference + +class NetworkStateProviderTest : BaseTest() { + + @MockK lateinit var context: Context + @MockK lateinit var conMan: ConnectivityManager + @MockK lateinit var testSettings: TestSettings + + @MockK lateinit var network: Network + @MockK lateinit var networkRequest: NetworkRequest + @MockK lateinit var networkRequestBuilder: NetworkRequest.Builder + @MockK lateinit var networkRequestBuilderProvider: NetworkRequestBuilderProvider + @MockK lateinit var capabilities: NetworkCapabilities + @MockK lateinit var linkProperties: LinkProperties + + private var lastRequest: NetworkRequest? = null + private var lastCallback: ConnectivityManager.NetworkCallback? = null + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + every { + conMan.registerNetworkCallback( + any<NetworkRequest>(), + any<ConnectivityManager.NetworkCallback>() + ) + } answers { + lastRequest = arg(0) + lastCallback = arg(1) + mockk() + } + every { conMan.unregisterNetworkCallback(any<ConnectivityManager.NetworkCallback>()) } just Runs + + every { context.getSystemService(Context.CONNECTIVITY_SERVICE) } returns conMan + + every { networkRequestBuilderProvider.get() } returns networkRequestBuilder + every { networkRequestBuilder.addCapability(any()) } returns networkRequestBuilder + every { networkRequestBuilder.build() } returns networkRequest + + every { conMan.activeNetwork } returns network + every { conMan.getNetworkCapabilities(network) } returns capabilities + every { conMan.getLinkProperties(network) } returns linkProperties + + every { testSettings.fakeMeteredConnection } returns mockFlowPreference(false) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createInstance(scope: CoroutineScope) = NetworkStateProvider( + context = context, + appScope = scope, + networkRequestBuilderProvider = networkRequestBuilderProvider, + testSettings = testSettings + ) + + @Test + fun `init is sideeffect free and lazy`() { + shouldNotThrowAny { + createInstance(TestCoroutineScope()) + } + verify { conMan wasNot Called } + } + + @Test + fun `initial state is emitted correctly without callback`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) + + instance.networkState.first() shouldBe NetworkStateProvider.State( + activeNetwork = network, + capabilities = capabilities, + linkProperties = linkProperties + ) + + advanceUntilIdle() + + verifySequence { + conMan.activeNetwork + conMan.getNetworkCapabilities(network) + conMan.getLinkProperties(network) + conMan.registerNetworkCallback(networkRequest, any<ConnectivityManager.NetworkCallback>()) + conMan.unregisterNetworkCallback(lastCallback!!) + } + } + + @Test + fun `we can handle null networks`() = runBlockingTest2(ignoreActive = true) { + every { conMan.activeNetwork } returns null + val instance = createInstance(this) + + instance.networkState.first() shouldBe NetworkStateProvider.State( + activeNetwork = null, + capabilities = null, + linkProperties = null + ) + verify { conMan.activeNetwork } + } + + @Test + fun `system callbacks lead to new emissions with an updated state`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) + + val testCollector = instance.networkState.test(startOnScope = this) + + lastCallback!!.onAvailable(mockk()) + + every { conMan.activeNetwork } returns null + lastCallback!!.onUnavailable() + + every { conMan.activeNetwork } returns network + lastCallback!!.onAvailable(mockk()) + + advanceUntilIdle() + + // 3 not 4 as first onAvailable call doesn't change the value (stateIn behavior) + testCollector.latestValues.size shouldBe 3 + + testCollector.awaitFinal(cancel = true) + + verifySequence { + // Start value + conMan.activeNetwork + conMan.getNetworkCapabilities(network) + conMan.getLinkProperties(network) + conMan.registerNetworkCallback(networkRequest, any<ConnectivityManager.NetworkCallback>()) + + // onAvailable + conMan.activeNetwork + conMan.getNetworkCapabilities(network) + conMan.getLinkProperties(network) + + // onUnavailable + conMan.activeNetwork + + // onAvailable + conMan.activeNetwork + conMan.getNetworkCapabilities(network) + conMan.getLinkProperties(network) + + conMan.unregisterNetworkCallback(lastCallback!!) + } + } + + @Test + fun `metered connection state checks capabilities`() { + val capabilities = mockk<NetworkCapabilities>() + + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } returns true + NetworkStateProvider.State( + activeNetwork = null, + capabilities = capabilities, + linkProperties = null + ).isMeteredConnection shouldBe false + + NetworkStateProvider.State( + activeNetwork = null, + capabilities = null, + linkProperties = null + ).isMeteredConnection shouldBe true + + every { capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) } returns false + NetworkStateProvider.State( + activeNetwork = null, + capabilities = capabilities, + linkProperties = null + ).isMeteredConnection shouldBe true + } + + @Test + fun `metered connection state can be overriden via test settings`() = runBlockingTest2(ignoreActive = true) { + every { testSettings.fakeMeteredConnection } returns mockFlowPreference(true) + val instance = createInstance(this) + + instance.networkState.first() + + NetworkStateProvider.State( + activeNetwork = null, + capabilities = null, + linkProperties = null + ).isMeteredConnection shouldBe true + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/preferences/FlowPreferenceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/preferences/FlowPreferenceTest.kt new file mode 100644 index 000000000..58c53a7fa --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/preferences/FlowPreferenceTest.kt @@ -0,0 +1,209 @@ +package de.rki.coronawarnapp.util.preferences + +import com.google.gson.Gson +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.extensions.toComparableJson +import testhelpers.preferences.MockSharedPreferences + +class FlowPreferenceTest : BaseTest() { + + private val mockPreferences = MockSharedPreferences() + + @Test + fun `reading and writing strings`() = runBlockingTest { + mockPreferences.createFlowPreference<String?>( + key = "testKey", + defaultValue = "default" + ).apply { + value shouldBe "default" + flow.first() shouldBe "default" + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe "default" + "newvalue" + } + + value shouldBe "newvalue" + flow.first() shouldBe "newvalue" + mockPreferences.dataMapPeek.values.first() shouldBe "newvalue" + + update { + it shouldBe "newvalue" + null + } + value shouldBe "default" + flow.first() shouldBe "default" + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing boolean`() = runBlockingTest { + mockPreferences.createFlowPreference<Boolean?>( + key = "testKey", + defaultValue = true + ).apply { + value shouldBe true + flow.first() shouldBe true + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe true + false + } + + value shouldBe false + flow.first() shouldBe false + mockPreferences.dataMapPeek.values.first() shouldBe false + + update { + it shouldBe false + null + } + value shouldBe true + flow.first() shouldBe true + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing long`() = runBlockingTest { + mockPreferences.createFlowPreference<Long?>( + key = "testKey", + defaultValue = 9000L + ).apply { + value shouldBe 9000L + flow.first() shouldBe 9000L + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 9000L + 9001L + } + + value shouldBe 9001L + flow.first() shouldBe 9001L + mockPreferences.dataMapPeek.values.first() shouldBe 9001L + + update { + it shouldBe 9001L + null + } + value shouldBe 9000L + flow.first() shouldBe 9000L + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing integer`() = runBlockingTest { + mockPreferences.createFlowPreference<Long?>( + key = "testKey", + defaultValue = 123 + ).apply { + value shouldBe 123 + flow.first() shouldBe 123 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 123 + 44 + } + + value shouldBe 44 + flow.first() shouldBe 44 + mockPreferences.dataMapPeek.values.first() shouldBe 44 + + update { + it shouldBe 44 + null + } + value shouldBe 123 + flow.first() shouldBe 123 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + @Test + fun `reading and writing float`() = runBlockingTest { + mockPreferences.createFlowPreference<Float?>( + key = "testKey", + defaultValue = 3.6f + ).apply { + value shouldBe 3.6f + flow.first() shouldBe 3.6f + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe 3.6f + 15000f + } + + value shouldBe 15000f + flow.first() shouldBe 15000f + mockPreferences.dataMapPeek.values.first() shouldBe 15000f + + update { + it shouldBe 15000f + null + } + value shouldBe 3.6f + flow.first() shouldBe 3.6f + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } + + data class TestGson( + val string: String = "", + val boolean: Boolean = true, + val float: Float = 1.0f, + val int: Int = 1, + val long: Long = 1L + ) + + @Test + fun `reading and writing GSON`() = runBlockingTest { + val testData1 = TestGson(string = "teststring") + val testData2 = TestGson(string = "update") + FlowPreference<TestGson?>( + preferences = mockPreferences, + key = "testKey", + reader = FlowPreference.gsonReader(Gson(), testData1), + writer = FlowPreference.gsonWriter(Gson()) + ).apply { + value shouldBe testData1 + flow.first() shouldBe testData1 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + + update { + it shouldBe testData1 + it!!.copy(string = "update") + } + + value shouldBe testData2 + flow.first() shouldBe testData2 + (mockPreferences.dataMapPeek.values.first() as String).toComparableJson() shouldBe """ + { + "string":"update", + "boolean":true, + "float":1.0, + "int":1, + "long":1 + } + """.toComparableJson() + + update { + it shouldBe testData2 + null + } + value shouldBe testData1 + flow.first() shouldBe testData1 + mockPreferences.dataMapPeek.values.isEmpty() shouldBe true + } + } +} diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt index 975a8c48d..3fc56ce9a 100644 --- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt +++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/FlowTest.kt @@ -80,7 +80,8 @@ class TestCollector<T>( } } - suspend fun awaitFinal() = apply { + suspend fun awaitFinal(cancel: Boolean = false) = apply { + if (cancel) cancel() try { job.join() } catch (e: Exception) { diff --git a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt index b5a62565a..375adf36e 100644 --- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt +++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestCoroutineScope import kotlinx.coroutines.test.UncompletedCoroutinesError import kotlinx.coroutines.test.runBlockingTest +import timber.log.Timber import kotlin.coroutines.CoroutineContext import kotlin.coroutines.EmptyCoroutineContext @@ -32,6 +33,7 @@ fun runBlockingTest2( ) } catch (e: UncompletedCoroutinesError) { if (!ignoreActive) throw e + else Timber.v("Ignoring active job.") } } } catch (e: Exception) { diff --git a/Corona-Warn-App/src/test/java/testhelpers/preferences/MockFlowPreference.kt b/Corona-Warn-App/src/test/java/testhelpers/preferences/MockFlowPreference.kt new file mode 100644 index 000000000..33d46578e --- /dev/null +++ b/Corona-Warn-App/src/test/java/testhelpers/preferences/MockFlowPreference.kt @@ -0,0 +1,20 @@ +package testhelpers.preferences + +import de.rki.coronawarnapp.util.preferences.FlowPreference +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.flow.MutableStateFlow + +fun <T> mockFlowPreference( + defaultValue: T +): FlowPreference<T> { + val instance = mockk<FlowPreference<T>>() + val flow = MutableStateFlow(defaultValue) + every { instance.flow } answers { flow } + every { instance.value } answers { flow.value } + every { instance.update(any()) } answers { + val updateCall = arg<(T) -> T>(0) + flow.value = updateCall(flow.value) + } + return instance +} -- GitLab