diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle index 7cd67f74b4201019176e6559bb946ad15e1042a4..1fb62d84fd0f4a3db61d212a57f07acca1e72665 100644 --- a/Corona-Warn-App/build.gradle +++ b/Corona-Warn-App/build.gradle @@ -313,7 +313,7 @@ dependencies { implementation 'androidx.navigation:navigation-ui-ktx:2.2.2' implementation 'androidx.legacy:legacy-support-v4:1.0.0' implementation 'androidx.preference:preference:1.1.1' - implementation 'androidx.work:work-runtime-ktx:2.4.0' + implementation 'androidx.work:work-runtime-ktx:2.5.0-beta01' implementation 'androidx.lifecycle:lifecycle-common-java8:2.2.0' implementation 'androidx.lifecycle:lifecycle-process:2.2.0' @@ -344,7 +344,7 @@ dependencies { implementation 'com.google.zxing:core:3.3.0' //ENA - implementation files('libs\\play-services-nearby-exposurenotification-1.6.1-eap.aar') + implementation files('libs\\play-services-nearby-exposurenotification-1.7.2-eap.aar') // Testing testImplementation "androidx.arch.core:core-testing:2.1.0" @@ -359,6 +359,8 @@ dependencies { // Testing - jUnit4 testImplementation 'junit:junit:4.13.1' testImplementation "org.junit.vintage:junit-vintage-engine:5.7.0" + testImplementation "androidx.test:core-ktx:1.3.0" + // Testing - jUnit5 testImplementation "org.junit.jupiter:junit-jupiter-api:5.7.0" @@ -380,7 +382,7 @@ dependencies { androidTestImplementation 'androidx.test:rules:1.3.0' androidTestImplementation 'androidx.test.ext:truth:1.3.0' androidTestImplementation 'androidx.test.ext:junit:1.1.2' - androidTestImplementation 'androidx.work:work-testing:2.4.0' + androidTestImplementation 'androidx.work:work-testing:2.5.0-beta01' androidTestImplementation "io.mockk:mockk-android:1.10.2" debugImplementation 'androidx.fragment:fragment-testing:1.2.5' diff --git a/Corona-Warn-App/config/detekt.yml b/Corona-Warn-App/config/detekt.yml index 0d06a280c125c8b8c72ebac0e0e8d104882ef824..16605a8917887ed41ae1933e82c95a3dd0a3cb0a 100644 --- a/Corona-Warn-App/config/detekt.yml +++ b/Corona-Warn-App/config/detekt.yml @@ -513,7 +513,7 @@ style: active: true maxJumpCount: 1 MagicNumber: - active: true + active: false excludes: [ '**/test/**', '**/androidTest/**', diff --git a/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.6.1-eap.aar b/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.6.1-eap.aar deleted file mode 100644 index 6eddb67adf1c8419af03a6c68153ba5dbb7397b1..0000000000000000000000000000000000000000 Binary files a/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.6.1-eap.aar and /dev/null differ diff --git a/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.7.2-eap.aar b/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.7.2-eap.aar new file mode 100644 index 0000000000000000000000000000000000000000..a2ec17e31ceaf61366982ab414810b5c7f268ece Binary files /dev/null and b/Corona-Warn-App/libs/play-services-nearby-exposurenotification-1.7.2-eap.aar differ 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 ac0ccdefe87983cc638001ef67e900c75810b44a..62230915dc95eda085f563425251a52969ffbb52 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/bugreporting/processor/DefaultBugProcessor.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt index 12c569990d6ca9a63ebccfaa7651240997557846..0c19f0fae546ff78633ec7513e583e6d657c8e2a 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/bugreporting/processor/DefaultBugProcessor.kt @@ -9,7 +9,7 @@ import de.rki.coronawarnapp.bugreporting.event.DefaultBugEvent import de.rki.coronawarnapp.bugreporting.loghistory.RollingLogHistory import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.di.AppContext -import de.rki.coronawarnapp.util.tryFormattedError +import de.rki.coronawarnapp.util.tryHumanReadableError import javax.inject.Inject import javax.inject.Singleton @@ -21,8 +21,9 @@ class DefaultBugProcessor @Inject constructor( ) : BugProcessor { override suspend fun processor(throwable: Throwable, tag: String?, info: String?): BugEvent { + val formattedError = throwable.tryHumanReadableError(context) + val crashedAt = timeStamper.nowUTC - val exceptionMessage = throwable.tryFormattedError(context) val exceptionClass = throwable::class.java.simpleName val stacktrace = Log.getStackTraceString(throwable) val deviceInfo = "${Build.MANUFACTURER} ${Build.MODEL} (${Build.DEVICE})" @@ -38,7 +39,7 @@ class DefaultBugProcessor @Inject constructor( tag = tag, info = info, exceptionClass = exceptionClass, - exceptionMessage = exceptionMessage, + exceptionMessage = formattedError.description, stackTrace = stacktrace, deviceInfo = deviceInfo, appVersionName = appVersionName, 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 1b6dfba3e38df02fb09a5a9ea586cf4d2eabf1da..0000000000000000000000000000000000000000 --- 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 fbdd56b5352c37f695c0ffa31727a7074bc68af1..694615a9ae234ae6f626b12a7ff9f0f179402ff1 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 @@ -66,7 +61,7 @@ import java.lang.reflect.Type import java.util.UUID import javax.inject.Inject -@SuppressWarnings("TooManyFunctions", "MagicNumber", "LongMethod") +@SuppressWarnings("TooManyFunctions", "LongMethod") class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), InternalExposureNotificationPermissionHelper.Callback, AutoInject { @@ -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/appconfig/ui/AppConfigTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt index feedc56bc7d28d2f0be692f7410a2e026c6662b7..742411f39edaa87b0a911267cb3b4d527326c194 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt @@ -36,7 +36,7 @@ class AppConfigTestFragment : Fragment(R.layout.fragment_test_appconfig), AutoIn data?.rawConfig?.toString() ?: "No config available." binding.lastUpdate.text = data?.updatedAt?.let { timeFormatter.print(it) } ?: "n/a" binding.timeOffset.text = data?.let { - "${it.localOffset.millis}ms (isFallbackConfig=${it.isFallback})" + "${it.localOffset.millis}ms (configType=${it.configType})" } ?: "n/a" } 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 8015debc73d4333c59d03accd9e61e6e823e9005..91b1206da2ff70f7df02367cf75d340c70809c70 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 784c9731edd17cf949a8bb4d22d8898732fb8691..c58e64556e2a35b1f6604de20ecaee41ba75638e 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 0000000000000000000000000000000000000000..da57e399eaf9c4caf03c07a9e2fa719d3375fa17 --- /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 0000000000000000000000000000000000000000..a659fbacff63f239e7baec4f795a987c51a5b2a5 --- /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 0000000000000000000000000000000000000000..60233a4eaee37dfb957b7aef30ecf52be4186d2d --- /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 0000000000000000000000000000000000000000..c5af46aeb618516de1de0c69af12a6dd4b3f96c2 --- /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 0000000000000000000000000000000000000000..c2dbd49fc3f779fa2ca313fcb178db5a8c10c42e --- /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 0000000000000000000000000000000000000000..596413a519f808b973c7998038b35fa9751582ba --- /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 d3316873e6c0bb8d17fc9cf190189b9e1eb4faed..36d71e9cb89f0fde006cbb5c9f2df53e228d6c33 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/risklevel/ui/TestRiskLevelCalculationFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt index 0644ec8261959aac9dd0a9428a133b11b15d01f3..0867b6e6bfcbce4261cde511273134389288fe83 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragment.kt @@ -24,7 +24,7 @@ import de.rki.coronawarnapp.util.viewmodel.cwaViewModelsAssisted import timber.log.Timber import javax.inject.Inject -@Suppress("MagicNumber", "LongMethod") +@Suppress("LongMethod") class TestRiskLevelCalculationFragment : Fragment(R.layout.fragment_test_risk_level_calculation), AutoInject { private val navArgs by navArgs<TestRiskLevelCalculationFragmentArgs>() 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 762a10de5f296adb9f3a06fa6846532c65ffeb59..45c6a1a39cb71af33faf15ee88baf8433099c4a4 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 @@ -31,7 +31,6 @@ class TestTask @Inject constructor() : Task<DefaultProgress, TestTask.Result> { internalProgress.close() } - @Suppress("MagicNumber") private suspend fun runSafely(arguments: Arguments): Result { for (it in 1..10) { internalProgress.send(DefaultProgress("${arguments.prefix}: ${Instant.now()}")) @@ -65,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/TestTaskControllerFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragment.kt index 4fd79b65b8f483eec939d0ecc93aa5bab934fd8e..c990c6980263ead1aa8bbdcfcb466edea665be66 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/tasks/ui/TestTaskControllerFragment.kt @@ -16,7 +16,7 @@ import de.rki.coronawarnapp.util.viewmodel.cwaViewModels import javax.inject.Inject @SuppressLint("SetTextI18n") -@Suppress("MagicNumber", "LongMethod") +@Suppress("LongMethod") class TestTaskControllerFragment : Fragment(R.layout.fragment_test_task_controller), AutoInject { @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory 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 7b88f9bf51ab4d9ef4685cac6dba6e32f634c63d..82b274e9b6196175afa396580e06d77b5d3114e2 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 dae7827378498fa470ebb0df278741e4d1e00522..41cca0d6071aab1aca83653e9283893028312c39 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 628201557567cccfde46d6928854209b410860e2..867b86569315d89fa9c080b0944a56deee7970cb 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 a72eafd72f6db23a094411021dbf8dd578e9cfc2..a4eb49227c15f443d2dde6b13eb76d3affca190c 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 0000000000000000000000000000000000000000..7c160bde79a9c55807e3402cc0fae18afca6accd --- /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 0000000000000000000000000000000000000000..ba86dfd179b0b4b0d970b67d873886cef7453cc0 --- /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 1c53c77729006536c92d18efecd0b547370bb3cf..226a618a77e11982b3a1ead5d9fbf879429c7188 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/AndroidManifest.xml b/Corona-Warn-App/src/main/AndroidManifest.xml index c4906d42e94beacd8d93e1454cc46327332be173..6556197e9545e30520b7ac4d2027f8a5d6772781 100644 --- a/Corona-Warn-App/src/main/AndroidManifest.xml +++ b/Corona-Warn-App/src/main/AndroidManifest.xml @@ -48,6 +48,10 @@ </intent-filter> </receiver> + <receiver + android:name=".notification.NotificationReceiver" + android:enabled="true"/> + <activity android:name=".ui.LauncherActivity" android:screenOrientation="portrait" diff --git a/Corona-Warn-App/src/main/assets/default_app_config.bin b/Corona-Warn-App/src/main/assets/default_app_config.bin new file mode 100644 index 0000000000000000000000000000000000000000..d04532907debe2eaf94fb70f39a3a0ccb7d9937a Binary files /dev/null and b/Corona-Warn-App/src/main/assets/default_app_config.bin differ diff --git a/Corona-Warn-App/src/main/assets/default_app_config.sha256 b/Corona-Warn-App/src/main/assets/default_app_config.sha256 new file mode 100644 index 0000000000000000000000000000000000000000..686b1764cf7bd1c0662651322c5ca3f29c8f2ff5 --- /dev/null +++ b/Corona-Warn-App/src/main/assets/default_app_config.sha256 @@ -0,0 +1 @@ +a562bf5940b8c149138634d313db69a298a50e8c52c0b42d18ddf608c385b598 \ No newline at end of file diff --git a/Corona-Warn-App/src/main/assets/privacy_de.html b/Corona-Warn-App/src/main/assets/privacy_de.html index ef95dc99ffd7ded32261d06b2f8ccba4283c5686..dd168314234a071eee96b203b3a0d4c60167b306 100644 --- a/Corona-Warn-App/src/main/assets/privacy_de.html +++ b/Corona-Warn-App/src/main/assets/privacy_de.html @@ -16,8 +16,7 @@ <strong>2. Ist die Nutzung der App freiwillig?</strong> </p> <p> - <strong> - Auf welcher Rechtsgrundlage werden Ihre Daten verarbeitet? + <strong>3. Auf welcher Rechtsgrundlage werden Ihre Daten verarbeitet? </strong> </p> <p> @@ -792,4 +791,4 @@ </p> <p> Stand: 15.10.2020 -</p> \ No newline at end of file +</p> diff --git a/Corona-Warn-App/src/main/assets/terms_de.html b/Corona-Warn-App/src/main/assets/terms_de.html index 49dfc65a29b23d2ce05571516b824266dfe0b9bd..380c9d68b8d3ab6ff7a2040a9514a8db76734f73 100644 --- a/Corona-Warn-App/src/main/assets/terms_de.html +++ b/Corona-Warn-App/src/main/assets/terms_de.html @@ -454,7 +454,7 @@ <p> Bestimmte Funktionen der App setzen auf zentrale Dienste und Systeme auf, die über die CWA-Dienste zur Verfügung gestellt werden. Diese Funktionen - stehen daher nur zur Verfügung, wenn Ihr Smartpnone über eine Datenverbindung + stehen daher nur zur Verfügung, wenn Ihr Smartphone über eine Datenverbindung mit dem Internet verfügt, z.B. über UMTS, LTE oder WLAN, um hierüber auf die CWA-Dienste zugreifen zu können. Ohne Datenverbindung stehen einige oder alle Funktionen der App nicht zur Verfügung. Dies gilt auch, wenn Sie diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt index 473252103438e393733130f36590eb7841fb2b36..ebe7c473fc3d6037becb4bcab9981d8fe5fccf6f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt @@ -7,20 +7,22 @@ import android.content.IntentFilter import android.os.Bundle import android.view.WindowManager import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.work.WorkManager import dagger.android.AndroidInjector import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.rki.coronawarnapp.bugreporting.loghistory.LogHistoryTree +import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler import de.rki.coronawarnapp.exception.reporting.ErrorReportReceiver import de.rki.coronawarnapp.exception.reporting.ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL import de.rki.coronawarnapp.notification.NotificationHelper +import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.task.TaskController import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.ForegroundState import de.rki.coronawarnapp.util.WatchdogService import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.di.ApplicationComponent -import de.rki.coronawarnapp.util.worker.WorkManagerSetup import de.rki.coronawarnapp.worker.BackgroundWorkHelper import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.launchIn @@ -41,7 +43,8 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { @Inject lateinit var watchdogService: WatchdogService @Inject lateinit var taskController: TaskController @Inject lateinit var foregroundState: ForegroundState - @Inject lateinit var workManagerSetup: WorkManagerSetup + @Inject lateinit var workManager: WorkManager + @Inject lateinit var deadmanNotificationScheduler: DeadmanNotificationScheduler @LogHistoryTree @Inject lateinit var rollingLogHistory: Timber.Tree override fun onCreate() { @@ -54,8 +57,7 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { Timber.plant(rollingLogHistory) - Timber.v("onCreate(): Initializing WorkManager") - workManagerSetup.setup() + Timber.v("onCreate(): WorkManager setup done: $workManager") NotificationHelper.createNotificationChannel() @@ -73,6 +75,10 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { foregroundState.isInForeground .onEach { isAppInForeground = it } .launchIn(GlobalScope) + + if (LocalData.onboardingCompletedTimestamp() != null) { + deadmanNotificationScheduler.schedulePeriodic() + } } private val activityLifecycleCallback = object : ActivityLifecycleCallbacks { 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 82936e52b38862e1288652fe250952d4e5669375..19ae812f371b271dcd77c11f8037b61d8d393f09 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/AppConfigProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt index 91866abb5c8d0d302038a0ffce536ab629246140..71906e83169d8e0ef4a68e34aa1208ec958defc5 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigProvider.kt @@ -2,12 +2,11 @@ package de.rki.coronawarnapp.appconfig import de.rki.coronawarnapp.util.coroutine.AppScope import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.flow.HotDataFlow import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.SharingStarted import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -19,24 +18,31 @@ class AppConfigProvider @Inject constructor( @AppScope private val scope: CoroutineScope ) { - private val mutex = Mutex() - private val currentConfigInternal = MutableStateFlow<ConfigData?>(null) - - val currentConfig: Flow<ConfigData?> = currentConfigInternal - - suspend fun clear() = mutex.withLock { - Timber.tag(TAG).v("clear()") - source.clear() - currentConfigInternal.value = null + private val configHolder = HotDataFlow( + loggingTag = "AppConfigProvider", + scope = scope, + coroutineContext = dispatcherProvider.IO, + sharingBehavior = SharingStarted.WhileSubscribed(replayExpirationMillis = 0) + ) { + source.retrieveConfig() } - suspend fun getAppConfig(): ConfigData = mutex.withLock { - Timber.tag(TAG).v("getAppConfig()") - withContext(context = scope.coroutineContext + dispatcherProvider.IO) { - source.retrieveConfig().also { - currentConfigInternal.emit(it) + val currentConfig: Flow<ConfigData> = configHolder.data + + suspend fun getAppConfig(): ConfigData { + // Switch scope so the app config can't get canceled due to unsubscription, + // we'd still like to have that new config in any case. + val deferred = scope.async(context = dispatcherProvider.IO) { + configHolder.updateBlocking { + source.retrieveConfig() } } + return deferred.await() + } + + suspend fun clear() { + Timber.tag(TAG).v("clear()") + source.clear() } companion object { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt index fd31afca8ef1e6ce15beef7927dab09473d752ca..fa981d6dc18048fd2a70a74e5bc42672db1f5bff 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt @@ -2,10 +2,12 @@ package de.rki.coronawarnapp.appconfig import de.rki.coronawarnapp.appconfig.download.AppConfigServer import de.rki.coronawarnapp.appconfig.download.AppConfigStorage -import de.rki.coronawarnapp.appconfig.download.ApplicationConfigurationInvalidException +import de.rki.coronawarnapp.appconfig.download.DefaultAppConfigSource import de.rki.coronawarnapp.appconfig.mapping.ConfigParser import de.rki.coronawarnapp.util.coroutine.DispatcherProvider import kotlinx.coroutines.withContext +import org.joda.time.Duration +import org.joda.time.Instant import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -15,6 +17,7 @@ class AppConfigSource @Inject constructor( private val server: AppConfigServer, private val storage: AppConfigStorage, private val parser: ConfigParser, + private val defaultAppConfig: DefaultAppConfigSource, private val dispatcherProvider: DispatcherProvider ) { @@ -36,7 +39,8 @@ class AppConfigSource @Inject constructor( mappedConfig = it, serverTime = configDownload.serverTime, localOffset = configDownload.localOffset, - isFallback = false + identifier = configDownload.etag, + configType = ConfigData.Type.FROM_SERVER ) } } catch (e: Exception) { @@ -53,7 +57,8 @@ class AppConfigSource @Inject constructor( mappedConfig = parser.parse(it.rawData), serverTime = it.serverTime, localOffset = it.localOffset, - isFallback = true + identifier = it.etag, + configType = ConfigData.Type.LAST_RETRIEVED ) } } catch (e: Exception) { @@ -64,7 +69,14 @@ class AppConfigSource @Inject constructor( } if (parsedConfig == null) { - throw ApplicationConfigurationInvalidException(serverError) + Timber.tag(TAG).w("Current or fallback config was unavailable, using default.") + parsedConfig = DefaultConfigData( + mappedConfig = parser.parse(defaultAppConfig.getRawDefaultConfig()), + serverTime = Instant.EPOCH, + localOffset = Duration.standardHours(12), + identifier = "fallback.local", + configType = ConfigData.Type.LOCAL_DEFAULT + ) } return@withContext parsedConfig diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt index 6845e3d29be4db488fb1fe29e5bfdd9102040672..e903926da969d2243dc02bc4332f41d6aff51f02 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt @@ -6,19 +6,42 @@ import org.joda.time.Instant interface ConfigData : ConfigMapping { + /** + * A unique value to identify this app config by. + * When this value changes, the app config has changed. + */ + val identifier: String + /** * serverTime + localOffset = updatedAt */ val updatedAt: Instant /** - * If **[isFallback]** returns true, + * If **[configType]** is not **[Type.FROM_SERVER]**, * you should probably ignore the time offset. */ val localOffset: Duration /** - * Returns true if this is not a fresh config, e.g. server could not be reached. + * Returns the type config this is. */ - val isFallback: Boolean + val configType: Type + + enum class Type { + /** + * Fresh one from a server. + */ + FROM_SERVER, + + /** + * Server config locally stored. + */ + LAST_RETRIEVED, + + /** + * Last resort, default config shipped with the app. + */ + LOCAL_DEFAULT + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt index 5fc918d095771bf9df33b81ec5cf591595ed46b5..d2ab41944ce8533f43ab7a82be7421899f823790 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt @@ -7,8 +7,9 @@ import org.joda.time.Instant data class DefaultConfigData( val serverTime: Instant, val mappedConfig: ConfigMapping, + override val identifier: String, override val localOffset: Duration, - override val isFallback: Boolean + override val configType: ConfigData.Type ) : ConfigData, ConfigMapping by mappedConfig { override val updatedAt: Instant = serverTime.plus(localOffset) } 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 5281c51ede2e8a7d12d55e56be4ccb92b8155031..be47c2f17b4d1834f1de0d0370eb2e4918138de5 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 d82a6cd3a3ecc741986426e6de9013ee8a3626f6..3c0a31697f3e0e87529481961e4c98943b3dc1ed 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 revokedDayPackages: Collection<RevokedKeyPackage.Day> + + val revokedHourPackages: Collection<RevokedKeyPackage.Hour> + + interface RevokedKeyPackage { + val etag: String + val region: LocationCode + + interface Day : RevokedKeyPackage { + val day: LocalDate + } + + interface Hour : Day, RevokedKeyPackage { + val hour: LocalTime + } + } interface Mapper : ConfigMapper<KeyDownloadConfig> } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt index 1d707c26bb248982443582fdba7ed672c640fe1e..7d174ac15251e8ca5f928e8b135f1985f32050bd 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt @@ -7,6 +7,7 @@ import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.ZipHelper.readIntoMap import de.rki.coronawarnapp.util.ZipHelper.unzip +import de.rki.coronawarnapp.util.retrofit.etag import de.rki.coronawarnapp.util.security.VerificationKeys import okhttp3.Cache import org.joda.time.Duration @@ -33,9 +34,6 @@ class AppConfigServer @Inject constructor( val response = api.get().getApplicationConfiguration(homeCountry.identifier) if (!response.isSuccessful) throw HttpException(response) - // If this is a cached response, we need the original timestamp to calculate the time offset - val localTime = response.getCacheTimestamp() ?: timeStamper.nowUTC - val rawConfig = with( requireNotNull(response.body()) { "Response was successful but body was null" } ) { @@ -55,12 +53,20 @@ class AppConfigServer @Inject constructor( exportBinary } + // If this is a cached response, we need the original timestamp to calculate the time offset + val localTime = response.getCacheTimestamp() ?: timeStamper.nowUTC + + // Shouldn't happen, but hey ¯\_(ツ)_/¯ + val etag = + response.headers().etag() ?: throw ApplicationConfigurationInvalidException(message = "Server has no ETAG.") + val serverTime = response.getServerDate() ?: localTime val offset = Duration(serverTime, localTime) Timber.tag(TAG).v("Time offset was %dms", offset.millis) return ConfigDownload( rawData = rawConfig, + etag = etag, serverTime = serverTime, localOffset = offset ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt index c5a86a92887ab5c26dd0afe1dd54ee4220186e6f..1d939896ce9e0b2f7eadf831cb40586ac73ce50b 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt @@ -47,7 +47,8 @@ class AppConfigStorage @Inject constructor( ConfigDownload( rawData = legacyConfigFile.readBytes(), serverTime = timeStamper.nowUTC, - localOffset = Duration.ZERO + localOffset = Duration.ZERO, + etag = "legacy.migration" ) } catch (e: Exception) { Timber.e(e, "Legacy config exits but couldn't be read.") diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt index 1f9ba9050b61f2a5d802e2f5d692cdeba3881369..d6114e5a89fa050b3773d9263efbfa5a1801d552 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt @@ -6,6 +6,7 @@ import org.joda.time.Instant data class ConfigDownload( @SerializedName("rawData") val rawData: ByteArray, + @SerializedName("etag") val etag: String, @SerializedName("serverTime") val serverTime: Instant, @SerializedName("localOffset") val localOffset: Duration ) { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSource.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSource.kt new file mode 100644 index 0000000000000000000000000000000000000000..ddb4e4528de00d44c08b7ba6e0910371c2ab9916 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSource.kt @@ -0,0 +1,16 @@ +package de.rki.coronawarnapp.appconfig.download + +import android.content.Context +import dagger.Reusable +import de.rki.coronawarnapp.util.di.AppContext +import javax.inject.Inject + +@Reusable +class DefaultAppConfigSource @Inject constructor( + @AppContext private val context: Context +) { + + fun getRawDefaultConfig(): ByteArray { + return context.assets.open("default_app_config.bin").readBytes() + } +} 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 752f41cb176c35ca3ba562185dcf172548e098d1..0000000000000000000000000000000000000000 --- 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 c010e25af42a0d9b70693ae7522488c33d5bcc4c..92473af001e20e37facf9758760eb9724195c0b3 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 0000000000000000000000000000000000000000..4c55393ec77de81c5d97d75e2804218d419fbcf6 --- /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(), + revokedDayPackages = rawParameters.mapDayEtags(), + revokedHourPackages = 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<RevokedKeyPackage.Day> = + this.revokedDayPackagesList.mapNotNull { + try { + RevokedKeyPackage.Day( + etag = it.etag, + region = LocationCode(it.region), + day = LocalDate.parse(it.date, DAY_FORMATTER) + ) + } catch (e: Exception) { + Timber.e(e, "Failed to parse revoked day metadata: %s", it) + null + } + } + + private fun KeyDownloadParametersAndroid.mapHourEtags(): List<RevokedKeyPackage.Hour> = + this.revokedHourPackagesList.mapNotNull { + try { + RevokedKeyPackage.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 revoked hour metadata: %s", it) + null + } + } + + data class KeyDownloadConfigContainer( + override val individualDownloadTimeout: Duration, + override val overallDownloadTimeout: Duration, + override val revokedDayPackages: Collection<KeyDownloadConfig.RevokedKeyPackage.Day>, + override val revokedHourPackages: Collection<KeyDownloadConfig.RevokedKeyPackage.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 RevokedKeyPackage : KeyDownloadConfig.RevokedKeyPackage { + + data class Day( + override val etag: String, + override val region: LocationCode, + override val day: LocalDate + ) : RevokedKeyPackage(), KeyDownloadConfig.RevokedKeyPackage.Day + + data class Hour( + override val etag: String, + override val region: LocationCode, + override val day: LocalDate, + override val hour: LocalTime + ) : RevokedKeyPackage(), KeyDownloadConfig.RevokedKeyPackage.Hour +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt index d3ae63b5936f0cb30d67f227d1640aef9603d987..31913f27d014b3340462b4271e205d07c03b9c90 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/BugReporter.kt @@ -1,12 +1,17 @@ package de.rki.coronawarnapp.bugreporting +import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.di.AppInjector +import timber.log.Timber interface BugReporter { fun report(throwable: Throwable, tag: String? = null, info: String? = null) } fun Throwable.reportProblem(tag: String? = null, info: String? = null) { + Timber.tag("BugReporter").v(this, "report(tag=$tag, info=$info)") + + if (CWADebug.isAUnitTest) return val reporter = AppInjector.component.bugReporter reporter.report(this, tag, info) } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..039330a1ca998c927852a16cf9d0a0465b072503 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorker.kt @@ -0,0 +1,44 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory +import de.rki.coronawarnapp.worker.BackgroundConstants +import timber.log.Timber + +/** + * One time background deadman notification worker + * + * @see DeadmanNotificationScheduler + */ +class DeadmanNotificationOneTimeWorker @AssistedInject constructor( + @Assisted val context: Context, + @Assisted workerParams: WorkerParameters, + private val sender: DeadmanNotificationSender +) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + Timber.d("Background job started. Run attempt: $runAttemptCount") + + if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) { + Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling") + + return Result.failure() + } + var result = Result.success() + try { + sender.sendNotification() + } catch (e: Exception) { + result = Result.retry() + } + + return result + } + + @AssistedInject.Factory + interface Factory : InjectedWorkerFactory<DeadmanNotificationOneTimeWorker> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..53e5d06ca010c8e45f40be61ab9219f0141d0381 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorker.kt @@ -0,0 +1,46 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.squareup.inject.assisted.Assisted +import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory +import de.rki.coronawarnapp.worker.BackgroundConstants +import timber.log.Timber + +/** + * Periodic background deadman notification worker + * + * @see DeadmanNotificationScheduler + */ +class DeadmanNotificationPeriodicWorker @AssistedInject constructor( + @Assisted val context: Context, + @Assisted workerParams: WorkerParameters, + private val scheduler: DeadmanNotificationScheduler +) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result { + Timber.d("Background job started. Run attempt: $runAttemptCount") + + if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) { + Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling") + + return Result.failure() + } + var result = Result.success() + try { + // Schedule one time deadman notification send work + scheduler.scheduleOneTime() + } catch (e: Exception) { + Timber.d(e) + result = Result.retry() + } + + return result + } + + @AssistedInject.Factory + interface Factory : InjectedWorkerFactory<DeadmanNotificationPeriodicWorker> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt new file mode 100644 index 0000000000000000000000000000000000000000..d3b1523be13e76e842a6536bedfbd18fc4e2bcca --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationScheduler.kt @@ -0,0 +1,59 @@ +package de.rki.coronawarnapp.deadman + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.WorkManager +import dagger.Reusable +import javax.inject.Inject + +@Reusable +class DeadmanNotificationScheduler @Inject constructor( + val timeCalculation: DeadmanNotificationTimeCalculation, + val workManager: WorkManager, + val workBuilder: DeadmanNotificationWorkBuilder +) { + + /** + * Enqueue background deadman notification onetime work + * Replace with new if older work exists. + */ + suspend fun scheduleOneTime() { + // Get initial delay + val delay = timeCalculation.getDelay() + + if (delay < 0) { + return + } else { + // Create unique work and enqueue + workManager.enqueueUniqueWork( + ONE_TIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + workBuilder.buildOneTimeWork(delay) + ) + } + } + + /** + * Enqueue background deadman notification onetime work + * Replace with new if older work exists. + */ + fun schedulePeriodic() { + // Create unique work and enqueue + workManager.enqueueUniquePeriodicWork( + PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + workBuilder.buildPeriodicWork() + ) + } + + companion object { + /** + * Deadman notification one time work + */ + const val ONE_TIME_WORK_NAME = "DeadmanNotificationOneTimeWork" + /** + * Deadman notification periodic work + */ + const val PERIODIC_WORK_NAME = "DeadmanNotificationPeriodicWork" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt new file mode 100644 index 0000000000000000000000000000000000000000..a261454f0bcf45edc674d58e539bc284fdf58edf --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSender.kt @@ -0,0 +1,73 @@ +package de.rki.coronawarnapp.deadman + +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import dagger.Reusable +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.notification.NotificationConstants +import de.rki.coronawarnapp.ui.main.MainActivity +import de.rki.coronawarnapp.util.ForegroundState +import de.rki.coronawarnapp.util.di.AppContext +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +@Reusable +class DeadmanNotificationSender @Inject constructor( + @AppContext private val context: Context, + private val foregroundState: ForegroundState, + private val notificationManagerCompat: NotificationManagerCompat +) { + + private val channelId = + context.getString(NotificationConstants.NOTIFICATION_CHANNEL_ID) + + private fun createPendingIntentToMainActivity() = + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + 0 + ) + + private fun buildNotification( + title: String, + content: String + ): Notification? { + val builder = NotificationCompat.Builder(context, + channelId + ) + .setSmallIcon(NotificationConstants.NOTIFICATION_SMALL_ICON) + .setPriority(NotificationCompat.PRIORITY_MAX) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(createPendingIntentToMainActivity()) + .setAutoCancel(true) + .setContentTitle(title) + .setContentText(content) + + return builder.build() + } + + suspend fun sendNotification() { + if (foregroundState.isInForeground.first()) { + return + } + val title = context.getString(R.string.risk_details_deadman_notification_title) + val content = context.getString(R.string.risk_details_deadman_notification_body) + val notification = + buildNotification(title, content) ?: return + with(notificationManagerCompat) { + notify(DEADMAN_NOTIFICATION_ID, notification) + } + } + + companion object { + /** + * Deadman notification id + */ + const val DEADMAN_NOTIFICATION_ID = 3 + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..48e6d3a8eba598fb4121e761684c7595230f544b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculation.kt @@ -0,0 +1,45 @@ +package de.rki.coronawarnapp.deadman + +import dagger.Reusable +import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.util.TimeStamper +import kotlinx.coroutines.flow.first +import org.joda.time.DateTimeConstants +import org.joda.time.Hours +import org.joda.time.Instant +import javax.inject.Inject + +@Reusable +class DeadmanNotificationTimeCalculation @Inject constructor( + val timeStamper: TimeStamper, + val enfClient: ENFClient +) { + + /** + * Calculate initial delay in minutes for deadman notification + */ + fun getHoursDiff(lastSuccess: Instant): Int { + val hoursDiff = Hours.hoursBetween(lastSuccess, timeStamper.nowUTC) + return (DEADMAN_NOTIFICATION_DELAY - hoursDiff.hours) * DateTimeConstants.MINUTES_PER_HOUR + } + + /** + * Get initial delay in minutes for deadman notification + * If last success date time is null (eg: on application first start) - return [DEADMAN_NOTIFICATION_DELAY] + */ + suspend fun getDelay(): Long { + val lastSuccess = enfClient.lastSuccessfulTrackedExposureDetection().first()?.finishedAt + return if (lastSuccess != null) { + getHoursDiff(lastSuccess).toLong() + } else { + (DEADMAN_NOTIFICATION_DELAY * DateTimeConstants.MINUTES_PER_HOUR).toLong() + } + } + + companion object { + /** + * Deadman notification background job delay set to 36 hours + */ + const val DEADMAN_NOTIFICATION_DELAY = 36 + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt new file mode 100644 index 0000000000000000000000000000000000000000..c574f5d761c3d8a613dc25ad5f235ef29ea9c2ea --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilder.kt @@ -0,0 +1,43 @@ +package de.rki.coronawarnapp.deadman + +import androidx.work.BackoffPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import dagger.Reusable +import de.rki.coronawarnapp.worker.BackgroundConstants +import org.joda.time.DateTimeConstants +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@Reusable +class DeadmanNotificationWorkBuilder @Inject constructor() { + + fun buildOneTimeWork(delay: Long): OneTimeWorkRequest = + OneTimeWorkRequestBuilder<DeadmanNotificationOneTimeWorker>() + .setInitialDelay( + delay, + TimeUnit.MINUTES + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BackgroundConstants.BACKOFF_INITIAL_DELAY, + TimeUnit.MINUTES + ) + .build() + + fun buildPeriodicWork(): PeriodicWorkRequest = PeriodicWorkRequestBuilder<DeadmanNotificationPeriodicWorker>( + DateTimeConstants.MINUTES_PER_HOUR.toLong(), TimeUnit.MINUTES + ) + .setInitialDelay( + BackgroundConstants.KIND_DELAY, + TimeUnit.MINUTES + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BackgroundConstants.BACKOFF_INITIAL_DELAY, + TimeUnit.MINUTES + ) + .build() +} 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 0000000000000000000000000000000000000000..a93bbcab0bfd6e6fc60c12d57a3bf1c9a34c63dd --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/BaseKeyPackageSyncTool.kt @@ -0,0 +1,109 @@ +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 +) { + + /** + * Returns true if any of our cached keys were revoked + */ + internal suspend fun revokeCachedKeys( + revokedKeyPackages: Collection<KeyDownloadConfig.RevokedKeyPackage> + ): Boolean { + if (revokedKeyPackages.isEmpty()) { + Timber.tag(tag).d("No revoked key packages to delete.") + return false + } + + val badEtags = revokedKeyPackages.map { it.etag } + val toDelete = keyCache.getAllCachedKeys().filter { badEtags.contains(it.info.etag) } + + return if (toDelete.isEmpty()) { + Timber.tag(tag).d("No local cached keys matched the revoked ones.") + false + } else { + Timber.tag(tag).w("Deleting revoked cached keys: %s", toDelete.joinToString("\n")) + keyCache.delete(toDelete.map { it.info }) + true + } + } + + 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 0000000000000000000000000000000000000000..9474d942db8ec51dbc6d405e493886940d2bde59 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncTool.kt @@ -0,0 +1,144 @@ +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() + val keysWereRevoked = revokeCachedKeys(downloadConfig.revokedDayPackages) + + val missingDays = targetLocations.mapNotNull { + determineMissingDayPackages(it, forceIndexLookup || keysWereRevoked) + } + 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)) { + Timber.tag(TAG).d("We don't expect new day packages.") + 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 552dc370b440abca3609f95419dc10db966cac08..870c80617d260aafb04eedc58c461b86ae003372 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() } @@ -208,19 +254,21 @@ class DownloadDiagnosisKeysTask @Inject constructor( ) : Task.Arguments data class Config( - @Suppress("MagicNumber") 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 0000000000000000000000000000000000000000..ad63bbb134ffc3583c9fea34bc4c31e1ee2cb724 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncTool.kt @@ -0,0 +1,172 @@ +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() + val keysWereRevoked = revokeCachedKeys(downloadConfig.revokedHourPackages) + + val missingHours = targetLocations.mapNotNull { + determineMissingHours(it, forceIndexLookup || keysWereRevoked) + } + 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)) { + Timber.tag(TAG).d("We don't expect new hour packages.") + 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 0000000000000000000000000000000000000000..c182eef184c624c3a18dc416b9469101730251e0 --- /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 60f6aca3482890c3a3148a2845f130293923d62e..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..46499c31230bf37e06c79f5c124a5e02f9f61215 --- /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 0000000000000000000000000000000000000000..9c8189c90e5015085a0b360b8d23999f8e662cd6 --- /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 6f58466cae1dd86d5200f342fb2793e6b31d7c4b..4eec1836296edc8c5dff6a5fe478ced80ec30a4d 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 258321c44c01b54b1c139d2c56700bc94942f2ec..575e48703aef949d33dbf0e0b187d896189bb81c 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 decf192f7e4e028a4e4845efcc6bd19ea0c09d31..bc73024a624b749f486912ecdd87f28fa27ae811 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 fb1dcbeebc0c6e027f36f3f2189a820e75ca2943..1310f2a0a8d12cd0e16d5ebb6b06a70538f9e47c 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 0000000000000000000000000000000000000000..bba9bcf20b96f0dcb34550f2eb744cab2489996c --- /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 b77f11c7be3f1f58959c1c7b19ba50414ea19724..9dd0b8ada11abec14be3e1a2f7740e5bc7715aca 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 ec23391b1878354a29a391cc3b30b111c3662563..f9f97faf09d59924c926bacf5a50de7ece041130 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 c6f42e84ed9982d3764161f5ebfb3f35babc161d..ed05838ea1a78efb009e643e30f8c024c22ead41 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/exception/reporting/ErrorReportReceiver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ErrorReportReceiver.kt index 8aafc6246c568100c2aa7b5eaaeb8942323d3c8e..ac820f405175ffe5ab05f0edd103926d2fa8f434 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ErrorReportReceiver.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ErrorReportReceiver.kt @@ -12,17 +12,12 @@ import timber.log.Timber import java.util.Locale class ErrorReportReceiver(private val activity: Activity) : BroadcastReceiver() { - companion object { - private val TAG: String = ErrorReportReceiver::class.java.simpleName - } + @Suppress("LongMethod") override fun onReceive(context: Context, intent: Intent) { val category = ExceptionCategory .valueOf(intent.getStringExtra(ReportingConstants.ERROR_REPORT_CATEGORY_EXTRA) ?: "") - val errorCode = intent.getIntExtra( - ReportingConstants.ERROR_REPORT_CODE_EXTRA, - ReportingConstants.ERROR_REPORT_UNKNOWN_ERROR - ) + val prefix = intent.getStringExtra(ReportingConstants.ERROR_REPORT_PREFIX_EXTRA) val suffix = intent.getStringExtra(ReportingConstants.ERROR_REPORT_SUFFIX_EXTRA) @@ -41,7 +36,7 @@ class ErrorReportReceiver(private val activity: Activity) : BroadcastReceiver() val confirm = context.resources.getString(R.string.errors_generic_button_positive) val details = context.resources.getString(R.string.errors_generic_button_negative) - var detailsTitle = context.resources.getString(R.string.errors_generic_details_headline) + val detailsTitle = context.resources.getString(R.string.errors_generic_details_headline) if (intent.hasExtra(ReportingConstants.ERROR_REPORT_API_EXCEPTION_CODE)) { val apiStatusCode = intent.getIntExtra( @@ -52,31 +47,44 @@ class ErrorReportReceiver(private val activity: Activity) : BroadcastReceiver() message += "#$apiStatusCode" } - val errorTitle = context.resources.getString(R.string.errors_generic_details_headline) - .toUpperCase(Locale.ROOT) - - if (CoronaWarnApplication.isAppInForeground) { - DialogHelper.showDialog( - DialogHelper.DialogInstance( - activity, - "$errorTitle: $errorCode\n$title", - message, - confirm, - details, - null, - {}, - { - DialogHelper.showDialog( - DialogHelper.DialogInstance( - activity, - title, - "$detailsTitle:\n$stack", - confirm - ) - ).run {} - } - )) + val dialogTitle = if (intent.hasExtra(ReportingConstants.ERROR_REPORT_TITLE_EXTRA)) { + intent.getStringExtra(ReportingConstants.ERROR_REPORT_TITLE_EXTRA) + } else { + val errorTitle = context.resources.getString(R.string.errors_generic_details_headline) + .toUpperCase(Locale.ROOT) + val errorCode = intent.getIntExtra( + ReportingConstants.ERROR_REPORT_CODE_EXTRA, + ReportingConstants.ERROR_REPORT_UNKNOWN_ERROR + ) + "$errorTitle: $errorCode\n$title" } + Timber.e("[$category]${(prefix ?: "")} $message${(suffix ?: "")}") + + if (!CoronaWarnApplication.isAppInForeground) { + Timber.v("Not displaying error dialog, not in foreground.") + return + } + + val dialogInstance = DialogHelper.DialogInstance( + context = activity, + title = dialogTitle, + message = message, + positiveButton = confirm, + negativeButton = details, + cancelable = null, + positiveButtonFunction = {}, + negativeButtonFunction = { + val stackTraceDialog = DialogHelper.DialogInstance( + activity, + title, + "$detailsTitle:\n$stack", + confirm + ) + DialogHelper.showDialog(stackTraceDialog.copy(isTextSelectable = true)) + Unit + } + ) + DialogHelper.showDialog(dialogInstance) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt index 8f602075d2af674dfa4b9853c9d9c22428e36777..e8b2ec5daa6317fe2db5d430567d63d143a0b6e5 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ExceptionReporter.kt @@ -10,7 +10,8 @@ import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.reporting.ReportingConstants.STATUS_CODE_GOOGLE_API_FAIL import de.rki.coronawarnapp.exception.reporting.ReportingConstants.STATUS_CODE_GOOGLE_UPDATE_NEEDED import de.rki.coronawarnapp.exception.reporting.ReportingConstants.STATUS_CODE_REACHED_REQUEST_LIMIT -import de.rki.coronawarnapp.util.tryFormattedError +import de.rki.coronawarnapp.util.tryHumanReadableError +import de.rki.coronawarnapp.util.CWADebug import java.io.PrintWriter import java.io.StringWriter @@ -22,14 +23,19 @@ fun Throwable.report( prefix: String?, suffix: String? ) { + if (CWADebug.isAUnitTest) return + reportProblem(tag = prefix, info = suffix) val context = CoronaWarnApplication.getAppContext() + val formattedError = this.tryHumanReadableError(context) + val intent = Intent(ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL) intent.putExtra(ReportingConstants.ERROR_REPORT_CATEGORY_EXTRA, exceptionCategory.name) intent.putExtra(ReportingConstants.ERROR_REPORT_PREFIX_EXTRA, prefix) intent.putExtra(ReportingConstants.ERROR_REPORT_SUFFIX_EXTRA, suffix) - intent.putExtra(ReportingConstants.ERROR_REPORT_MESSAGE_EXTRA, this.tryFormattedError(context)) + intent.putExtra(ReportingConstants.ERROR_REPORT_TITLE_EXTRA, formattedError.title) + intent.putExtra(ReportingConstants.ERROR_REPORT_MESSAGE_EXTRA, formattedError.description) if (this is ReportedExceptionInterface) { intent.putExtra(ReportingConstants.ERROR_REPORT_CODE_EXTRA, this.code) @@ -51,10 +57,7 @@ fun Throwable.report( errorMessage = R.string.errors_google_api_error } - intent.putExtra( - ReportingConstants.ERROR_REPORT_RES_ID, - errorMessage - ) + intent.putExtra(ReportingConstants.ERROR_REPORT_RES_ID, errorMessage) intent.putExtra(ReportingConstants.ERROR_REPORT_CODE_EXTRA, ErrorCodes.API_EXCEPTION.code) intent.putExtra(ReportingConstants.ERROR_REPORT_API_EXCEPTION_CODE, this.statusCode) } @@ -69,12 +72,3 @@ fun Throwable.report( intent.putExtra(ReportingConstants.ERROR_REPORT_STACK_EXTRA, stackExtra) LocalBroadcastManager.getInstance(context).sendBroadcast(intent) } - -fun reportGeneric( - stackString: String -) { - val intent = Intent(ReportingConstants.ERROR_REPORT_LOCAL_BROADCAST_CHANNEL) - intent.putExtra("category", ExceptionCategory.INTERNAL.name) - intent.putExtra("stack", stackString) - LocalBroadcastManager.getInstance(CoronaWarnApplication.getAppContext()).sendBroadcast(intent) -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ReportingConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ReportingConstants.kt index 16e021b5c85da260a870e0e2b0bfefdcbe63dcda..19475b1d64ebe1a0bbc5f43b02481d985a4ee714 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ReportingConstants.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/exception/reporting/ReportingConstants.kt @@ -5,6 +5,7 @@ object ReportingConstants { const val ERROR_REPORT_CATEGORY_EXTRA = "category" const val ERROR_REPORT_PREFIX_EXTRA = "prefix" const val ERROR_REPORT_SUFFIX_EXTRA = "suffix" + const val ERROR_REPORT_TITLE_EXTRA = "title" const val ERROR_REPORT_MESSAGE_EXTRA = "message" const val ERROR_REPORT_STACK_EXTRA = "stack" const val ERROR_REPORT_CODE_EXTRA = "code" 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 98914070b231d5485cd3b50f4302cff631e43345..c3de7abd0615b66f64d5eb6255c6d1c255e571e8 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 1bf6f5dd72da98edefc721583b8aa1327d77c73d..9d98d5b33bf809dbc19995310857d2cda775fcb6 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/InternalExposureNotificationPermissionHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationPermissionHelper.kt index 3468e94e6de747c6ab7089afb613765313691f1c..8bba58f42a35db70e0a5497d739d8588eabda020 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationPermissionHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/InternalExposureNotificationPermissionHelper.kt @@ -81,7 +81,6 @@ class InternalExposureNotificationPermissionHelper( * */ fun requestPermissionToShareKeys() { - host.viewLifecycleOwner.lifecycleScope.launch { try { val keys = InternalExposureNotificationClient.asyncGetTemporaryExposureKeyHistory() 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 30bbc1b25e9a1d1aede361fbb2e3f6ce11b2ab43..0000000000000000000000000000000000000000 --- 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 67% 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 c94eea81e2e5696c44fc8e76d80e837d02151c8d..c5e4b8073deb172dcb4856afb9d2406af38ef94d 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 @@ -9,6 +10,7 @@ import de.rki.coronawarnapp.util.mutate import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -21,35 +23,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.currentConfig.first().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 +79,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 +93,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 +110,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 +135,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 0000000000000000000000000000000000000000..4d9bcf0c6e318c96a314d71bdd0050f0ecf2bd88 --- /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 18fb150e31c1164039079bf0130f5037bdd2c533..01e93c4b9ab724a44dc953b0466c2b6a7b366c31 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 13779d6ba1d22c6f55cdef891ffa9c4d74590b7a..20fdddefe71875201448539b662f89f782f986dd 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/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt index bca8dfdd48723b685774623dfcd72733203aec66..59114d58d4c0bab4709d0568ffb39c052923913d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/DefaultDiagnosisKeyProvider.kt @@ -2,8 +2,10 @@ package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider +import com.google.android.gms.common.api.ApiException import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient +import de.rki.coronawarnapp.exception.reporting.ReportingConstants import de.rki.coronawarnapp.util.GoogleAPIVersion import timber.log.Timber import java.io.File @@ -101,6 +103,14 @@ class DefaultDiagnosisKeyProvider @Inject constructor( enfClient .provideDiagnosisKeys(keyFiles.toList(), configuration, token) .addOnSuccessListener { cont.resume(it) } - .addOnFailureListener { cont.resumeWithException(it) } + .addOnFailureListener { + val wrappedException = when { + it is ApiException && it.statusCode == ReportingConstants.STATUS_CODE_REACHED_REQUEST_LIMIT -> { + QuotaExceededException(cause = it) + } + else -> it + } + cont.resumeWithException(wrappedException) + } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/QuotaExceededException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/QuotaExceededException.kt new file mode 100644 index 0000000000000000000000000000000000000000..3a8f85a4aabe396bd7dd62b5be82c00973a154fe --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/nearby/modules/diagnosiskeyprovider/QuotaExceededException.kt @@ -0,0 +1,16 @@ +package de.rki.coronawarnapp.nearby.modules.diagnosiskeyprovider + +import android.content.Context +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.util.HasHumanReadableError +import de.rki.coronawarnapp.util.HumanReadableError + +class QuotaExceededException( + cause: Throwable +) : IllegalStateException("Quota limit exceeded.", cause), HasHumanReadableError { + + override fun toHumanReadableError(context: Context): HumanReadableError = HumanReadableError( + title = context.getString(R.string.errors_risk_detection_limit_reached_title), + description = context.getString(R.string.errors_risk_detection_limit_reached_description) + ) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationConstants.kt index 1aba38cf9b0fe897f7284960db0b74d6aded9cb3..01b785fe65070177f3bd47080b2fd1fbc0c3712e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationConstants.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationConstants.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.notification import de.rki.coronawarnapp.R +import org.joda.time.Duration /** * The notification constants are used inside the NotificationHelper @@ -9,6 +10,13 @@ import de.rki.coronawarnapp.R */ object NotificationConstants { + const val NOTIFICATION_ID = "NOTIFICATION_ID" + + const val POSITIVE_RESULT_NOTIFICATION_ID = 100 + const val POSITIVE_RESULT_NOTIFICATION_TOTAL_COUNT = 2 + val POSITIVE_RESULT_NOTIFICATION_INITIAL_OFFSET: Duration = Duration.standardHours(2) + val POSITIVE_RESULT_NOTIFICATION_INTERVAL: Duration = Duration.standardHours(2) + /** * Notification channel id String.xml path */ diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationHelper.kt index 1fd3465f14562f241e5e9ecdd1c1707ce5d05c2f..c642a8a4b7788ec131309bd8704549889abffebc 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationHelper.kt @@ -1,19 +1,25 @@ package de.rki.coronawarnapp.notification +import android.app.AlarmManager import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent +import android.app.PendingIntent.FLAG_CANCEL_CURRENT import android.content.Context import android.content.Intent import android.media.AudioAttributes import android.media.RingtoneManager import android.os.Build import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.PRIORITY_HIGH import androidx.core.app.NotificationManagerCompat import de.rki.coronawarnapp.BuildConfig import de.rki.coronawarnapp.CoronaWarnApplication +import de.rki.coronawarnapp.notification.NotificationConstants.NOTIFICATION_ID import de.rki.coronawarnapp.ui.main.MainActivity +import org.joda.time.Duration +import org.joda.time.Instant import timber.log.Timber import kotlin.random.Random @@ -26,8 +32,6 @@ import kotlin.random.Random */ object NotificationHelper { - private val TAG: String? = NotificationHelper::class.simpleName - /** * Notification channel id * @@ -82,6 +86,37 @@ object NotificationHelper { } } + fun cancelFutureNotifications(notificationId: Int) { + val pendingIntent = createPendingIntentToScheduleNotification(notificationId) + val manager = + CoronaWarnApplication.getAppContext().getSystemService(Context.ALARM_SERVICE) as AlarmManager + manager.cancel(pendingIntent) + Timber.v("Canceled future notifications with id: %s", notificationId) + } + + fun scheduleRepeatingNotification( + initialTime: Instant, + interval: Duration, + notificationId: NotificationId + ) { + val pendingIntent = createPendingIntentToScheduleNotification(notificationId) + val manager = + CoronaWarnApplication.getAppContext().getSystemService(Context.ALARM_SERVICE) as AlarmManager + manager.setInexactRepeating(AlarmManager.RTC, initialTime.millis, interval.millis, pendingIntent) + } + + private fun createPendingIntentToScheduleNotification( + notificationId: NotificationId, + flag: Int = FLAG_CANCEL_CURRENT + ) = + PendingIntent.getBroadcast( + CoronaWarnApplication.getAppContext(), + notificationId, + Intent(CoronaWarnApplication.getAppContext(), NotificationReceiver::class.java).apply { + putExtra(NOTIFICATION_ID, notificationId) + }, + flag) + /** * Build notification * Create notification with defined title, content text and visibility. @@ -98,13 +133,14 @@ object NotificationHelper { title: String, content: String, visibility: Int, - expandableLongText: Boolean = false + expandableLongText: Boolean = false, + pendingIntent: PendingIntent = createPendingIntentToMainActivity() ): Notification? { val builder = NotificationCompat.Builder(CoronaWarnApplication.getAppContext(), channelId) .setSmallIcon(NotificationConstants.NOTIFICATION_SMALL_ICON) .setPriority(NotificationCompat.PRIORITY_MAX) .setVisibility(visibility) - .setContentIntent(createPendingIntentToMainActivity()) + .setContentIntent(pendingIntent) .setAutoCancel(true) if (expandableLongText) { @@ -154,17 +190,22 @@ object NotificationHelper { * @param title: String * @param content: String * @param visibility: Int + * @param expandableLongText: Boolean + * @param notificationId: NotificationId + * @param pendingIntent: PendingIntent */ + fun sendNotification( title: String, content: String, - visibility: Int, - expandableLongText: Boolean = false + expandableLongText: Boolean = false, + notificationId: NotificationId = Random.nextInt(), + pendingIntent: PendingIntent = createPendingIntentToMainActivity() ) { val notification = - buildNotification(title, content, visibility, expandableLongText) ?: return + buildNotification(title, content, PRIORITY_HIGH, expandableLongText, pendingIntent) ?: return with(NotificationManagerCompat.from(CoronaWarnApplication.getAppContext())) { - notify(Random.nextInt(), notification) + notify(notificationId, notification) } } @@ -174,11 +215,10 @@ object NotificationHelper { * Notification is only sent if app is not in foreground. * * @param content: String - * @param visibility: Int */ - fun sendNotification(content: String, visibility: Int) { + fun sendNotification(content: String) { if (!CoronaWarnApplication.isAppInForeground) { - sendNotification("", content, visibility, true) + sendNotification("", content, true) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationReceiver.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationReceiver.kt new file mode 100644 index 0000000000000000000000000000000000000000..3d2b6970a8a7eb31de6f3258fd17f4088eb538a8 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/NotificationReceiver.kt @@ -0,0 +1,33 @@ +package de.rki.coronawarnapp.notification + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import dagger.android.AndroidInjection +import de.rki.coronawarnapp.notification.NotificationConstants.NOTIFICATION_ID +import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_ID +import timber.log.Timber +import javax.inject.Inject + +typealias NotificationId = Int + +class NotificationReceiver : BroadcastReceiver() { + + @Inject lateinit var testResultNotificationService: TestResultNotificationService + + override fun onReceive(context: Context, intent: Intent) { + AndroidInjection.inject(this, context) + when (val notificationId = intent.getIntExtra(NOTIFICATION_ID, Int.MIN_VALUE)) { + POSITIVE_RESULT_NOTIFICATION_ID -> { + Timber.tag(TAG).v("NotificationReceiver received intent to show a positive test result notification") + testResultNotificationService.showPositiveTestResultNotification(notificationId) + } + else -> + Timber.tag(TAG).d("NotificationReceiver received an undefined notificationId: %s", notificationId) + } + } + + companion object { + private val TAG: String? = NotificationReceiver::class.simpleName + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/TestResultNotificationService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/TestResultNotificationService.kt new file mode 100644 index 0000000000000000000000000000000000000000..b435c2ff87d82e4c5eac5125ab2ab86d4d57be0f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/notification/TestResultNotificationService.kt @@ -0,0 +1,66 @@ +package de.rki.coronawarnapp.notification + +import android.content.Context +import androidx.navigation.NavDeepLinkBuilder +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_ID +import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_INITIAL_OFFSET +import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_INTERVAL +import de.rki.coronawarnapp.notification.NotificationConstants.POSITIVE_RESULT_NOTIFICATION_TOTAL_COUNT +import de.rki.coronawarnapp.storage.LocalData +import de.rki.coronawarnapp.ui.main.MainActivity +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.di.AppContext +import timber.log.Timber +import javax.inject.Inject + +class TestResultNotificationService @Inject constructor( + @AppContext private val context: Context, + private val timeStamper: TimeStamper +) { + + fun schedulePositiveTestResultReminder() { + if (LocalData.numberOfRemainingPositiveTestResultReminders < 0) { + Timber.v("Schedule positive test result notification") + LocalData.numberOfRemainingPositiveTestResultReminders = POSITIVE_RESULT_NOTIFICATION_TOTAL_COUNT + NotificationHelper.scheduleRepeatingNotification( + timeStamper.nowUTC.plus(POSITIVE_RESULT_NOTIFICATION_INITIAL_OFFSET), + POSITIVE_RESULT_NOTIFICATION_INTERVAL, + POSITIVE_RESULT_NOTIFICATION_ID + ) + } else { + Timber.v("Positive test result notification has already been scheduled") + } + } + + fun showPositiveTestResultNotification(notificationId: Int) { + if (LocalData.numberOfRemainingPositiveTestResultReminders > 0) { + LocalData.numberOfRemainingPositiveTestResultReminders -= 1 + val pendingIntent = NavDeepLinkBuilder(context) + .setGraph(R.navigation.nav_graph) + .setComponentName(MainActivity::class.java) + .setDestination(R.id.submissionResultFragment) + .createPendingIntent() + + NotificationHelper.sendNotification( + title = context.getString(R.string.notification_headline_share_positive_result), + content = context.getString(R.string.notification_body_share_positive_result), + notificationId = notificationId, + pendingIntent = pendingIntent + ) + } else { + NotificationHelper.cancelFutureNotifications(notificationId) + } + } + + fun cancelPositiveTestResultNotification() { + NotificationHelper.cancelFutureNotifications(POSITIVE_RESULT_NOTIFICATION_ID) + Timber.v("Future positive test result notifications have been canceled") + } + + fun resetPositiveTestResultNotification() { + cancelPositiveTestResultNotification() + LocalData.numberOfRemainingPositiveTestResultReminders = Int.MIN_VALUE + Timber.v("Positive test result notification counter has been reset") + } +} 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 314a944a7aa0dd3cb775272be6f8e687a8b9f19f..dab0da7f05f50d7b6b75c1e4d81d2187113f4c00 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/receiver/ReceiverBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt index 676692bdbc9f96d1eab17e557adb44f125d38a5c..3f509e47e794d23a989136c71ba17f056c959f4a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/receiver/ReceiverBinder.kt @@ -2,10 +2,13 @@ package de.rki.coronawarnapp.receiver import dagger.Module import dagger.android.ContributesAndroidInjector +import de.rki.coronawarnapp.notification.NotificationReceiver @Module internal abstract class ReceiverBinder { @ContributesAndroidInjector internal abstract fun exposureUpdateReceiver(): ExposureStateUpdateReceiver + @ContributesAndroidInjector + internal abstract fun notificationReceiver(): NotificationReceiver } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt index 4df5b3228daba8b66f098621e61dc89a8ff73ec1..68d9b8b15df9a37596fab84e1438c6272dc14954 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/risk/DefaultRiskLevels.kt @@ -1,7 +1,7 @@ package de.rki.coronawarnapp.risk import androidx.annotation.VisibleForTesting -import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import com.google.android.gms.nearby.exposurenotification.ExposureSummary import de.rki.coronawarnapp.CoronaWarnApplication import de.rki.coronawarnapp.R @@ -196,19 +196,30 @@ class DefaultRiskLevels @Inject constructor( @VisibleForTesting internal fun updateRiskLevelScore(riskLevel: RiskLevel) { val lastCalculatedScore = RiskLevelRepository.getLastCalculatedScore() + Timber.d("last CalculatedS core is ${lastCalculatedScore.raw} and Current Risk Level is ${riskLevel.raw}") + if (RiskLevel.riskLevelChangedBetweenLowAndHigh( lastCalculatedScore, riskLevel ) && !LocalData.submissionWasSuccessful() ) { + Timber.d( + "Notification Permission = ${ + NotificationManagerCompat.from(CoronaWarnApplication.getAppContext()).areNotificationsEnabled() + }" + ) + NotificationHelper.sendNotification( - CoronaWarnApplication.getAppContext().getString(R.string.notification_body), - NotificationCompat.PRIORITY_HIGH + CoronaWarnApplication.getAppContext().getString(R.string.notification_body) ) + + Timber.d("Risk level changed and notification sent. Current Risk level is ${riskLevel.raw}") } if (lastCalculatedScore.raw == RiskLevelConstants.INCREASED_RISK && riskLevel.raw == RiskLevelConstants.LOW_LEVEL_RISK) { LocalData.isUserToBeNotifiedOfLoweredRiskLevel = true + + Timber.d("Risk level changed LocalData is updated. Current Risk level is ${riskLevel.raw}") } RiskLevelRepository.setRiskLevelScore(riskLevel) } 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 407fef631dd901e8995d64cff3047acc22beb5c6..81959b959a242b1a4640ffd23c399780f48cea1a 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.") @@ -150,7 +146,6 @@ class RiskLevelTask @Inject constructor( data class Config( // TODO unit-test that not > 9 min - @Suppress("MagicNumber") override val executionTimeout: Duration = Duration.standardMinutes(8), override val collisionBehavior: TaskFactory.Config.CollisionBehavior = @@ -162,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/InsufficientStorageException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/InsufficientStorageException.kt index bb38a584438e3e1078078684667e7fa96da905d2..b2203bc075fc462b1c8429b53c3ccadbd2e895b2 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/InsufficientStorageException.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/InsufficientStorageException.kt @@ -2,19 +2,23 @@ package de.rki.coronawarnapp.storage import android.content.Context import android.text.format.Formatter -import de.rki.coronawarnapp.util.FormattedError +import de.rki.coronawarnapp.util.HasHumanReadableError +import de.rki.coronawarnapp.util.HumanReadableError import java.io.IOException class InsufficientStorageException( val result: DeviceStorage.CheckResult ) : IOException( "Not enough free space: ${result.requiredBytes}B are required and only ${result.freeBytes}B are available." -), FormattedError { +), HasHumanReadableError { - override fun getFormattedError(context: Context): String { + override fun toHumanReadableError(context: Context): HumanReadableError { val formattedRequired = Formatter.formatShortFileSize(context, result.requiredBytes) val formattedFree = Formatter.formatShortFileSize(context, result.freeBytes) // TODO Replace with localized message when the exception is logged via new error tracking. - return "Not enough free space: $formattedRequired are required and only $formattedFree are available." + return HumanReadableError( + description = "Not enough free space: $formattedRequired are required " + + "and only $formattedFree are available." + ) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt index d3f73dff85afeb4999f3a293680176d75e1da8b9..04b08cdd10b8ef86ebcb046d27c86077fdc6fbf9 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/LocalData.kt @@ -507,6 +507,13 @@ object LocalData { isNotificationsTestEnabledFlowInternal.value = value } + private const val PKEY_POSITIVE_TEST_RESULT_REMINDER_COUNT = "preference_positive_test_result_reminder_count" + var numberOfRemainingPositiveTestResultReminders: Int + get() = getSharedPreferenceInstance().getInt(PKEY_POSITIVE_TEST_RESULT_REMINDER_COUNT, Int.MIN_VALUE) + set(value) = getSharedPreferenceInstance().edit(true) { + putInt(PKEY_POSITIVE_TEST_RESULT_REMINDER_COUNT, value) + } + /** * Gets the decision if background jobs are enabled * diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt index ed9100cda73ba3dad4bf70ede1eda68443f3dd14..b601e6d77475a9a9a2e1bd665c0f5ba50d8d58ff 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/SubmissionRepository.kt @@ -108,6 +108,11 @@ object SubmissionRepository { } } + fun reset() { + uiStateStateFlowInternal.value = ApiRequestState.IDLE + deviceUIStateFlowInternal.value = DeviceUIState.UNPAIRED + } + // TODO this should be more UI agnostic private suspend fun refreshUIState(refreshTestResult: Boolean) { var uiState = DeviceUIState.UNPAIRED 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 11152b8ff33380629b06d97205a1ba66e8bc30f0..261fd5fcacf20a08176f1db87dbc42bb1e44b8a1 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 86baa1239d015ffc5bddc26f18034b7cd98e9523..0658b10604134435f471c5ef6f351372f18bdcb0 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/DaysSinceOnsetOfSymptomsVectorDeterminator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt index 037dfc69052e4aa8674fde07ce589b8b2ce04f70..f37199188d60f902be1cd58ae7dab5a976f570f0 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/DaysSinceOnsetOfSymptomsVectorDeterminator.kt @@ -16,7 +16,6 @@ class DaysSinceOnsetOfSymptomsVectorDeterminator @Inject constructor( private val timeStamper: TimeStamper ) { - @Suppress("MagicNumber") internal fun determine(symptoms: Symptoms): DaysSinceOnsetOfSymptomsVector { return when (symptoms.symptomIndication) { Symptoms.Indication.POSITIVE -> @@ -28,7 +27,6 @@ class DaysSinceOnsetOfSymptomsVectorDeterminator @Inject constructor( } } - @Suppress("MagicNumber") private fun determinePositiveIndication(symptoms: Symptoms): DaysSinceOnsetOfSymptomsVector { return when (symptoms.startOfSymptoms) { is Symptoms.StartOf.Date -> createDaysSinceOnsetOfSymptomsVectorWith( 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 7b605f10eaed4cc0b218eb2819301e931d15c7a3..2bff73211488afcd7022423df0d5200583b2406e 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 @@ -85,7 +85,6 @@ class SubmissionTask @Inject constructor( ) : Task.Arguments data class Config( - @Suppress("MagicNumber") override val executionTimeout: Duration = Duration.standardMinutes(8), // TODO unit-test that not > 9 min override val collisionBehavior: TaskFactory.Config.CollisionBehavior = @@ -97,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/submission/TransmissionRiskVectorDeterminator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/TransmissionRiskVectorDeterminator.kt index fc87a8785cf97ffd3adbe18d82e4a04aa626d3ee..20d4eb3aa94da8c8aa225431135cf45848ae6351 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/TransmissionRiskVectorDeterminator.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/submission/TransmissionRiskVectorDeterminator.kt @@ -14,7 +14,6 @@ class TransmissionRiskVectorDeterminator @Inject constructor( private val timeStamper: TimeStamper ) { - @Suppress("MagicNumber") fun determine(symptoms: Symptoms, now: LocalDate = timeStamper.nowUTC.toLocalDate()) = TransmissionRiskVector( when (symptoms.symptomIndication) { Indication.POSITIVE -> when (symptoms.startOfSymptoms) { 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 77e22394784193a235c6a0793ebedf78d2ff5231..932fb9bebc875c253a4e8bbae26b8bbbb5f91c7c 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 @@ -1,6 +1,9 @@ package de.rki.coronawarnapp.task import androidx.annotation.VisibleForTesting +import de.rki.coronawarnapp.bugreporting.reportProblem +import de.rki.coronawarnapp.exception.ExceptionCategory +import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.task.TaskFactory.Config.CollisionBehavior import de.rki.coronawarnapp.task.internal.InternalTaskState import de.rki.coronawarnapp.util.TimeStamper @@ -86,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) { @@ -147,6 +150,8 @@ class TaskController @Inject constructor( state.job.getCompleted() } else { Timber.tag(TAG).e(error, "Task failed: %s", state) + error.report(ExceptionCategory.INTERNAL) + error.reportProblem(tag = state.request.type.simpleName) null } 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 8c694e86f93e5b7450c8ea5ca2c1480f7ce7ca44..652bf1b58f63604bcc2aaa5cd17f895ea7f55ac4 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 88618d3ed747e42b5980e61542634a22bca4b91d..d34f0e8890919b779fb7a51dc598213b1a255f5b 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 @@ -15,7 +15,6 @@ import java.util.UUID import javax.inject.Inject import javax.inject.Provider -@Suppress("MagicNumber") open class QueueingTask @Inject constructor() : Task<DefaultProgress, QueueingTask.Result> { private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>() @@ -72,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/transaction/TransactionCoroutineScope.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/DefaultTaskCoroutineScope.kt similarity index 75% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/TransactionCoroutineScope.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/DefaultTaskCoroutineScope.kt index 0e27712b8af6d356585d87e67c6576dec91421ff..c10a3c04117bfa1fb76c1cf3da536a8b8684ffab 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/TransactionCoroutineScope.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/DefaultTaskCoroutineScope.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.transaction +package de.rki.coronawarnapp.task.internal import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -8,6 +8,6 @@ import javax.inject.Singleton import kotlin.coroutines.CoroutineContext @Singleton -class TransactionCoroutineScope @Inject constructor() : CoroutineScope { +class DefaultTaskCoroutineScope @Inject constructor() : CoroutineScope { override val coroutineContext: CoroutineContext = SupervisorJob() + Dispatchers.Default } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/TaskModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/TaskModule.kt index c94f6beb9e882a0cf1cd9c44374ba40637fa4333..f0169422b448ae4e1153a12fda48c7ff21f89b6d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/TaskModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/task/internal/TaskModule.kt @@ -4,7 +4,6 @@ import dagger.Module import dagger.Provides import de.rki.coronawarnapp.task.TaskCoroutineScope import de.rki.coronawarnapp.task.example.QueueingTaskModule -import de.rki.coronawarnapp.transaction.TransactionCoroutineScope import kotlinx.coroutines.CoroutineScope import javax.inject.Singleton @@ -17,5 +16,5 @@ class TaskModule { @Provides @Singleton @TaskCoroutineScope - fun provideScope(scope: TransactionCoroutineScope): CoroutineScope = scope + fun provideScope(scope: DefaultTaskCoroutineScope): CoroutineScope = scope } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt index 4b07eea491c78a3135f3bd0407a1bc041ebec75e..efb78a5cbf81f040fe8b6cac1c78e64785a86303 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivity.kt @@ -15,6 +15,7 @@ import dagger.android.AndroidInjector import dagger.android.DispatchingAndroidInjector import dagger.android.HasAndroidInjector import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler import de.rki.coronawarnapp.playbook.BackgroundNoise import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.ui.base.startActivitySafely @@ -63,11 +64,11 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector { private lateinit var settingsViewModel: SettingsViewModel - @Inject - lateinit var backgroundPrioritization: BackgroundPrioritization + @Inject lateinit var backgroundPrioritization: BackgroundPrioritization - @Inject - lateinit var powerManagement: PowerManagement + @Inject lateinit var powerManagement: PowerManagement + + @Inject lateinit var deadmanScheduler: DeadmanNotificationScheduler /** * Register connection callback. @@ -105,6 +106,7 @@ class MainActivity : AppCompatActivity(), HasAndroidInjector { scheduleWork() checkShouldDisplayBackgroundWarning() doBackgroundNoiseCheck() + deadmanScheduler.schedulePeriodic() } private fun doBackgroundNoiseCheck() { 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 55e426d63f01557b91be18402f430778ec28678d..aba0e9850f7be7eb0f6945755d7259da9ff4d1b9 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 @@ -5,17 +5,20 @@ import android.os.Bundle import android.view.View import android.view.accessibility.AccessibilityEvent import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.FragmentHomeBinding 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 import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider import de.rki.coronawarnapp.util.viewmodel.cwaViewModels +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -35,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) @@ -91,14 +95,17 @@ class HomeFragment : Fragment(R.layout.fragment_home), AutoInject { } vm.showLoweredRiskLevelDialog.observe2(this) { - if (it) { showRiskLevelLoweredDialog() } + if (it) { + showRiskLevelLoweredDialog() + } } + + lifecycleScope.launch { vm.observeTestResultToSchedulePositiveTestResultReminder() } } 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/ui/main/home/HomeFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt index c50cee70f2a186980552bb1b43d562f93ff4bd0d..ea111127b91b13506bd3a057672c47a837e4d555 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt @@ -3,6 +3,7 @@ package de.rki.coronawarnapp.ui.main.home import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.notification.TestResultNotificationService import de.rki.coronawarnapp.risk.TimeVariables import de.rki.coronawarnapp.service.submission.SubmissionService import de.rki.coronawarnapp.storage.LocalData @@ -21,6 +22,7 @@ import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool 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.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.sample @@ -29,9 +31,10 @@ class HomeFragmentViewModel @AssistedInject constructor( private val errorResetTool: EncryptionErrorResetTool, tracingStatus: GeneralTracingStatus, tracingCardStateProvider: TracingCardStateProvider, - submissionCardsStateProvider: SubmissionCardsStateProvider, + private val submissionCardsStateProvider: SubmissionCardsStateProvider, val settingsViewModel: SettingsViewModel, - private val tracingRepository: TracingRepository + private val tracingRepository: TracingRepository, + private val testResultNotificationService: TestResultNotificationService ) : CWAViewModel( dispatcherProvider = dispatcherProvider, childViewModels = listOf(settingsViewModel) @@ -44,7 +47,6 @@ class HomeFragmentViewModel @AssistedInject constructor( val tracingCardState: LiveData<TracingCardState> = tracingCardStateProvider.state .asLiveData(dispatcherProvider.Default) - @Suppress("MagicNumber") val submissionCardState: LiveData<SubmissionCardState> = submissionCardsStateProvider.state .sample(150L) .asLiveData(dispatcherProvider.Default) @@ -74,6 +76,11 @@ class HomeFragmentViewModel @AssistedInject constructor( private var isLoweredRiskLevelDialogBeingShown = false + suspend fun observeTestResultToSchedulePositiveTestResultReminder() = + submissionCardsStateProvider.state + .first { it.isPositiveSubmissionCardVisible() } + .also { testResultNotificationService.schedulePositiveTestResultReminder() } + // TODO only lazy to keep tests going which would break because of LocalData access val showLoweredRiskLevelDialog: LiveData<Boolean> by lazy { LocalData diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt index 2b559cd3a7e0e3f5ff919070de566610b31811bc..a685bab2c0ea90d6d8ea1de895e38c40f8213928 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/settings/SettingsResetViewModel.kt @@ -5,6 +5,7 @@ import com.squareup.inject.assisted.AssistedInject import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient +import de.rki.coronawarnapp.notification.TestResultNotificationService import de.rki.coronawarnapp.ui.SingleLiveEvent import de.rki.coronawarnapp.util.DataReset import de.rki.coronawarnapp.util.coroutine.DispatcherProvider @@ -14,7 +15,8 @@ import de.rki.coronawarnapp.worker.BackgroundWorkScheduler class SettingsResetViewModel @AssistedInject constructor( dispatcherProvider: DispatcherProvider, - private val dataReset: DataReset + private val dataReset: DataReset, + private val testResultNotificationService: TestResultNotificationService ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { val clickEvent: SingleLiveEvent<SettingsEvents> = SingleLiveEvent() @@ -41,6 +43,7 @@ class SettingsResetViewModel @AssistedInject constructor( ExceptionCategory.EXPOSURENOTIFICATION, TAG, null ) } + testResultNotificationService.resetPositiveTestResultNotification() dataReset.clearAllLocalData() clickEvent.postValue(SettingsEvents.GoToOnboarding) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt index a9c953118a6983ab44db4e83d669d41185e25c00..7f95fa7c859f30d8b35da16d36e28aea968e2f1d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanFragment.kt @@ -40,7 +40,11 @@ class SubmissionTanFragment : Fragment(R.layout.fragment_submission_tan), AutoIn binding.uiState = it submission_tan_character_error.setGone(it.areCharactersCorrect) - submission_tan_error.setGone(it.isTanValidFormat) + if (it.isCorrectLength) { + submission_tan_error.setGone(it.isTanValid) + } else { + submission_tan_error.setGone(true) + } } binding.submissionTanContent.submissionTanInput.listener = { tan -> diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt index c947e09a91c7507945441f918e49abe8306814a2..28dbe39bc10303fb8df48d1c1458be736bde5f4f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModel.kt @@ -28,7 +28,8 @@ class SubmissionTanViewModel @AssistedInject constructor( UIState( isTanValid = currentTan.isTanValid, isTanValidFormat = currentTan.isTanValidFormat, - areCharactersCorrect = currentTan.areCharactersValid + areCharactersCorrect = currentTan.areCharactersValid, + isCorrectLength = currentTan.isCorrectLength ) }.asLiveData(context = dispatcherProvider.Default) @@ -73,7 +74,8 @@ class SubmissionTanViewModel @AssistedInject constructor( data class UIState( val isTanValid: Boolean = false, val areCharactersCorrect: Boolean = false, - val isTanValidFormat: Boolean = false + val isTanValidFormat: Boolean = false, + val isCorrectLength: Boolean = false ) @AssistedInject.Factory diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt index a4d883605477fbc1295047d10155bb8bb5305b22..8cfc0e09483da70ab08b16405b7a9aac1ce9bb34 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/tan/Tan.kt @@ -8,8 +8,9 @@ data class Tan( val value: String ) { + val isCorrectLength = value.length == MAX_LENGTH val areCharactersValid = allCharactersValid(value) - val isTanValidFormat = value.length == MAX_LENGTH && isChecksumValid(value) + val isTanValidFormat = isCorrectLength && isChecksumValid(value) val isTanValid = areCharactersValid && isTanValidFormat companion object { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt index 0655e3e5893e889f563f5b0b7361e3f9e4efcf0d..a1d42d3961c96b916e3498af53c1e9347c231ca8 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultFragment.kt @@ -6,6 +6,7 @@ import android.view.View import android.view.accessibility.AccessibilityEvent import androidx.activity.OnBackPressedCallback import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.FragmentSubmissionTestResultBinding import de.rki.coronawarnapp.exception.http.CwaClientError @@ -21,6 +22,7 @@ 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 kotlinx.coroutines.launch import javax.inject.Inject class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_result), @@ -132,6 +134,8 @@ class SubmissionTestResultFragment : Fragment(R.layout.fragment_submission_test_ ) } } + + lifecycleScope.launch { viewModel.observeTestResultToSchedulePositiveTestResultReminder() } } override fun onResume() { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt index 96766c26904d12ab16c85b2809fe68477248cb0d..97586ba507f291498d92a0145b88e1eacedd4e76 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/testresult/SubmissionTestResultViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.asLiveData import com.squareup.inject.assisted.AssistedInject import de.rki.coronawarnapp.exception.http.CwaWebException import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.notification.TestResultNotificationService import de.rki.coronawarnapp.service.submission.SubmissionService import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.SubmissionRepository @@ -24,7 +25,8 @@ import timber.log.Timber class SubmissionTestResultViewModel @AssistedInject constructor( dispatcherProvider: DispatcherProvider, - private val enfClient: ENFClient + private val enfClient: ENFClient, + private val testResultNotificationService: TestResultNotificationService ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { val routeToScreen: SingleLiveEvent<SubmissionNavigationEvents> = SingleLiveEvent() @@ -54,6 +56,11 @@ class SubmissionTestResultViewModel @AssistedInject constructor( ).let { emit(it) } }.asLiveData(context = dispatcherProvider.Default) + suspend fun observeTestResultToSchedulePositiveTestResultReminder() = + SubmissionRepository.deviceUIStateFlow + .first { it == DeviceUIState.PAIRED_POSITIVE || it == DeviceUIState.PAIRED_POSITIVE_TELETAN } + .also { testResultNotificationService.schedulePositiveTestResultReminder() } + val uiStateError: LiveData<Event<CwaWebException>> = SubmissionRepository.uiStateError fun onBackPressed() { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt index 9f1591ec0cb161e753cf68dbd1add15dc65bb310..4a11a2ed4da149940286c01abacf67c6b3d8462b 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/warnothers/SubmissionResultPositiveOtherWarningViewModel.kt @@ -6,6 +6,7 @@ import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import de.rki.coronawarnapp.exception.NoRegistrationTokenSetException import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.notification.TestResultNotificationService import de.rki.coronawarnapp.service.submission.SubmissionService import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.interoperability.InteroperabilityRepository @@ -31,7 +32,8 @@ class SubmissionResultPositiveOtherWarningViewModel @AssistedInject constructor( dispatcherProvider: DispatcherProvider, private val enfClient: ENFClient, private val taskController: TaskController, - interoperabilityRepository: InteroperabilityRepository + interoperabilityRepository: InteroperabilityRepository, + private val testResultNotificationService: TestResultNotificationService ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { private var currentSubmissionRequestId: UUID? = null @@ -96,6 +98,7 @@ class SubmissionResultPositiveOtherWarningViewModel @AssistedInject constructor( submitWithNoDiagnosisKeys() routeToScreen.postValue(SubmissionNavigationEvents.NavigateToSubmissionDone) } + testResultNotificationService.cancelPositiveTestResultNotification() } private fun submitDiagnosisKeys(keys: List<TemporaryExposureKey>) { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt index 56a85ecc4d1cfd937e39b41a301588fb56e6242c..ceb286194df6b309f4b4138964e573721ddb3bc1 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardState.kt @@ -41,13 +41,19 @@ data class TracingCardState( * for general information when no definite risk level * can be calculated */ - fun getRiskBody(c: Context): String = when (riskLevelScore) { - RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.string.risk_card_outdated_risk_body - RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.string.risk_card_body_tracing_off - RiskLevelConstants.UNKNOWN_RISK_INITIAL -> R.string.risk_card_unknown_risk_body - RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> R.string.risk_card_outdated_manual_risk_body - else -> null - }?.let { c.getString(it) } ?: "" + fun getRiskBody(c: Context): String { + return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) { + when (riskLevelScore) { + RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.string.risk_card_outdated_risk_body + RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.string.risk_card_body_tracing_off + RiskLevelConstants.UNKNOWN_RISK_INITIAL -> R.string.risk_card_unknown_risk_body + RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> R.string.risk_card_outdated_manual_risk_body + else -> null + }?.let { c.getString(it) } ?: "" + } else { + return c.getString(R.string.risk_card_body_tracing_off) + } + } /** * Formats the risk card text display of last persisted risk level @@ -55,23 +61,29 @@ data class TracingCardState( * the persisted risk level is of importance */ fun getSavedRiskBody(c: Context): String { - return if ( - riskLevelScore == RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF || - riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS || - riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL - ) { - when (lastRiskLevelScoreCalculated) { - RiskLevelConstants.LOW_LEVEL_RISK, - RiskLevelConstants.INCREASED_RISK, - RiskLevelConstants.UNKNOWN_RISK_INITIAL -> { - val arg = formatRiskLevelHeadline(c, lastRiskLevelScoreCalculated) - c.getString(R.string.risk_card_no_calculation_possible_body_saved_risk) - .format(arg) + return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) { + return if ( + riskLevelScore == RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF || + riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS || + riskLevelScore == RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL + ) { + when (lastRiskLevelScoreCalculated) { + RiskLevelConstants.LOW_LEVEL_RISK, + RiskLevelConstants.INCREASED_RISK, + RiskLevelConstants.UNKNOWN_RISK_INITIAL -> { + val arg = formatRiskLevelHeadline(c, lastRiskLevelScoreCalculated) + c.getString(R.string.risk_card_no_calculation_possible_body_saved_risk) + .format(arg) + } + else -> "" } - else -> "" + } else { + "" } } else { - "" + val arg = formatRiskLevelHeadline(c, lastRiskLevelScoreCalculated) + c.getString(R.string.risk_card_no_calculation_possible_body_saved_risk) + .format(arg) } } @@ -189,7 +201,17 @@ data class TracingCardState( */ */ fun getTimeFetched(c: Context): String { - return when (riskLevelScore) { + if (tracingStatus == GeneralTracingStatus.Status.TRACING_INACTIVE) { + return if (lastTimeDiagnosisKeysFetched != null) { + c.getString( + R.string.risk_card_body_time_fetched, + formatRelativeDateTimeString(c, lastTimeDiagnosisKeysFetched) + ) + } else { + c.getString(R.string.risk_card_body_not_yet_fetched) + } + } + return when (riskLevelScore) { RiskLevelConstants.LOW_LEVEL_RISK, RiskLevelConstants.INCREASED_RISK -> { if (lastTimeDiagnosisKeysFetched != null) { @@ -224,23 +246,6 @@ data class TracingCardState( } } - /** - * Formats the risk card text display of time when diagnosis keys will be updated - * from server again when applicable - */ - fun getNextUpdate(c: Context): String = if (!isBackgroundJobEnabled) { - "" - } else { - when (riskLevelScore) { - RiskLevelConstants.UNKNOWN_RISK_INITIAL, - RiskLevelConstants.LOW_LEVEL_RISK, - RiskLevelConstants.INCREASED_RISK -> c.getString( - R.string.risk_card_body_next_update - ) - else -> "" - } - } - /** * Formats the risk card divider color depending on risk level * This special handling is required due to light / dark mode differences and switches @@ -264,15 +269,22 @@ data class TracingCardState( fun getRiskLevelHeadline(c: Context) = formatRiskLevelHeadline(c, riskLevelScore) - fun formatRiskLevelHeadline(c: Context, riskLevelScore: Int) = when (riskLevelScore) { - RiskLevelConstants.INCREASED_RISK -> R.string.risk_card_increased_risk_headline - RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.string.risk_card_outdated_risk_headline - RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.string.risk_card_no_calculation_possible_headline - RiskLevelConstants.LOW_LEVEL_RISK -> R.string.risk_card_low_risk_headline - RiskLevelConstants.UNKNOWN_RISK_INITIAL -> R.string.risk_card_unknown_risk_headline - RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> R.string.risk_card_unknown_risk_headline - else -> null - }?.let { c.getString(it) } ?: "" + fun formatRiskLevelHeadline(c: Context, riskLevelScore: Int): String { + return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) { + when (riskLevelScore) { + RiskLevelConstants.INCREASED_RISK -> R.string.risk_card_increased_risk_headline + RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.string.risk_card_outdated_risk_headline + RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> + R.string.risk_card_no_calculation_possible_headline + RiskLevelConstants.LOW_LEVEL_RISK -> R.string.risk_card_low_risk_headline + RiskLevelConstants.UNKNOWN_RISK_INITIAL -> R.string.risk_card_unknown_risk_headline + RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS_MANUAL -> R.string.risk_card_unknown_risk_headline + else -> null + }?.let { c.getString(it) } ?: "" + } else { + return c.getString(R.string.risk_card_no_calculation_possible_headline) + } + } fun getProgressCardHeadline(c: Context): String = when (tracingProgress) { TracingProgress.Downloading -> R.string.risk_card_progress_download_headline @@ -288,11 +300,17 @@ data class TracingCardState( fun isTracingInProgress(): Boolean = tracingProgress != TracingProgress.Idle - fun getRiskInfoContainerBackgroundTint(c: Context): ColorStateList = when (riskLevelScore) { - RiskLevelConstants.INCREASED_RISK -> R.color.card_increased - RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.color.card_outdated - RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.color.card_no_calculation - RiskLevelConstants.LOW_LEVEL_RISK -> R.color.card_low - else -> R.color.card_unknown - }.let { c.getColorStateList(it) } + fun getRiskInfoContainerBackgroundTint(c: Context): ColorStateList { + return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) { + when (riskLevelScore) { + RiskLevelConstants.INCREASED_RISK -> R.color.card_increased + RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> R.color.card_outdated + RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.color.card_no_calculation + RiskLevelConstants.LOW_LEVEL_RISK -> R.color.card_low + else -> R.color.card_unknown + }.let { c.getColorStateList(it) } + } else { + return c.getColorStateList(R.color.card_no_calculation) + } + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt index 0961bf1c4e421a05e19f12d6d1e583a78f781e4f..a132f0cd0f96f0d316e2d5502ce4c3958463e900 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/common/BaseTracingState.kt @@ -25,18 +25,30 @@ abstract class BaseTracingState { /** * Formats the risk card colors for default and pressed states depending on risk level */ - fun getRiskColor(c: Context): Int = when (riskLevelScore) { - RiskLevelConstants.INCREASED_RISK -> R.color.colorSemanticHighRisk - RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS, - RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.color.colorSemanticUnknownRisk - RiskLevelConstants.LOW_LEVEL_RISK -> R.color.colorSemanticLowRisk - else -> R.color.colorSemanticNeutralRisk - }.let { c.getColor(it) } + fun getRiskColor(c: Context): Int { + return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) { + when (riskLevelScore) { + RiskLevelConstants.INCREASED_RISK -> R.color.colorSemanticHighRisk + RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS, + RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF -> R.color.colorSemanticUnknownRisk + RiskLevelConstants.LOW_LEVEL_RISK -> R.color.colorSemanticLowRisk + else -> R.color.colorSemanticNeutralRisk + }.let { c.getColor(it) } + } else { + return c.getColor(R.color.colorSemanticUnknownRisk) + } + } - fun isTracingOffRiskLevel(): Boolean = when (riskLevelScore) { - RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF, - RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> true - else -> false + fun isTracingOffRiskLevel(): Boolean { + return if (tracingStatus != GeneralTracingStatus.Status.TRACING_INACTIVE) { + when (riskLevelScore) { + RiskLevelConstants.NO_CALCULATION_POSSIBLE_TRACING_OFF, + RiskLevelConstants.UNKNOWN_RISK_OUTDATED_RESULTS -> true + else -> false + } + } else { + return true + } } fun getStableTextColor(c: Context): Int = c.getColor( diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/RiskDetailsFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/RiskDetailsFragmentViewModel.kt index b4fa7d034d2e38e36d72a3fd1b4762bb716a2cfa..2075f82ed79aa0788b3aeec2707de2a86c338597 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/RiskDetailsFragmentViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/tracing/details/RiskDetailsFragmentViewModel.kt @@ -25,12 +25,10 @@ class RiskDetailsFragmentViewModel @AssistedInject constructor( childViewModels = listOf(settingsViewModel) ) { - @Suppress("MagicNumber") val tracingDetailsState: LiveData<TracingDetailsState> = tracingDetailsStateProvider.state .sample(150L) .asLiveData(dispatcherProvider.Default) - @Suppress("MagicNumber") val tracingCardState: LiveData<TracingCardState> = tracingCardStateProvider.state .map { it.copy(showDetails = true) } .sample(150L) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt index 8efb6c14888cb3fb0193da546f7ea5f9cf7566d4..cade97a58323736139c16c3898b04b26944a21fe 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/CWADebug.kt @@ -31,4 +31,13 @@ object CWADebug { DEVICE("device"), DEVICE_FOR_TESTERS("deviceForTesters") } + + val isAUnitTest: Boolean by lazy { + try { + Class.forName("testhelpers.IsAUnitTest") + true + } catch (e: Exception) { + false + } + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt index b0aae90040901a60c89c9ff37e0f68e07aacc983..86690e22520a4689a14f591cf5e5edab61ef853a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt @@ -25,6 +25,7 @@ import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository import de.rki.coronawarnapp.storage.AppDatabase import de.rki.coronawarnapp.storage.RiskLevelRepository +import de.rki.coronawarnapp.storage.SubmissionRepository import de.rki.coronawarnapp.storage.interoperability.InteroperabilityRepository import de.rki.coronawarnapp.util.di.AppContext import de.rki.coronawarnapp.util.security.SecurityHelper @@ -59,6 +60,8 @@ class DataReset @Inject constructor( SecurityHelper.resetSharedPrefs() // Reset the current risk level stored in LiveData RiskLevelRepository.reset() + // Reset the current states stored in LiveData + SubmissionRepository.reset() keyCacheRepository.clear() appConfigProvider.clear() interoperabilityRepository.clear() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DialogHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DialogHelper.kt index e7a295a1643aea654def25d0a152bc62bbbf12eb..48bb77b8dbcd1ccb99cd3d3793b89f940f31bf58 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DialogHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DialogHelper.kt @@ -18,6 +18,7 @@ object DialogHelper { val positiveButton: String, val negativeButton: String? = null, val cancelable: Boolean? = true, + val isTextSelectable: Boolean = false, val positiveButtonFunction: () -> Unit? = {}, val negativeButtonFunction: () -> Unit? = {} ) { @@ -31,14 +32,14 @@ object DialogHelper { positiveButtonFunction: () -> Unit? = {}, negativeButtonFunction: () -> Unit? = {} ) : this( - context, - context.resources.getString(title), - context.resources.getString(message), - context.resources.getString(positiveButton), - negativeButton?.let { context.resources.getString(it) }, - cancelable, - positiveButtonFunction, - negativeButtonFunction + context = context, + title = context.resources.getString(title), + message = context.resources.getString(message), + positiveButton = context.resources.getString(positiveButton), + negativeButton = negativeButton?.let { context.resources.getString(it) }, + cancelable = cancelable, + positiveButtonFunction = positiveButtonFunction, + negativeButtonFunction = negativeButtonFunction ) constructor( @@ -51,21 +52,25 @@ object DialogHelper { positiveButtonFunction: () -> Unit? = {}, negativeButtonFunction: () -> Unit? = {} ) : this( - context, - context.resources.getString(title), - message, - context.resources.getString(positiveButton), - negativeButton?.let { context.resources.getString(it) }, - cancelable, - positiveButtonFunction, - negativeButtonFunction + context = context, + title = context.resources.getString(title), + message = message, + positiveButton = context.resources.getString(positiveButton), + negativeButton = negativeButton?.let { context.resources.getString(it) }, + cancelable = cancelable, + positiveButtonFunction = positiveButtonFunction, + negativeButtonFunction = negativeButtonFunction ) } fun showDialog( dialogInstance: DialogInstance ): AlertDialog { - val message = getMessage(dialogInstance.context, dialogInstance.message) + val message = getMessage( + dialogInstance.context, + dialogInstance.message, + dialogInstance.isTextSelectable + ) val alertDialog: AlertDialog = dialogInstance.context.let { val builder = AlertDialog.Builder(it) builder.apply { @@ -91,7 +96,7 @@ object DialogHelper { return alertDialog } - private fun getMessage(context: Context, message: String?): TextView { + private fun getMessage(context: Context, message: String?, isTextSelectable: Boolean): TextView { // create spannable and add links, removed stack trace links into nowhere val spannable = SpannableString(message) val httpPattern: Pattern = Pattern.compile("[a-z]+://[^ \\n]*") @@ -107,6 +112,7 @@ object DialogHelper { textView.setPadding(paddingStartEnd, paddingLeftRight, paddingStartEnd, paddingLeftRight) textView.setTextAppearance(R.style.body1) textView.setLinkTextColor(context.getColorStateList(R.color.button_primary)) + if (isTextSelectable) textView.setTextIsSelectable(true) return textView } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/FormattedError.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/FormattedError.kt deleted file mode 100644 index 0639465cec8c0a42e1af49781100ac85ccba1f01..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/FormattedError.kt +++ /dev/null @@ -1,12 +0,0 @@ -package de.rki.coronawarnapp.util - -import android.content.Context - -interface FormattedError { - fun getFormattedError(context: Context): String -} - -fun Throwable.tryFormattedError(context: Context): String = when (this) { - is FormattedError -> this.getFormattedError(context) - else -> (localizedMessage ?: this.message) ?: this.toString() -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HasHumanReadableError.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HasHumanReadableError.kt new file mode 100644 index 0000000000000000000000000000000000000000..82f66fda53b9599b39dd156620343d52cdb97de6 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HasHumanReadableError.kt @@ -0,0 +1,21 @@ +package de.rki.coronawarnapp.util + +import android.content.Context + +interface HasHumanReadableError { + fun toHumanReadableError(context: Context): HumanReadableError +} + +data class HumanReadableError( + val title: String? = null, + val description: String +) + +fun Throwable.tryHumanReadableError(context: Context): HumanReadableError = when (this) { + is HasHumanReadableError -> this.toHumanReadableError(context) + else -> { + HumanReadableError( + description = (localizedMessage ?: this.message) ?: this.toString() + ) + } +} 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 665a94a67bfb156af56fbc7fcb75757bc349f4a3..68c427d28d4369de241d8dc69b51f828aac49a62 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/AndroidModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt index 3b8359370197e92077899388a9ba931e90a5e0ba..6fdc8c4a0a4137d21f1efb3535cd7cab66cf6cc4 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/AndroidModule.kt @@ -4,9 +4,11 @@ import android.app.Application import android.bluetooth.BluetoothAdapter import android.content.Context import androidx.core.app.NotificationManagerCompat +import androidx.work.WorkManager import dagger.Module import dagger.Provides import de.rki.coronawarnapp.CoronaWarnApplication +import de.rki.coronawarnapp.util.worker.WorkManagerProvider import javax.inject.Singleton @Module @@ -30,4 +32,10 @@ class AndroidModule { fun notificationManagerCompat( @AppContext context: Context ): NotificationManagerCompat = NotificationManagerCompat.from(context) + + @Provides + @Singleton + fun workManager( + workManagerProvider: WorkManagerProvider + ): WorkManager = workManagerProvider.workManager } 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 71c8f4f7a8770fc2cee5a1d104f469741ba72b1b..af2cb89a00f2cbb07c2f27e5abdff7d7728abafd 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 25b36bc91df4887155d8ded61815c0ef1ce63ad7..701dc3239b455bff16edc0b2d656f64f1e8c43ad 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, @@ -30,7 +42,7 @@ fun <T> Flow<T>.shareLatest( ) .mapNotNull { it } -@Suppress("UNCHECKED_CAST", "MagicNumber", "LongParameterList") +@Suppress("UNCHECKED_CAST", "LongParameterList") inline fun <T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, T11, R> combine( flow: Flow<T1>, flow2: Flow<T2>, diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt index c3d6d5787f6c991b9a8498e6cc3b3ede70afa522..91086980e2bd33336558e889a15272962e37bd83 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/flow/HotDataFlow.kt @@ -85,14 +85,7 @@ class HotDataFlow<T : Any>( suspend fun updateBlocking(update: suspend T.() -> T): T { updateActions.tryEmit(update) Timber.tag(tag).v("Waiting for update.") - return internalFlow.first { - val targetUpdate = it.updatedBy - Timber.tag(tag).v( - "Comparing %s with %s; match=%b", - targetUpdate, update, targetUpdate == update - ) - it.updatedBy == update - }.value.also { Timber.tag(tag).v("Returning blocking update result: %s", it) } + return internalFlow.first { it.updatedBy == update }.value } internal sealed class Holder<T> { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/TestResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/TestResult.kt index c0cacc40a79d73c32ce47d9f4863df2c6f8bc1e9..912799429291f7b7e5c99cc9d3f0629b3acbad94 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/TestResult.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/formatter/TestResult.kt @@ -1,6 +1,5 @@ package de.rki.coronawarnapp.util.formatter -@Suppress("MagicNumber") enum class TestResult(val value: Int) { PENDING(0), NEGATIVE(1), 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 0000000000000000000000000000000000000000..e8db4b808a22f40ae314a07fa3a26a69e93605ee --- /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 0000000000000000000000000000000000000000..096cdebe147e8b45d360f75053d96bc45a4386b7 --- /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 0000000000000000000000000000000000000000..cafc44da324f553fd852b21ee8ae3353535b7005 --- /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 0000000000000000000000000000000000000000..52f30d628001d62dc3b39eb63114e9025ae352ef --- /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 0000000000000000000000000000000000000000..9202e1bd6d82b8cde3238c8d310a9011ef8d8f36 --- /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 0000000000000000000000000000000000000000..f30ff1b744d11f86b36225587ae3bcbad6766d80 --- /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 0000000000000000000000000000000000000000..c64cbedfde89f3c164c26e3eb1ea2204102d2bd3 --- /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/main/java/de/rki/coronawarnapp/util/retrofit/RetrofitExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/retrofit/RetrofitExtensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..aad0801ac35eb4dd4bf5a0581d019e82a42cbcc6 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/retrofit/RetrofitExtensions.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.util.retrofit + +import okhttp3.Headers + +fun Headers.etag(): String? = values("ETag").singleOrNull() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt similarity index 74% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt index 3e7c0ee22be629676acafc785136f3a64af69f17..dff4a55e38f96608003dd00059d3c9e64e777aa2 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerSetup.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkManagerProvider.kt @@ -9,20 +9,23 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class WorkManagerSetup @Inject constructor( +class WorkManagerProvider @Inject constructor( @AppContext private val context: Context, private val cwaWorkerFactory: CWAWorkerFactory ) { - fun setup() { + val workManager by lazy { Timber.v("Setting up WorkManager.") val configuration = Configuration.Builder().apply { setMinimumLoggingLevel(android.util.Log.DEBUG) setWorkerFactory(cwaWorkerFactory) }.build() + Timber.v("WorkManager initialize...") WorkManager.initialize(context, configuration) - Timber.v("WorkManager setup done.") + WorkManager.getInstance(context).also { + Timber.v("WorkManager setup done: %s", it) + } } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt index 7bae929f22ca532f2efcb9103cd5407e4a5e6438..99541ed60f1dfc69b41bb3a354af2dc78798d937 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt @@ -4,6 +4,8 @@ import androidx.work.ListenableWorker import dagger.Binds import dagger.Module import dagger.multibindings.IntoMap +import de.rki.coronawarnapp.deadman.DeadmanNotificationOneTimeWorker +import de.rki.coronawarnapp.deadman.DeadmanNotificationPeriodicWorker import de.rki.coronawarnapp.nearby.ExposureStateUpdateWorker import de.rki.coronawarnapp.worker.BackgroundNoiseOneTimeWorker import de.rki.coronawarnapp.worker.BackgroundNoisePeriodicWorker @@ -55,4 +57,18 @@ abstract class WorkerBinder { abstract fun testResultRetrievalPeriodic( factory: DiagnosisTestResultRetrievalPeriodicWorker.Factory ): InjectedWorkerFactory<out ListenableWorker> + + @Binds + @IntoMap + @WorkerKey(DeadmanNotificationOneTimeWorker::class) + abstract fun deadmanNotificationOneTime( + factory: DeadmanNotificationOneTimeWorker.Factory + ): InjectedWorkerFactory<out ListenableWorker> + + @Binds + @IntoMap + @WorkerKey(DeadmanNotificationPeriodicWorker::class) + abstract fun deadmanNotificationPeriodic( + factory: DeadmanNotificationPeriodicWorker.Factory + ): InjectedWorkerFactory<out ListenableWorker> } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt index f15edf5567a084410d3accf8e9cd529b4f1a6d0a..75b991b690e2f25d6c7d474257765028129c0c80 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundConstants.kt @@ -64,18 +64,6 @@ object BackgroundConstants { */ const val MINUTES_IN_DAY = 1440 - /** - * Total tries count for diagnosis key retrieval per day - * Internal requirement - */ - const val DIAGNOSIS_KEY_RETRIEVAL_TRIES_PER_DAY = 12 - - /** - * Maximum tries count for diagnosis key retrieval per day - * Google API limit - */ - const val GOOGLE_API_MAX_CALLS_PER_DAY = 20 - /** * Total tries count for diagnosis key retrieval per day * Internal requirement diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt index 4dfc187b4cb173eba23e66924001d1925a686999..b77d7c9d79b47591ac2ae4cd42a52eca7ef227fa 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoiseOneTimeWorker.kt @@ -7,6 +7,7 @@ import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject import de.rki.coronawarnapp.playbook.Playbook import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory +import timber.log.Timber /** * One time background noise worker @@ -25,6 +26,7 @@ class BackgroundNoiseOneTimeWorker @AssistedInject constructor( * @return Result */ override suspend fun doWork(): Result { + Timber.d("$id: doWork() started. Run attempt: $runAttemptCount") var result = Result.success() try { @@ -38,6 +40,7 @@ class BackgroundNoiseOneTimeWorker @AssistedInject constructor( } } + Timber.d("$id: doWork() finished with %s", result) return result } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt index 3869efb0a2ff59d518a538c814ff821cb8b6e30f..15b175f853c7dd9ffdc7385c4130b0460d293232 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundNoisePeriodicWorker.kt @@ -34,7 +34,7 @@ class BackgroundNoisePeriodicWorker @AssistedInject constructor( * @see BackgroundConstants.NUMBER_OF_DAYS_TO_RUN_PLAYBOOK */ override suspend fun doWork(): Result { - Timber.d("Background job started. Run attempt: $runAttemptCount") + Timber.d("$id: doWork() started. Run attempt: $runAttemptCount") var result = Result.success() try { @@ -57,11 +57,13 @@ class BackgroundNoisePeriodicWorker @AssistedInject constructor( Result.retry() } } + Timber.d("$id: doWork() finished with %s", result) return result } private fun stopWorker() { BackgroundWorkScheduler.WorkType.BACKGROUND_NOISE_PERIODIC_WORK.stop() + Timber.d("$id: worker stopped") } @AssistedInject.Factory diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt index 4578ebaac7e5e5a775478b737e6d785c48113b03..b109e8ba261979dc70e6760555a0a1c772bcf301 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilder.kt @@ -11,7 +11,7 @@ import java.util.concurrent.TimeUnit * Set "kind delay" for accessibility reason. * Backoff criteria set to Linear type. * - * @return PeriodicWorkRequest + * The launchInterval is 60 minutes as we want to check every hour, for new hour packages on the CDN. * * @see WorkTag.DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER * @see BackgroundConstants.KIND_DELAY @@ -19,9 +19,7 @@ import java.util.concurrent.TimeUnit * @see BackoffPolicy.LINEAR */ fun buildDiagnosisKeyRetrievalPeriodicWork() = - PeriodicWorkRequestBuilder<DiagnosisKeyRetrievalPeriodicWorker>( - BackgroundWorkHelper.getDiagnosisKeyRetrievalPeriodicWorkTimeInterval(), TimeUnit.MINUTES - ) + PeriodicWorkRequestBuilder<DiagnosisKeyRetrievalPeriodicWorker>(60, TimeUnit.MINUTES) .addTag(WorkTag.DIAGNOSIS_KEY_RETRIEVAL_PERIODIC_WORKER.tag) .setInitialDelay( BackgroundConstants.KIND_DELAY, diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt index 9e9fb0a702ba1f8268bd0e1c231776fccb7f55fe..2b8f2cebec6728c11a56ad81194fdb577c10720d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/BackgroundWorkHelper.kt @@ -1,6 +1,5 @@ package de.rki.coronawarnapp.worker -import androidx.core.app.NotificationCompat import androidx.work.Constraints import androidx.work.NetworkType import de.rki.coronawarnapp.notification.NotificationHelper @@ -17,17 +16,6 @@ import kotlin.random.Random */ object BackgroundWorkHelper { - /** - * Calculate the time for diagnosis key retrieval periodic work - * - * @return Long - * - * @see BackgroundConstants.MINUTES_IN_DAY - * @see getDiagnosisKeyRetrievalMaximumCalls - */ - fun getDiagnosisKeyRetrievalPeriodicWorkTimeInterval(): Long = - (BackgroundConstants.MINUTES_IN_DAY / getDiagnosisKeyRetrievalMaximumCalls()).toLong() - /** * Calculate the time for diagnosis key retrieval periodic work * @@ -39,18 +27,6 @@ object BackgroundWorkHelper { (BackgroundConstants.MINUTES_IN_DAY / BackgroundConstants.DIAGNOSIS_TEST_RESULT_RETRIEVAL_TRIES_PER_DAY).toLong() - /** - * Get maximum calls count to Google API - * - * @return Long - * - * @see BackgroundConstants.DIAGNOSIS_KEY_RETRIEVAL_TRIES_PER_DAY - * @see BackgroundConstants.GOOGLE_API_MAX_CALLS_PER_DAY - */ - fun getDiagnosisKeyRetrievalMaximumCalls() = - BackgroundConstants.DIAGNOSIS_KEY_RETRIEVAL_TRIES_PER_DAY - .coerceAtMost(BackgroundConstants.GOOGLE_API_MAX_CALLS_PER_DAY) - /** * Get background noise one time work delay * The periodic job is already delayed by MIN_HOURS_TO_NEXT_BACKGROUND_NOISE_EXECUTION @@ -93,6 +69,6 @@ object BackgroundWorkHelper { fun sendDebugNotification(title: String, content: String) { Timber.d("sendDebugNotification(title=%s, content=%s)", title, content) if (!LocalData.backgroundNotification()) return - NotificationHelper.sendNotification(title, content, NotificationCompat.PRIORITY_HIGH, true) + NotificationHelper.sendNotification(title, content, true) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt index e899972775e9bcaf7a645bdd3e2d6013f0985b9a..60dc57174abac3d7ed941ad2dd3d7662da4066e6 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/worker/DiagnosisTestResultRetrievalPeriodicWorker.kt @@ -1,7 +1,6 @@ package de.rki.coronawarnapp.worker import android.content.Context -import androidx.core.app.NotificationCompat import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import com.squareup.inject.assisted.Assisted @@ -44,19 +43,20 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor( */ override suspend fun doWork(): Result { - Timber.d("Background job started. Run attempt: $runAttemptCount") + Timber.d("$id: doWork() started. Run attempt: $runAttemptCount") BackgroundWorkHelper.sendDebugNotification( "TestResult Executing: Start", "TestResult started. Run attempt: $runAttemptCount " ) if (runAttemptCount > BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD) { - Timber.d("Background job failed after $runAttemptCount attempts. Rescheduling") + Timber.d("$id doWork() failed after $runAttemptCount attempts. Rescheduling") BackgroundWorkHelper.sendDebugNotification( "TestResult Executing: Failure", "TestResult failed with $runAttemptCount attempts" ) - BackgroundWorkScheduler.scheduleDiagnosisKeyPeriodicWork() + Timber.d("$id Rescheduled background worker") + return Result.failure() } var result = Result.success() @@ -66,10 +66,13 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor( System.currentTimeMillis() ) < BackgroundConstants.POLLING_VALIDITY_MAX_DAYS ) { + Timber.d(" $id maximum days not exceeded") val testResult = SubmissionService.asyncRequestTestResult() initiateNotification(testResult) + Timber.d(" $id Test Result Notification Initiated") } else { stopWorker() + Timber.d(" $id worker stopped") } } catch (e: Exception) { result = Result.retry() @@ -78,6 +81,7 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor( BackgroundWorkHelper.sendDebugNotification( "TestResult Executing: End", "TestResult result: $result " ) + Timber.d("$id: doWork() finished with %s", result) return result } @@ -95,9 +99,10 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor( */ private fun initiateNotification(testResult: TestResult) { if (LocalData.isTestResultNotificationSent() || LocalData.submissionWasSuccessful()) { + Timber.d("$id: Notification already sent or there was a successful submission") return } - + Timber.d("$id: Test Result retried is $testResult") if (testResult == TestResult.NEGATIVE || testResult == TestResult.POSITIVE || testResult == TestResult.INVALID ) { @@ -106,9 +111,9 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor( CoronaWarnApplication.getAppContext() .getString(R.string.notification_name), CoronaWarnApplication.getAppContext() - .getString(R.string.notification_body), - NotificationCompat.PRIORITY_HIGH + .getString(R.string.notification_body) ) + Timber.d("$id: Test Result available and notification is initiated") } LocalData.isTestResultNotificationSent(true) stopWorker() @@ -124,7 +129,7 @@ class DiagnosisTestResultRetrievalPeriodicWorker @AssistedInject constructor( private fun stopWorker() { LocalData.initialPollingForTestResultTimeStamp(0L) BackgroundWorkScheduler.WorkType.DIAGNOSIS_TEST_RESULT_PERIODIC_WORKER.stop() - + Timber.d("$id: Background worker stopped") BackgroundWorkHelper.sendDebugNotification( "TestResult Stopped", "TestResult Stopped" ) diff --git a/Corona-Warn-App/src/main/res/layout/include_risk_card_content.xml b/Corona-Warn-App/src/main/res/layout/include_risk_card_content.xml index bcbad4136a1425ebd1944300bc65b678a808e89b..4d3f7ab8344ee894a5054d61f9707791cf14f687 100644 --- a/Corona-Warn-App/src/main/res/layout/include_risk_card_content.xml +++ b/Corona-Warn-App/src/main/res/layout/include_risk_card_content.xml @@ -229,33 +229,6 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/risk_card_row_saved_days" /> - <include - android:id="@+id/risk_card_next_update_divider" - gone="@{tracingCard.getNextUpdate(context).empty}" - layout="@layout/include_divider" - android:layout_width="@dimen/match_constraint" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - app:dividerColor="@{tracingCard.getStableDividerColor(context)}" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/risk_card_row_time_fetched" /> - - <TextView - android:id="@+id/risk_card_next_update_test" - style="@style/body2" - gone="@{tracingCard.getNextUpdate(context).empty}" - android:layout_width="@dimen/match_constraint" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_small" - android:text="@{tracingCard.getNextUpdate(context)}" - android:textColor="@{tracingCard.getStableTextColor(context)}" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/risk_card_next_update_divider" - tools:text="@string/risk_card_body_next_update" - tools:textColor="@color/colorStableLight" /> - <Button android:id="@+id/risk_card_button_enable_tracing" style="@style/buttonPrimary" @@ -266,7 +239,7 @@ android:text="@string/risk_details_button_enable_tracing" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/risk_card_next_update_test" /> + app:layout_constraintTop_toBottomOf="@+id/risk_card_row_time_fetched" /> <Button android:id="@+id/risk_card_button_update" diff --git a/Corona-Warn-App/src/main/res/values-de/strings.xml b/Corona-Warn-App/src/main/res/values-de/strings.xml index 6856b0f41124fe28851d80b913a5e61e3b017c3c..e4c773c8c238ca47b667f88f9ecdcf0b4ebba675 100644 --- a/Corona-Warn-App/src/main/res/values-de/strings.xml +++ b/Corona-Warn-App/src/main/res/values-de/strings.xml @@ -158,8 +158,6 @@ <string name="risk_card_body_not_yet_fetched">"Begegnungen wurden noch nicht überprüft."</string> <!-- XTXT: risk card - last successful update --> <string name="risk_card_body_time_fetched">"Aktualisiert: %1$s"</string> - <!-- XTXT: risk card - next update --> - <string name="risk_card_body_next_update">"Tägliche Aktualisierung"</string> <!-- XTXT: risk card - hint to open the app daily --> <string name="risk_card_body_open_daily">"Hinweis: Bitte öffnen Sie die App täglich, um den Risikostatus zu aktualisieren."</string> <!-- XBUT: risk card - update risk --> @@ -341,9 +339,9 @@ <!-- XHED: risk details - infection period logged headling, below behaviors --> <string name="risk_details_headline_period_logged">"Ermittlungszeitraum"</string> <!-- XHED: risk details - infection period logged headling, below behaviors --> - <string name="risk_details_subtitle_period_logged">"Dieser Zeitraum wird berücksichtigt"</string> + <string name="risk_details_subtitle_period_logged">"Dieser Zeitraum wird berücksichtigt."</string> <!-- XHED: risk details - infection period logged information body, below behaviors --> - <string name="risk_details_information_body_period_logged">"Die Berechnung des Infektionsrisikos kann nur für die Zeiträume erfolgen, an denen die Risiko-Ermittlung aktiv war. Die Risiko-Ermittlung sollte daher dauerhaft aktiv sein"</string> + <string name="risk_details_information_body_period_logged">"Die Berechnung des Infektionsrisikos kann nur für die Zeiträume erfolgen, an denen die Risiko-Ermittlung aktiv war. Die Risiko-Ermittlung sollte daher dauerhaft aktiv sein."</string> <!-- XHED: risk details - infection period logged information body, below behaviors --> <string name="risk_details_information_body_period_logged_assessment">"Für Ihre Risiko-Ermittlung wird nur der Zeitraum der letzten 14 Tage betrachtet. In diesem Zeitraum war Ihre Risiko-Ermittlung für eine Gesamtdauer von %1$s Tagen aktiv. Ältere Tage werden automatisch gelöscht, da sie aus Sicht des Infektionsschutzes nicht mehr relevant sind."</string> <!-- XHED: risk details - how your risk level was calculated, below behaviors --> @@ -1213,7 +1211,10 @@ <string name="errors_google_update_needed">"Ihre Corona-Warn-App ist korrekt installiert. Leider fehlt dem Betriebssystem Ihres Smartphones der Dienst „COVID-19-Benachrichtigungen“ und Sie können die Corona-Warn-App nicht nutzen. Weitere Informationen finden Sie in unseren FAQ: https://www.coronawarn.app/de/faq/"</string> <!-- XTXT: error dialog - either Google API Error (10) or reached request limit per day --> <string name="errors_google_api_error">"Ihre Corona-Warn-App läuft fehlerfrei. Leider können Sie Ihren Risikostatus im Moment nicht aktualisieren. Ihre Risiko-Ermittlung ist weiterhin aktiv und funktioniert. Weitere Informationen finden Sie in unseren FAQ: https://www.coronawarn.app/de/faq/"</string> - + <!-- XTXT: error dialog - Error title when the provideDiagnosisKeys quota limit was reached. --> + <string name="errors_risk_detection_limit_reached_title">"Limit bereits erreicht"</string> + <!-- XTXT: error dialog - Error description when the provideDiagnosisKeys quota limit was reached. --> + <string name="errors_risk_detection_limit_reached_description">"Heute sind keine weiteren Risiko-Überprüfungen möglich, weil das von Ihrem Betriebssystem festgelegte Limit von Risiko-Überprüfungen pro Tag bereits erreicht ist. Bitte überprüfen Sie Ihren Risikostatus morgen wieder."</string> <!-- #################################### Generic Error Messages ###################################### --> diff --git a/Corona-Warn-App/src/main/res/values/strings.xml b/Corona-Warn-App/src/main/res/values/strings.xml index d135fef1639ced57593c5caa26055e71dc66be0c..266628ab2b4af25556ecb991a8911cff5a40eea2 100644 --- a/Corona-Warn-App/src/main/res/values/strings.xml +++ b/Corona-Warn-App/src/main/res/values/strings.xml @@ -1218,6 +1218,10 @@ <string name="errors_google_update_needed">"Your Corona-Warn-App is correctly installed, but the \"COVID-19 Exposure Notifications System\" is not available on your smartphone\'s operating system. This means that you cannot use the Corona-Warn-App. For further information, please see our FAQ page: https://www.coronawarn.app/en/faq/"</string> <!-- XTXT: error dialog - either Google API Error (10) or reached request limit per day --> <string name="errors_google_api_error">"The Corona-Warn-App is running correctly, but we cannot update your current risk status. Exposure logging remains active and is working correctly. For further information, please see our FAQ page: https://www.coronawarn.app/en/faq/"</string> + <!-- XTXT: error dialog - Error title when the provideDiagnosisKeys quota limit was reached. --> + <string name="errors_risk_detection_limit_reached_title" /> + <!-- XTXT: error dialog - Error description when the provideDiagnosisKeys quota limit was reached. --> + <string name="errors_risk_detection_limit_reached_description" /> <!-- #################################### Generic Error Messages diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt index 00fcd329fc1e5456231e032cdfe1788531ba5f6f..8f5817cf7441619cc98c5d9296f7bafa0bbf8718 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigProviderTest.kt @@ -6,12 +6,12 @@ import io.mockk.MockKAnnotations import io.mockk.Runs import io.mockk.clearAllMocks import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.coVerifySequence import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first import org.joda.time.Duration import org.joda.time.Instant import org.junit.jupiter.api.AfterEach @@ -43,7 +43,8 @@ class AppConfigProviderTest : BaseIOTest() { serverTime = Instant.parse("2020-11-03T05:35:16.000Z"), localOffset = Duration.ZERO, mappedConfig = configData, - isFallback = false + identifier = "identifier", + configType = ConfigData.Type.FROM_SERVER ) coEvery { source.clear() } just Runs coEvery { source.retrieveConfig() } returns testConfigDownload @@ -65,44 +66,73 @@ class AppConfigProviderTest : BaseIOTest() { @Test fun `appConfig is observable`() = runBlockingTest2(ignoreActive = true) { + var counter = 0 + coEvery { source.retrieveConfig() } answers { + DefaultConfigData( + serverTime = Instant.parse("2020-11-03T05:35:16.000Z"), + localOffset = Duration.ZERO, + mappedConfig = configData, + identifier = "${++counter}", + configType = ConfigData.Type.FROM_SERVER + ) + } + val instance = createInstance(this) val testCollector = instance.currentConfig.test(startOnScope = this) instance.getAppConfig() - instance.clear() + instance.getAppConfig() instance.getAppConfig() - advanceUntilIdle() + testCollector.cancel() - testCollector.latestValues shouldBe listOf( - null, - testConfigDownload, - null, - testConfigDownload - ) + advanceUntilIdle() coVerifySequence { source.retrieveConfig() - source.clear() + source.retrieveConfig() + source.retrieveConfig() source.retrieveConfig() } } @Test - fun `clear clears storage and current config`() = runBlockingTest2(ignoreActive = true) { + fun `appConfig uses WHILE_SUBSCRIBED mode`() = runBlockingTest2(ignoreActive = true) { val instance = createInstance(this) - instance.getAppConfig() shouldBe testConfigDownload - instance.currentConfig.first() shouldBe testConfigDownload + val testCollector1 = instance.currentConfig.test(startOnScope = this) + coVerify(exactly = 1) { source.retrieveConfig() } - instance.clear() + // Was still active + val testCollector2 = instance.currentConfig.test(startOnScope = this) + advanceUntilIdle() + testCollector2.cancel() + + // Was still active + val testCollector3 = instance.currentConfig.test(startOnScope = this) + advanceUntilIdle() + testCollector3.cancel() - instance.currentConfig.first() shouldBe null + coVerify(exactly = 1) { source.retrieveConfig() } + testCollector1.cancel() // Last subscriber + advanceUntilIdle() - coVerifySequence { - source.retrieveConfig() + // Restarts the HotDataFlow + val testCollector4 = instance.currentConfig.test(startOnScope = this) + advanceUntilIdle() + testCollector4.cancel() + + coVerify(exactly = 2) { source.retrieveConfig() } + } + @Test + fun `clear clears storage and current config`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) + + instance.clear() + + coVerifySequence { source.clear() } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt index a4ba54e99f2a9ad6630597cbfcec4f3601a91021..2a3bf75048881cb9f6acb22bcb84e7ee8511abcc 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt @@ -3,6 +3,7 @@ package de.rki.coronawarnapp.appconfig import de.rki.coronawarnapp.appconfig.download.AppConfigServer import de.rki.coronawarnapp.appconfig.download.AppConfigStorage import de.rki.coronawarnapp.appconfig.download.ConfigDownload +import de.rki.coronawarnapp.appconfig.download.DefaultAppConfigSource import de.rki.coronawarnapp.appconfig.mapping.ConfigParser import de.rki.coronawarnapp.util.TimeStamper import io.kotest.matchers.shouldBe @@ -15,6 +16,8 @@ import io.mockk.coVerifyOrder import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.just +import io.mockk.verify +import kotlinx.coroutines.test.runBlockingTest import okio.ByteString.Companion.decodeHex import org.joda.time.Duration import org.joda.time.Instant @@ -34,13 +37,15 @@ class AppConfigSourceTest : BaseIOTest() { @MockK lateinit var configParser: ConfigParser @MockK lateinit var configData: ConfigData @MockK lateinit var timeStamper: TimeStamper + @MockK lateinit var appConfigDefaultFallback: DefaultAppConfigSource private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) private var testConfigDownload = ConfigDownload( rawData = APPCONFIG_RAW, serverTime = Instant.parse("2020-11-03T05:35:16.000Z"), - localOffset = Duration.standardHours(1) + localOffset = Duration.standardHours(1), + etag = "etag" ) private var mockConfigStorage: ConfigDownload? = null @@ -74,6 +79,7 @@ class AppConfigSourceTest : BaseIOTest() { server = configServer, storage = configStorage, parser = configParser, + defaultAppConfig = appConfigDefaultFallback, dispatcherProvider = TestDispatcherProvider ) @@ -84,12 +90,14 @@ class AppConfigSourceTest : BaseIOTest() { serverTime = mockConfigStorage!!.serverTime, localOffset = mockConfigStorage!!.localOffset, mappedConfig = configData, - isFallback = false + configType = ConfigData.Type.FROM_SERVER, + identifier = "etag" ) mockConfigStorage shouldBe testConfigDownload coVerify { configStorage.setStoredConfig(testConfigDownload) } + verify(exactly = 0) { appConfigDefaultFallback.getRawDefaultConfig() } } @Test @@ -101,8 +109,11 @@ class AppConfigSourceTest : BaseIOTest() { serverTime = mockConfigStorage!!.serverTime, localOffset = mockConfigStorage!!.localOffset, mappedConfig = configData, - isFallback = true + configType = ConfigData.Type.LAST_RETRIEVED, + identifier = "etag" ) + + verify(exactly = 0) { appConfigDefaultFallback.getRawDefaultConfig() } } @Test @@ -131,6 +142,25 @@ class AppConfigSourceTest : BaseIOTest() { } } + @Test + fun `local default config is used as last resort`() = runBlockingTest { + coEvery { configServer.downloadAppConfig() } throws IOException() + coEvery { configStorage.getStoredConfig() } returns null + every { appConfigDefaultFallback.getRawDefaultConfig() } returns APPCONFIG_RAW + + val instance = createInstance() + + instance.retrieveConfig() shouldBe DefaultConfigData( + serverTime = Instant.EPOCH, + localOffset = Duration.standardHours(12), + mappedConfig = configData, + configType = ConfigData.Type.LOCAL_DEFAULT, + identifier = "fallback.local" + ) + + verify { appConfigDefaultFallback.getRawDefaultConfig() } + } + companion object { private val APPCONFIG_RAW = ( "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" + diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt index eda32b98ef41d9a00ff05020c9e24f3551e5cbd8..db28c6d6d7c8456eac64fc06250071511e1df0d8 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt @@ -63,7 +63,10 @@ class AppConfigServerTest : BaseIOTest() { fun `application config download`() = runBlockingTest { coEvery { api.getApplicationConfiguration("DE") } returns Response.success( APPCONFIG_BUNDLE.toResponseBody(), - Headers.headersOf("Date", "Tue, 03 Nov 2020 08:46:03 GMT") + Headers.headersOf( + "Date", "Tue, 03 Nov 2020 08:46:03 GMT", + "ETag", "I am an ETag :)!" + ) ) val downloadServer = createInstance() @@ -75,7 +78,8 @@ class AppConfigServerTest : BaseIOTest() { localOffset = Duration( Instant.parse("2020-11-03T08:46:03.000Z"), Instant.ofEpochMilli(123456789) - ) + ), + etag = "I am an ETag :)!" ) verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) } @@ -111,7 +115,10 @@ class AppConfigServerTest : BaseIOTest() { @Test fun `missing server date leads to local time fallback`() = runBlockingTest { coEvery { api.getApplicationConfiguration("DE") } returns Response.success( - APPCONFIG_BUNDLE.toResponseBody() + APPCONFIG_BUNDLE.toResponseBody(), + Headers.headersOf( + "ETag", "I am an ETag :)!" + ) ) val downloadServer = createInstance() @@ -120,15 +127,32 @@ class AppConfigServerTest : BaseIOTest() { configDownload shouldBe ConfigDownload( rawData = APPCONFIG_RAW, serverTime = Instant.ofEpochMilli(123456789), - localOffset = Duration.ZERO + localOffset = Duration.ZERO, + etag = "I am an ETag :)!" + ) + } + + @Test + fun `missing server etag leads to exception`() = runBlockingTest { + coEvery { api.getApplicationConfiguration("DE") } returns Response.success( + APPCONFIG_BUNDLE.toResponseBody() ) + + val downloadServer = createInstance() + + shouldThrow<ApplicationConfigurationInvalidException> { + downloadServer.downloadAppConfig() + } } @Test fun `local offset is the difference between server time and local time`() = runBlockingTest { coEvery { api.getApplicationConfiguration("DE") } returns Response.success( APPCONFIG_BUNDLE.toResponseBody(), - Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT") + Headers.headersOf( + "Date", "Tue, 03 Nov 2020 06:35:16 GMT", + "ETag", "I am an ETag :)!" + ) ) every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z") @@ -137,7 +161,8 @@ class AppConfigServerTest : BaseIOTest() { downloadServer.downloadAppConfig() shouldBe ConfigDownload( rawData = APPCONFIG_RAW, serverTime = Instant.parse("2020-11-03T06:35:16.000Z"), - localOffset = Duration.standardHours(-1) + localOffset = Duration.standardHours(-1), + etag = "I am an ETag :)!" ) } @@ -146,7 +171,10 @@ class AppConfigServerTest : BaseIOTest() { val response = spyk( Response.success( APPCONFIG_BUNDLE.toResponseBody(), - Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT") + Headers.headersOf( + "Date", "Tue, 03 Nov 2020 06:35:16 GMT", + "ETag", "I am an ETag :)!" + ) ) ) @@ -163,7 +191,8 @@ class AppConfigServerTest : BaseIOTest() { downloadServer.downloadAppConfig() shouldBe ConfigDownload( rawData = APPCONFIG_RAW, serverTime = Instant.parse("2020-11-03T06:35:16.000Z"), - localOffset = Duration.standardHours(-2) + localOffset = Duration.standardHours(-2), + etag = "I am an ETag :)!" ) } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt index fa15cab904896ee99211904a97fbf2411fbe4621..20ddd9ef77f9e81fc8c08cec1a47d0acc9f23bc2 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt @@ -34,7 +34,8 @@ class AppConfigStorageTest : BaseIOTest() { private val testConfigDownload = ConfigDownload( rawData = APPCONFIG_RAW, serverTime = Instant.parse("2020-11-03T05:35:16.000Z"), - localOffset = Duration.standardHours(1) + localOffset = Duration.standardHours(1), + etag = "I am an ETag :)!" ) @BeforeEach @@ -69,6 +70,7 @@ class AppConfigStorageTest : BaseIOTest() { configPath.readText().toComparableJson() shouldBe """ { "rawData": "$APPCONFIG_BASE64", + "etag": "I am an ETag :)!", "serverTime": 1604381716000, "localOffset": 3600000 } @@ -94,6 +96,7 @@ class AppConfigStorageTest : BaseIOTest() { configPath.readText().toComparableJson() shouldBe """ { "rawData": "$APPCONFIG_BASE64", + "etag": "I am an ETag :)!", "serverTime": 1604381716000, "localOffset": 3600000 } @@ -117,7 +120,8 @@ class AppConfigStorageTest : BaseIOTest() { storage.getStoredConfig() shouldBe ConfigDownload( rawData = APPCONFIG_RAW, serverTime = Instant.ofEpochMilli(1234), - localOffset = Duration.ZERO + localOffset = Duration.ZERO, + etag = "I am an ETag :)!" ) } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSanityCheck.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSanityCheck.kt new file mode 100644 index 0000000000000000000000000000000000000000..0266983e8e99167afc3d1984ab6fa48ec469d8e8 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSanityCheck.kt @@ -0,0 +1,57 @@ +package de.rki.coronawarnapp.appconfig.download + +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import de.rki.coronawarnapp.util.HashExtensions.toSHA256 +import io.kotest.assertions.throwables.shouldNotThrowAny +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import testhelpers.BaseTest +import testhelpers.EmptyApplication + +@Config(sdk = [Build.VERSION_CODES.P], application = EmptyApplication::class) +@RunWith(RobolectricTestRunner::class) +class DefaultAppConfigSanityCheck : BaseTest() { + + private val configName = "default_app_config.bin" + private val checkSumName = "default_app_config.sha256" + + @Before + fun setup() { + MockKAnnotations.init(this) + } + + @After + fun teardown() { + clearAllMocks() + } + + val context: Context + get() = ApplicationProvider.getApplicationContext() + + @Test + fun `current default matches checksum`() { + val config = context.assets.open(configName).readBytes() + val sha256 = context.assets.open(checkSumName).readBytes().toString(Charsets.UTF_8) + sha256 shouldBe "a562bf5940b8c149138634d313db69a298a50e8c52c0b42d18ddf608c385b598" + config.toSHA256() shouldBe sha256 + } + + @Test + fun `current default config can be parsed`() { + shouldNotThrowAny { + val config = context.assets.open(configName).readBytes() + AppConfig.ApplicationConfiguration.parseFrom(config) shouldNotBe null + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSourceTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSourceTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2ddfad5332b90522c39a2f0848db1b3eb6342463 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/DefaultAppConfigSourceTest.kt @@ -0,0 +1,55 @@ +package de.rki.coronawarnapp.appconfig.download + +import android.content.Context +import android.content.res.AssetManager +import de.rki.coronawarnapp.util.HashExtensions.toSHA256 +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseIOTest +import java.io.File + +class DefaultAppConfigSourceTest : BaseIOTest() { + @MockK private lateinit var context: Context + @MockK private lateinit var assetManager: AssetManager + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val configFile = File(testDir, "default_app_config.bin") + private val checksumFile = File(testDir, "default_app_config.sha256") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + every { context.assets } returns assetManager + + every { assetManager.open("default_app_config.bin") } answers { configFile.inputStream() } + every { assetManager.open("default_app_config.sha256") } answers { checksumFile.inputStream() } + + testDir.mkdirs() + testDir.exists() shouldBe true + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createInstance() = DefaultAppConfigSource(context = context) + + @Test + fun `config loaded from asset`() { + val testData = "The Cake Is A Lie" + configFile.writeText(testData) + checksumFile.writeText(testData.toSHA256()) + + val instance = createInstance() + instance.getRawDefaultConfig() shouldBe testData.toByteArray() + } +} 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 d8ce3fd278925df0151f609f257866f3db3371b9..2d0ab66e16af911165826ea1d14c99e651483b62 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 days`() { + val builder = KeyDownloadParameters.KeyDownloadParametersAndroid.newBuilder().apply { + KeyDownloadParameters.DayPackageMetadata.newBuilder().apply { + etag = "\"GoodMorningEtag\"" + region = "EUR" + date = "2020-11-09" + }.let { addRevokedDayPackages(it) } + } + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setAndroidKeyDownloadParameters(builder) .build() + + createInstance().map(rawConfig).apply { + revokedDayPackages.first().apply { + etag shouldBe "\"GoodMorningEtag\"" + region shouldBe LocationCode("EUR") + day shouldBe LocalDate.parse("2020-11-09") + } + } + } + + @Test + fun `parse etag missmatch for hours`() { + val builder = KeyDownloadParameters.KeyDownloadParametersAndroid.newBuilder().apply { + KeyDownloadParameters.HourPackageMetadata.newBuilder().apply { + etag = "\"GoodMorningEtag\"" + region = "EUR" + date = "2020-11-09" + hour = 8 + }.let { addRevokedHourPackages(it) } + } + + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setAndroidKeyDownloadParameters(builder) + .build() + createInstance().map(rawConfig).apply { - keyDownloadParameters shouldBe rawConfig.androidKeyDownloadParameters + revokedHourPackages.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 2552a72dc3554e0879ad28a1bfaf2da77d2dca15..7812c23aba103ae8b16d0b5cbb9d799764fb3776 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/DeadmanNotificationOneTimeWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..3455d8b824e70954665f16e270e3caec8b09ce60 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationOneTimeWorkerTest.kt @@ -0,0 +1,65 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.work.WorkerParameters +import de.rki.coronawarnapp.worker.BackgroundConstants +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationOneTimeWorkerTest : BaseTest() { + + @MockK lateinit var sender: DeadmanNotificationSender + @MockK lateinit var context: Context + @RelaxedMockK lateinit var workerParams: WorkerParameters + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createWorker() = DeadmanNotificationOneTimeWorker( + context = context, + workerParams = workerParams, + sender = sender + ) + + @Test + fun `create worker`() { + createWorker() + } + + @Test + fun `run worker success`() = runBlockingTest { + createWorker().doWork() + + coVerify(exactly = 1) { sender.sendNotification() } + } + + @Test + fun `run worker fail`() = runBlockingTest { + val worker = createWorker() + + worker.runAttemptCount shouldBe 0 + + every { worker.runAttemptCount } returns BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 1 + + worker.doWork() + + coVerify(exactly = 0) { sender.sendNotification() } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..bc53f70fe193dcdf601b01cfd131231f57c6f6d1 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationPeriodicWorkerTest.kt @@ -0,0 +1,65 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.work.WorkerParameters +import de.rki.coronawarnapp.worker.BackgroundConstants +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.impl.annotations.RelaxedMockK +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationPeriodicWorkerTest : BaseTest() { + + @MockK lateinit var scheduler: DeadmanNotificationScheduler + @MockK lateinit var context: Context + @RelaxedMockK lateinit var workerParams: WorkerParameters + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createWorker() = DeadmanNotificationPeriodicWorker( + context = context, + workerParams = workerParams, + scheduler = scheduler + ) + + @Test + fun `create worker`() { + createWorker() + } + + @Test + fun `run worker success`() = runBlockingTest { + createWorker().doWork() + + coVerify(exactly = 1) { scheduler.scheduleOneTime() } + } + + @Test + fun `run worker fail`() = runBlockingTest { + val worker = createWorker() + + worker.runAttemptCount shouldBe 0 + + every { worker.runAttemptCount } returns BackgroundConstants.WORKER_RETRY_COUNT_THRESHOLD + 1 + + worker.doWork() + + coVerify(exactly = 0) { scheduler.scheduleOneTime() } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..26e1708493bb8f3c56d47141493aba8c3a54384f --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSchedulerTest.kt @@ -0,0 +1,112 @@ +package de.rki.coronawarnapp.deadman + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequest +import androidx.work.Operation +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkManager +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.verify +import io.mockk.verifySequence +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationSchedulerTest : BaseTest() { + + @MockK lateinit var timeCalculation: DeadmanNotificationTimeCalculation + @MockK lateinit var workManager: WorkManager + @MockK lateinit var operation: Operation + @MockK lateinit var workBuilder: DeadmanNotificationWorkBuilder + @MockK lateinit var periodicWorkRequest: PeriodicWorkRequest + @MockK lateinit var oneTimeWorkRequest: OneTimeWorkRequest + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { workBuilder.buildPeriodicWork() } returns periodicWorkRequest + every { workBuilder.buildOneTimeWork(any()) } returns oneTimeWorkRequest + every { + workManager.enqueueUniquePeriodicWork( + DeadmanNotificationScheduler.PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + any() + ) + } returns operation + + every { + workManager.enqueueUniqueWork( + DeadmanNotificationScheduler.ONE_TIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + oneTimeWorkRequest + ) + } returns operation + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createScheduler() = DeadmanNotificationScheduler( + timeCalculation = timeCalculation, + workManager = workManager, + workBuilder = workBuilder + ) + + @Test + fun `one time work was scheduled`() = runBlockingTest { + coEvery { timeCalculation.getDelay() } returns 10L + + createScheduler().scheduleOneTime() + + verifySequence { + workManager.enqueueUniqueWork( + DeadmanNotificationScheduler.ONE_TIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + oneTimeWorkRequest + ) + } + } + + @Test + fun `one time work was not scheduled`() = runBlockingTest { + coEvery { timeCalculation.getDelay() } returns -10L + + createScheduler().scheduleOneTime() + + verify(exactly = 0) { + workManager.enqueueUniqueWork( + DeadmanNotificationScheduler.ONE_TIME_WORK_NAME, + ExistingWorkPolicy.REPLACE, + oneTimeWorkRequest + ) + } + + verify(exactly = 0) { + workManager.enqueueUniquePeriodicWork( + any(), any(), any() + ) + } + } + + @Test + fun `test periodic work was scheduled`() { + createScheduler().schedulePeriodic() + + verifySequence { + workManager.enqueueUniquePeriodicWork( + DeadmanNotificationScheduler.PERIODIC_WORK_NAME, + ExistingPeriodicWorkPolicy.REPLACE, + periodicWorkRequest + ) + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..35bae170457dcd40fd29593cfadfc54b91d3f98a --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationSenderTest.kt @@ -0,0 +1,45 @@ +package de.rki.coronawarnapp.deadman + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import de.rki.coronawarnapp.notification.NotificationConstants +import de.rki.coronawarnapp.util.ForegroundState +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationSenderTest : BaseTest() { + + @MockK lateinit var context: Context + @MockK lateinit var foregroundState: ForegroundState + @MockK lateinit var notificationManagerCompat: NotificationManagerCompat + + private val channelId = "de.rki.coronawarnapp.notification.exposureNotificationChannelId" + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.getString(NotificationConstants.NOTIFICATION_CHANNEL_ID) } returns channelId + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createSender() = DeadmanNotificationSender( + context = context, + foregroundState = foregroundState, + notificationManagerCompat = notificationManagerCompat + ) + + @Test + fun `sender creation`() { + createSender() + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..a446d6e317bf97f5cd42e8d234447c8bfc651bb9 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationTimeCalculationTest.kt @@ -0,0 +1,103 @@ +package de.rki.coronawarnapp.deadman + +import de.rki.coronawarnapp.nearby.ENFClient +import de.rki.coronawarnapp.nearby.modules.detectiontracker.TrackedExposureDetection +import de.rki.coronawarnapp.util.TimeStamper +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.verify +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.Instant +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationTimeCalculationTest : BaseTest() { + + @MockK lateinit var timeStamper: TimeStamper + @MockK lateinit var enfClient: ENFClient + @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.lastSuccessfulTrackedExposureDetection() } returns flowOf(mockExposureDetection) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createTimeCalculator() = DeadmanNotificationTimeCalculation( + timeStamper = timeStamper, + enfClient = enfClient + ) + + @Test + fun `12 hours difference`() { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-28T14:00:00.000Z") + + createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T14:00:00.000Z")) shouldBe 720 + } + + @Test + fun `negative time difference`() { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-30T14:00:00.000Z") + + createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T14:00:00.000Z")) shouldBe -2160 + } + + @Test + fun `success in future case`() { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-27T14:00:00.000Z") + + createTimeCalculator().getHoursDiff(Instant.parse("2020-08-27T15:00:00.000Z")) shouldBe 2220 + } + + @Test + fun `12 hours delay`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-28T14:00:00.000Z") + every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z") + + createTimeCalculator().getDelay() shouldBe 720 + + verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() } + } + + @Test + fun `negative delay`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-08-30T14:00:00.000Z") + every { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T14:00:00.000Z") + + createTimeCalculator().getDelay() shouldBe -2160 + + 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 { mockExposureDetection.finishedAt } returns Instant.parse("2020-08-27T15:00:00.000Z") + + createTimeCalculator().getDelay() shouldBe 2220 + + 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.lastSuccessfulTrackedExposureDetection() } returns flowOf(null) + + createTimeCalculator().getDelay() shouldBe 2160 + + verify(exactly = 1) { enfClient.lastSuccessfulTrackedExposureDetection() } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..d7ca037ee3c5ea6416020ae6dd9c7a035e8c71b6 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/deadman/DeadmanNotificationWorkBuilderTest.kt @@ -0,0 +1,56 @@ +package de.rki.coronawarnapp.deadman + +import androidx.work.BackoffPolicy +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DeadmanNotificationWorkBuilderTest : BaseTest() { + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + @Test + fun `onetime work test`() { + testOneTimeWork(10L) + testOneTimeWork(-10L) + testOneTimeWork(0) + } + + /** + * Delay time in minutes + * Backoff delay 8 minutes + */ + private fun testOneTimeWork(delay: Long) { + val periodicWork = DeadmanNotificationWorkBuilder().buildOneTimeWork(delay) + + periodicWork.workSpec.backoffPolicy shouldBe BackoffPolicy.EXPONENTIAL + periodicWork.workSpec.backoffDelayDuration shouldBe 8 * 60 * 1000 + periodicWork.workSpec.initialDelay shouldBe delay * 60 * 1000 + } + + /** + * Delay time in minutes + * Backoff delay 8 minutes + * Interval duration 1 hour + */ + @Test + fun `periodic work test`() { + val periodicWork = DeadmanNotificationWorkBuilder().buildPeriodicWork() + + periodicWork.workSpec.backoffPolicy shouldBe BackoffPolicy.EXPONENTIAL + periodicWork.workSpec.backoffDelayDuration shouldBe 8 * 60 * 1000 + periodicWork.workSpec.intervalDuration shouldBe 60 * 60 * 1000 + } +} 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 0000000000000000000000000000000000000000..0e8a55046adcd4317ced155237004c7f03b1c64c --- /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.RevokedKeyPackage>().apply { + every { etag } returns "etag-badday" + } + val invalidatedHour = mockk<KeyDownloadConfig.RevokedKeyPackage>().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.revokeCachedKeys(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 0000000000000000000000000000000000000000..5cb2f02548a9899777337aceb65b4e4397705575 --- /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 52f54f51b584993994e1ec40df2d8aca36cfd147..434e3a7db13f668c3cca4b9b9a7fb74307c31bfa 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 0000000000000000000000000000000000000000..598e8408c1e8e0aad0fd5f78c4940b13723fa664 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/DayPackageSyncToolTest.kt @@ -0,0 +1,225 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.mapping.RevokedKeyPackage +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.coVerify +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.revokedDayPackages } 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 with 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.revokedDayPackages } returns listOf( + RevokedKeyPackage.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) + keyServer.getDayIndex("EUR".loc) + + keyCache.createCacheEntry(Type.LOCATION_DAY, "EUR".loc, "2020-01-03".day, null) + downloadTool.downloadKeyFile(any(), downloadConfig) + } + } + + @Test + fun `if keys were revoked skip the EXPECT packages check`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T12:12:12.000Z") + mockCachedDay("EUR".loc, "2020-01-01".day) + mockCachedDay("EUR".loc, "2020-01-02".day) + mockCachedDay("EUR".loc, "2020-01-03".day).apply { + every { downloadConfig.revokedDayPackages } returns listOf( + RevokedKeyPackage.Day( + day = info.day, + region = info.location, + etag = info.etag!! + ) + ) + } + + createInstance().syncMissingDayPackages(listOf("EUR".loc), false) + + coVerify(exactly = 1) { keyServer.getDayIndex("EUR".loc) } + } + + @Test + fun `if force-sync is set we skip the EXPECT packages check`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T12:12:12.000Z") + mockCachedDay("EUR".loc, "2020-01-01".day) + mockCachedDay("EUR".loc, "2020-01-02".day) + mockCachedDay("EUR".loc, "2020-01-03".day) + createInstance().syncMissingDayPackages(listOf("EUR".loc), true) + + coVerify(exactly = 1) { keyServer.getDayIndex("EUR".loc) } + } + + @Test + fun `if neither force-sync is set and keys were revoked we check EXPECT NEW PKGS`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T12:12:12.000Z") + mockCachedDay("EUR".loc, "2020-01-01".day) + mockCachedDay("EUR".loc, "2020-01-02".day) + mockCachedDay("EUR".loc, "2020-01-03".day) + createInstance().syncMissingDayPackages(listOf("EUR".loc), false) + + coVerify(exactly = 0) { keyServer.getDayIndex("EUR".loc) } + } +} 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 0000000000000000000000000000000000000000..4aec1691f060b27ae4a8ff466967840a4c80475c --- /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 0000000000000000000000000000000000000000..062d1bfb4c1fbf45e9fb29d274fbd97c0c7ee93a --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/diagnosiskeys/download/HourPackageSyncToolTest.kt @@ -0,0 +1,247 @@ +package de.rki.coronawarnapp.diagnosiskeys.download + +import de.rki.coronawarnapp.appconfig.mapping.RevokedKeyPackage +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.coVerify +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.revokedHourPackages } 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.revokedHourPackages } returns listOf( + RevokedKeyPackage.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 with 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, forceIndexLookup = 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 + } + + @Test + fun `if keys were revoked skip the EXPECT packages check`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z") + mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour) + mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour) + mockCachedHour("EUR".loc, "2020-01-04".day, "02:00".hour).apply { + every { downloadConfig.revokedHourPackages } returns listOf( + RevokedKeyPackage.Hour( + region = info.location, + etag = info.etag!!, + day = info.day, + hour = info.hour!! + ) + ) + } + + createInstance().syncMissingHourPackages(listOf("EUR".loc), false) + + coVerify(exactly = 1) { keyServer.getHourIndex("EUR".loc, "2020-01-04".day) } + } + + @Test + fun `if force-sync is set we skip the EXPECT packages check`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z") + mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour) + mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour) + createInstance().syncMissingHourPackages(listOf("EUR".loc), true) + + coVerify(exactly = 1) { keyServer.getHourIndex("EUR".loc, "2020-01-04".day) } + } + + @Test + fun `if neither force-sync is set and keys were revoked we check EXPECT NEW PKGS`() = runBlockingTest { + every { timeStamper.nowUTC } returns Instant.parse("2020-01-04T02:00:00.000Z") + mockCachedHour("EUR".loc, "2020-01-04".day, "00:00".hour) + mockCachedHour("EUR".loc, "2020-01-04".day, "01:00".hour) + createInstance().syncMissingHourPackages(listOf("EUR".loc), false) + + coVerify(exactly = 0) { keyServer.getHourIndex("EUR".loc, "2020-01-04".day) } + } +} 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 0000000000000000000000000000000000000000..a5d3b18682e4b3bbc471ccfc7380870002c7ed72 --- /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 b105a1b36e76664d7fcf518f640cc56bde8620b5..0000000000000000000000000000000000000000 --- 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 0000000000000000000000000000000000000000..4579828228e565c51d5e07f3738ba8ca657e9b00 --- /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 a8ed796f69eb60e567d503d66f02018d6e43a7d2..85ab7828ae8c3c2fc05955410ebeba28c6d54a5d 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 08aed5e5ad0fcfd395d52169b7c3b3d760e9b049..9f2fea42a597ba9bdfec45dca8a1e87a72b02de0 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 cceb7f2d5481cccdb7f1e6c3edd1afbaf38bf0e4..d240050872a8d3255222638c54eeac35115b54ae 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 cf1d1ebe08debced8dc4ccda7b90eed1e5d0da18..6b399b506179c140115f5a3dcedf7f0f726cf146 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 95fe5150464b5906f94d5555fd0ec10785712d56..2b1b205ee3cf4cf210243710d8d0cb3c200a3b71 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/main/home/HomeFragmentViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt index 91e19f01dea1acbf3fe3d8c71e97398ecdcc1a2b..44a4980d7826e632fd792e724dc1c8d8c807d308 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.main.home import android.content.Context +import de.rki.coronawarnapp.notification.TestResultNotificationService import de.rki.coronawarnapp.storage.TracingRepository import de.rki.coronawarnapp.tracing.GeneralTracingStatus import de.rki.coronawarnapp.tracing.GeneralTracingStatus.Status @@ -8,9 +9,12 @@ import de.rki.coronawarnapp.ui.main.home.HomeFragmentViewModel import de.rki.coronawarnapp.ui.main.home.SubmissionCardState import de.rki.coronawarnapp.ui.main.home.SubmissionCardsStateProvider import de.rki.coronawarnapp.ui.main.home.TracingHeaderState +import de.rki.coronawarnapp.ui.submission.ApiRequestState.SUCCESS import de.rki.coronawarnapp.ui.tracing.card.TracingCardState import de.rki.coronawarnapp.ui.tracing.card.TracingCardStateProvider import de.rki.coronawarnapp.ui.viewmodel.SettingsViewModel +import de.rki.coronawarnapp.util.DeviceUIState.PAIRED_POSITIVE +import de.rki.coronawarnapp.util.DeviceUIState.PAIRED_POSITIVE_TELETAN import de.rki.coronawarnapp.util.security.EncryptionErrorResetTool import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations @@ -21,6 +25,7 @@ import io.mockk.mockk import io.mockk.verify import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -41,6 +46,7 @@ class HomeFragmentViewModelTest : BaseTest() { @MockK lateinit var tracingCardStateProvider: TracingCardStateProvider @MockK lateinit var submissionCardsStateProvider: SubmissionCardsStateProvider @MockK lateinit var tracingRepository: TracingRepository + @MockK lateinit var testResultNotificationService: TestResultNotificationService @BeforeEach fun setup() { @@ -63,7 +69,8 @@ class HomeFragmentViewModelTest : BaseTest() { tracingStatus = generalTracingStatus, tracingCardStateProvider = tracingCardStateProvider, submissionCardsStateProvider = submissionCardsStateProvider, - tracingRepository = tracingRepository + tracingRepository = tracingRepository, + testResultNotificationService = testResultNotificationService ) @Test @@ -118,4 +125,32 @@ class HomeFragmentViewModelTest : BaseTest() { verify { submissionCardsStateProvider.state } } } + + @Test + fun `positive test result notification is triggered on positive QR code result`() { + val state = SubmissionCardState(PAIRED_POSITIVE, true, SUCCESS) + every { submissionCardsStateProvider.state } returns flowOf(state) + every { testResultNotificationService.schedulePositiveTestResultReminder() } returns Unit + + runBlocking { + createInstance().apply { + observeTestResultToSchedulePositiveTestResultReminder() + verify { testResultNotificationService.schedulePositiveTestResultReminder() } + } + } + } + + @Test + fun `positive test result notification is triggered on positive TeleTan code result`() { + val state = SubmissionCardState(PAIRED_POSITIVE_TELETAN, true, SUCCESS) + every { submissionCardsStateProvider.state } returns flowOf(state) + every { testResultNotificationService.schedulePositiveTestResultReminder() } returns Unit + + runBlocking { + createInstance().apply { + observeTestResultToSchedulePositiveTestResultReminder() + verify { testResultNotificationService.schedulePositiveTestResultReminder() } + } + } + } } 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 541eedf627670fbf71a60a819a15cf71e661771d..4880b66aa8e79d52d909cc7e2ecdc9fc47a5ea96 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 976ba6a4b0cc0015cc0636c19c747fac90921fee..0521bdb63cc6523f953df8dd9cf190428c27f25b 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 @@ -16,6 +18,7 @@ import io.mockk.just import io.mockk.verify import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.test.runBlockingTest import org.joda.time.Duration import org.joda.time.Instant @@ -27,10 +30,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 +44,9 @@ class DefaultCalculationTrackerTest : BaseTest() { every { timeStamper.nowUTC } returns Instant.EPOCH coEvery { storage.load() } returns emptyMap() coEvery { storage.save(any()) } just Runs + + coEvery { configProvider.currentConfig } returns flowOf(appConfigData) + every { appConfigData.overallDetectionTimeout } returns Duration.standardMinutes(15) } @AfterEach @@ -46,11 +54,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 +71,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 +85,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 +93,7 @@ class DefaultCalculationTrackerTest : BaseTest() { calculationData.entries.single().apply { key shouldBe expectedIdentifier - value shouldBe Calculation( + value shouldBe TrackedExposureDetection( identifier = expectedIdentifier, startedAt = Instant.EPOCH ) @@ -98,11 +107,13 @@ class DefaultCalculationTrackerTest : BaseTest() { } advanceUntilIdle() } + + coVerify { configProvider.currentConfig } } @Test fun `finish an existing calcluation`() = runBlockingTest2(ignoreActive = true) { - val calcData = Calculation( + val calcData = TrackedExposureDetection( identifier = UUID.randomUUID().toString(), startedAt = Instant.EPOCH ) @@ -112,14 +123,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 +148,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 +162,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 +178,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 +189,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 +206,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 +256,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 4a7c2a019b42c627cf506154457c74be5bf5b1b4..fee9f63fa4177ddbb0fc6819d6788fe67535c514 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 7afb7e551f7e437343d5fcb00a6fef71db8bd69b..d30014e3ca84e5d42ca202c4f4468c9ddf5025d7 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 f0802e4c311c65a1b3d2ea44b276e25b609a93a2..fbc170105e025901200d40c8dc3e40d92a95e3c8 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 6468dda956d4974478fb320e5ec5587ccf77e880..f904964bfddde385950bac6c95efad7247fda59b 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 1b8b83751b885da0467e9b6ffc869eb08a8d4eef..581eab4dc2df49394919ab0679391f98f230333f 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 f04714e342280f267dfd7a55d7f34365b6b43c90..cb0848c9fbbb770cbb8baa2f7c3ee8b4b073b65e 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 d5e513abb6def320fef21f7ebd13c607b82bb1e8..bafa28a2cb134a3c02fe723dd90eb2b9139b1e49 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/task/testtasks/timeout/TimeoutTaskArguments.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/TimeoutTaskArguments.kt index 2195009183fe5794f72afdce27098104b13c8512..1eec8030b8ef5cf18d196ee5f610106054d0d8bd 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/TimeoutTaskArguments.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/task/testtasks/timeout/TimeoutTaskArguments.kt @@ -2,6 +2,5 @@ package de.rki.coronawarnapp.task.testtasks.timeout import de.rki.coronawarnapp.task.Task -@Suppress("MagicNumber") data class TimeoutTaskArguments(val delay: Long = 15 * 1000L) : Task.Arguments diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt index fe2bd9043d442472d3a2b8739ffa07a583545f85..ee57585af900af56835c3bf36089e72e85ece59b 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/tan/SubmissionTanViewModelTest.kt @@ -28,15 +28,19 @@ class SubmissionTanViewModelTest : BaseTest() { viewModel.onTanChanged("ZWFPC7NG47") viewModel.state.value!!.isTanValid shouldBe true + viewModel.state.value!!.isCorrectLength shouldBe true viewModel.onTanChanged("ABC") viewModel.state.value!!.isTanValid shouldBe false + viewModel.state.value!!.isCorrectLength shouldBe false viewModel.onTanChanged("ZWFPC7NG48") viewModel.state.value!!.isTanValid shouldBe false + viewModel.state.value!!.isCorrectLength shouldBe true viewModel.onTanChanged("ZWFPC7NG4A") viewModel.state.value!!.isTanValid shouldBe false + viewModel.state.value!!.isCorrectLength shouldBe true } @Test 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 4a011b972587c5bc14a74e2b0bf002a1e70259ce..a2a9be3c29f49e74b068374d3c3e9d8612186b02 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 @@ -46,6 +46,26 @@ class TanTest : BaseTest() { } } + @Test + fun isTanValid() { + val validTans = arrayOf( + "9A3B578UMG", "DEU7TKSV3H", "PTPHM35RP4", "V923D59AT8", "H9NC5CQ34E" + ) + for (tan in validTans) { + Tan.allCharactersValid(tan) shouldBe true + Tan.isChecksumValid(tan) shouldBe true + (tan.length == Tan.MAX_LENGTH) shouldBe true + } + + // invalid tans due to length and/or invalid characters + val invalidTans = arrayOf( + "ABÖAA1", "-1234", "PTPHM15RP4", "aAASd A" + ) + for (tan in invalidTans) { + Tan.allCharactersValid(tan) shouldBe false + } + } + @Test fun isChecksumValid() { // valid diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt index a6d95594651ced9e26de4315ad9ee6cdb87efe6c..ba00e34847588437cdfc964648d3a06e011fe2e7 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/tracing/card/TracingCardStateTest.kt @@ -146,6 +146,49 @@ class TracingCardStateTest : BaseTest() { } } + @Test + fun `risklevel affected by tracing status`() { + createInstance( + riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS, + tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE + ).apply { + getRiskBody(context) + verify { context.getString(R.string.risk_card_body_tracing_off) } + } + + createInstance( + riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF, + tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE + ).apply { + getRiskBody(context) + verify { context.getString(R.string.risk_card_body_tracing_off) } + } + + createInstance( + riskLevel = UNKNOWN_RISK_INITIAL, + tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE + ).apply { + getRiskBody(context) + verify { context.getString(R.string.risk_card_body_tracing_off) } + } + + createInstance( + riskLevel = LOW_LEVEL_RISK, + tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE + ).apply { + getRiskBody(context) + verify { context.getString(R.string.risk_card_body_tracing_off) } + } + + createInstance( + riskLevel = INCREASED_RISK, + tracingStatus = GeneralTracingStatus.Status.TRACING_INACTIVE + ).apply { + getRiskBody(context) + verify { context.getString(R.string.risk_card_body_tracing_off) } + } + } + @Test fun `saved risk body is affected by risklevel`() { createInstance( @@ -636,82 +679,6 @@ class TracingCardStateTest : BaseTest() { } } - @Test - fun `text for next update time`() { - createInstance( - riskLevel = INCREASED_RISK, - isBackgroundJobEnabled = false - ).apply { - getNextUpdate(context) shouldBe "" - } - - createInstance( - riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS, - isBackgroundJobEnabled = false - ).apply { - getNextUpdate(context) shouldBe "" - } - - createInstance( - riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF, - isBackgroundJobEnabled = false - ).apply { - getNextUpdate(context) shouldBe "" - } - - createInstance( - riskLevel = LOW_LEVEL_RISK, - isBackgroundJobEnabled = false - ).apply { - getNextUpdate(context) shouldBe "" - } - - createInstance( - riskLevel = UNKNOWN_RISK_INITIAL, - isBackgroundJobEnabled = false - ).apply { - getNextUpdate(context) shouldBe "" - } - - createInstance( - riskLevel = INCREASED_RISK, - isBackgroundJobEnabled = true - ).apply { - getNextUpdate(context) - verify { context.getString(R.string.risk_card_body_next_update) } - } - - createInstance( - riskLevel = UNKNOWN_RISK_OUTDATED_RESULTS, - isBackgroundJobEnabled = true - ).apply { - getNextUpdate(context) shouldBe "" - } - - createInstance( - riskLevel = NO_CALCULATION_POSSIBLE_TRACING_OFF, - isBackgroundJobEnabled = true - ).apply { - getNextUpdate(context) shouldBe "" - } - - createInstance( - riskLevel = LOW_LEVEL_RISK, - isBackgroundJobEnabled = true - ).apply { - getNextUpdate(context) - verify { context.getString(R.string.risk_card_body_next_update) } - } - - createInstance( - riskLevel = UNKNOWN_RISK_INITIAL, - isBackgroundJobEnabled = true - ).apply { - getNextUpdate(context) - verify { context.getString(R.string.risk_card_body_next_update) } - } - } - @Test fun `task divider is formatted according to riskLevel`() { createInstance(riskLevel = INCREASED_RISK).apply { 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 0000000000000000000000000000000000000000..2a9e92c6cc8e8a0aa21be016d28a400ea1165798 --- /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 0000000000000000000000000000000000000000..58c53a7fa055cd1a6ce6f4318752046393a8255e --- /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/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt index 37b956e809b2c776896f4f06f37b3f45952c08c4..468877bbaaa72b0dfc9f3177073a15d4b6e7024c 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt @@ -4,6 +4,8 @@ import androidx.work.ListenableWorker import dagger.Component import dagger.Module import dagger.Provides +import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler +import de.rki.coronawarnapp.deadman.DeadmanNotificationSender import de.rki.coronawarnapp.playbook.Playbook import de.rki.coronawarnapp.task.TaskController import de.rki.coronawarnapp.util.di.AssistedInjectModule @@ -75,6 +77,14 @@ class MockProvider { @Provides fun playbook(): Playbook = mockk() + // For DeadmanNotificationScheduler + @Provides + fun sender(): DeadmanNotificationSender = mockk() + + // For DeadmanNotificationPeriodicWorker + @Provides + fun scheduler(): DeadmanNotificationScheduler = mockk() + @Provides fun taskController(): TaskController = mockk() } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundConstantsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundConstantsTest.kt index 161df1e1a35400cdd21eea306492d1dd04fa0e6d..a2247300dd4871e0a949b7bfaba310237c2862b3 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundConstantsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundConstantsTest.kt @@ -8,8 +8,6 @@ class BackgroundConstantsTest { @Test fun allBackgroundConstants() { Assert.assertEquals(BackgroundConstants.MINUTES_IN_DAY, 1440) - Assert.assertEquals(BackgroundConstants.DIAGNOSIS_KEY_RETRIEVAL_TRIES_PER_DAY, 12) - Assert.assertEquals(BackgroundConstants.GOOGLE_API_MAX_CALLS_PER_DAY, 20) Assert.assertEquals(BackgroundConstants.DIAGNOSIS_TEST_RESULT_RETRIEVAL_TRIES_PER_DAY, 12) Assert.assertEquals(BackgroundConstants.KIND_DELAY, 1L) Assert.assertEquals(BackgroundConstants.DIAGNOSIS_TEST_RESULT_PERIODIC_INITIAL_DELAY, 10L) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f77fd6a7e8ebd20758a1949af5652484ac69ac48 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkBuilderTest.kt @@ -0,0 +1,16 @@ +package de.rki.coronawarnapp.worker + +import io.kotest.matchers.shouldBe +import org.joda.time.Duration +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class BackgroundWorkBuilderTest : BaseTest() { + + @Test + fun `worker interval for key retrieval is 60 minutes, once every hour`() { + buildDiagnosisKeyRetrievalPeriodicWork().apply { + workSpec.intervalDuration shouldBe Duration.standardMinutes(60).millis + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkHelperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkHelperTest.kt index eddc5f4c1ccdb514af1cd942950315ecdefd880f..0ab0f5e23f942087b5356443b7d56cec27075be3 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkHelperTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/worker/BackgroundWorkHelperTest.kt @@ -5,13 +5,6 @@ import org.junit.Assert import org.junit.Test class BackgroundWorkHelperTest { - @Test - fun getDiagnosisKeyRetrievalPeriodicWorkTimeInterval() { - Assert.assertEquals( - BackgroundWorkHelper.getDiagnosisKeyRetrievalPeriodicWorkTimeInterval(), - 120 - ) - } @Test fun getDiagnosisTestResultRetrievalPeriodicWorkTimeInterval() { @@ -21,11 +14,6 @@ class BackgroundWorkHelperTest { ) } - @Test - fun getDiagnosisKeyRetrievalMaximumCalls() { - Assert.assertEquals(BackgroundWorkHelper.getDiagnosisKeyRetrievalMaximumCalls(), 12) - } - @Test fun getConstraintsForDiagnosisKeyOneTimeBackgroundWork() { val constraints = BackgroundWorkHelper.getConstraintsForDiagnosisKeyOneTimeBackgroundWork() diff --git a/Corona-Warn-App/src/test/java/testhelpers/EmptyApplication.kt b/Corona-Warn-App/src/test/java/testhelpers/EmptyApplication.kt new file mode 100644 index 0000000000000000000000000000000000000000..5b9c7a4b7aaf8c9e2bc5a5538ebf016684bd44f9 --- /dev/null +++ b/Corona-Warn-App/src/test/java/testhelpers/EmptyApplication.kt @@ -0,0 +1,5 @@ +package testhelpers + +import android.app.Application + +class EmptyApplication : Application() diff --git a/Corona-Warn-App/src/test/java/testhelpers/IsAUnitTest.kt b/Corona-Warn-App/src/test/java/testhelpers/IsAUnitTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2af3e4cb78347fa7849576e9c3f36eac53fe3968 --- /dev/null +++ b/Corona-Warn-App/src/test/java/testhelpers/IsAUnitTest.kt @@ -0,0 +1,3 @@ +package testhelpers + +class IsAUnitTest 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 975a8c48dd522f417915c601cca231fea22e0bf2..3fc56ce9a503dcd65c2afb6f91e657557776c2d8 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 b5a62565aa2ee00e0fb214a9d1c6d53453d7199a..375adf36e462e42753b985160ba5062cbcdc3397 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 0000000000000000000000000000000000000000..33d46578e1a956c645d549edc3f793d2fc9b27df --- /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 +} diff --git a/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto b/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto index 00c58f526b33cb2a6659cfe4939178d32142cf61..6a4518b71769b9ebc6db1610fada5d2cda8a64d7 100644 --- a/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto +++ b/Server-Protocol-Buffer/src/main/proto/internal/key_download_parameters.proto @@ -5,14 +5,14 @@ package de.rki.coronawarnapp.server.protocols.internal; message KeyDownloadParametersIOS { - repeated DayPackageMetadata cachedDayPackagesToUpdateOnETagMismatch = 1; - repeated HourPackageMetadata cachedHourPackagesToUpdateOnETagMismatch = 2; + repeated DayPackageMetadata revokedDayPackages = 1; + repeated HourPackageMetadata revokedHourPackages = 2; } message KeyDownloadParametersAndroid { - repeated DayPackageMetadata cachedDayPackagesToUpdateOnETagMismatch = 1; - repeated HourPackageMetadata cachedHourPackagesToUpdateOnETagMismatch = 2; + repeated DayPackageMetadata revokedDayPackages = 1; + repeated HourPackageMetadata revokedHourPackages = 2; int32 downloadTimeoutInSeconds = 3;