From a9ae4ad8188c89c9604a31cdb7e75e10fe88a528 Mon Sep 17 00:00:00 2001 From: Matthias Urhahn <matthias.urhahn@sap.com> Date: Fri, 2 Oct 2020 15:08:40 +0200 Subject: [PATCH] Improve API test fragment and environment switching (DEV) (#1269) * Define environment for each flavor and build type. * Add smart LiveData class that does async initialization (not on the main thread). * View extensions setGone/setInvisible * Refactor test for api fragment. * Replace ALT environment with a list to select from. * klint * Fix tests * Fix tests * Further flaky test fixes. * Move the debug check for isLast3HourModeEnabled to the settings. The KeyFileDownloader.kt shouldn't be aware of that detail. The settings are what is affected by the build flavors/modes. * Code cleanup and UI improvement of fragment_test_for_a_p_i.xml * Code that retries flaky tests until we found a better solution. Not great, Not terrible... Co-authored-by: Mert Safter <mert.safter@sap.com> --- Corona-Warn-App/build.gradle | 24 +- .../test/api/ui/DebugOptionsState.kt | 6 + .../test/api/ui/EnvironmentState.kt | 21 + .../test/api/ui/GoogleServicesState.kt | 5 + .../coronawarnapp/test/api/ui/LoggerState.kt | 13 + .../test/api/ui/TestForAPIFragment.kt | 376 +++++------ .../api/ui/TestForApiFragmentViewModel.kt | 107 ++- .../res/layout/fragment_test_for_a_p_i.xml | 629 +++++++++++------- .../download/KeyFileDownloader.kt | 3 +- .../environment/BuildConfigWrap.kt | 1 - .../environment/EnvironmentSetup.kt | 16 +- .../rki/coronawarnapp/storage/AppSettings.kt | 3 +- .../util/ui/LiveDataExtensions.kt | 15 + .../coronawarnapp/util/ui/SmartLiveData.kt | 53 ++ .../coronawarnapp/util/ui/ViewExtensions.kt | 11 + .../download/KeyFileDownloaderTest.kt | 57 +- .../environment/BuildConfigWrapTest.kt | 10 +- .../environment/EnvironmentSetupTest.kt | 25 +- .../test/java/testhelpers/KotestExtensions.kt | 27 + .../api/ui/TestForApiFragmentViewModelTest.kt | 72 +- 20 files changed, 901 insertions(+), 573 deletions(-) create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/EnvironmentState.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/GoogleServicesState.kt create mode 100644 Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/LoggerState.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/SmartLiveData.kt create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/ViewExtensions.kt create mode 100644 Corona-Warn-App/src/test/java/testhelpers/KotestExtensions.kt diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle index f96ff2b5c..a45a78f75 100644 --- a/Corona-Warn-App/build.gradle +++ b/Corona-Warn-App/build.gradle @@ -28,7 +28,7 @@ apply plugin: 'jacoco' def environmentExtractor = { File path -> def rawJson = path.text def escapedJson = rawJson.replace("\"", "\\\"").replace("\n", "").replace("\r", "") - "\"${escapedJson}\"" + return "\"${escapedJson}\"" } android { @@ -47,14 +47,11 @@ android { resConfigs "de", "en", "tr", "bg", "pl", "ro" - buildConfigField "String", "ENVIRONMENT_TYPE_DEFAULT", "\"PROD\"" - buildConfigField "String", "ENVIRONMENT_TYPE_ALTERNATIVE", "\"PROD\"" - def prodEnvJson = environmentExtractor(file("../prod_environments.json")) buildConfigField "String", "ENVIRONMENT_JSONDATA", prodEnvJson def devEnvironmentFile = file("../test_environments.json") - if(devEnvironmentFile.exists()) { + if (devEnvironmentFile.exists()) { def devEnvJson = environmentExtractor(devEnvironmentFile) buildConfigField "String", "ENVIRONMENT_JSONDATA", devEnvJson } @@ -76,24 +73,35 @@ android { } } - // One version contains our Test Fragments + flavorDimensions "version" productFlavors { device { dimension "version" buildConfigField "String", "BUILD_VARIANT", "\"device\"" resValue "string", "app_name", "Corona-Warn" + + ext { + envTypeDefault = [debug: "INT", release: "PROD"] + } } deviceForTesters { + // Contains test fragments dimension "version" buildConfigField "String", "BUILD_VARIANT", "\"deviceForTesters\"" resValue "string", "app_name", "CWA TEST" applicationIdSuffix '.dev' - buildConfigField "String", "ENVIRONMENT_TYPE_DEFAULT", "\"DEV\"" - buildConfigField "String", "ENVIRONMENT_TYPE_ALTERNATIVE", "\"WRU-XA\"" + ext { + envTypeDefault = [debug: "INT", release: "WRU-XD"] + } } } + applicationVariants.all { variant -> + def flavor = variant.productFlavors[0] + def typeName = variant.buildType.name // debug/release + variant.buildConfigField "String", "ENVIRONMENT_TYPE_DEFAULT", "\"${flavor.envTypeDefault[typeName]}\"" + } buildFeatures { dataBinding true 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 new file mode 100644 index 000000000..a86be3932 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/DebugOptionsState.kt @@ -0,0 +1,6 @@ +package de.rki.coronawarnapp.test.api.ui + +data class DebugOptionsState( + val areNotificationsEnabled: Boolean, + val is3HourModeEnabled: Boolean +) diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/EnvironmentState.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/EnvironmentState.kt new file mode 100644 index 000000000..239f7dcbb --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/EnvironmentState.kt @@ -0,0 +1,21 @@ +package de.rki.coronawarnapp.test.api.ui + +import de.rki.coronawarnapp.environment.EnvironmentSetup + +data class EnvironmentState( + val current: EnvironmentSetup.Type, + val available: List<EnvironmentSetup.Type>, + val urlSubmission: String, + val urlDownload: String, + val urlVerification: String +) { + companion object { + internal fun EnvironmentSetup.toEnvironmentState() = EnvironmentState( + current = currentEnvironment, + available = EnvironmentSetup.Type.values().toList(), + urlSubmission = submissionCdnUrl, + urlDownload = downloadCdnUrl, + urlVerification = verificationCdnUrl + ) + } +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/GoogleServicesState.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/GoogleServicesState.kt new file mode 100644 index 000000000..c60522129 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/GoogleServicesState.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.test.api.ui + +data class GoogleServicesState( + val version: Long +) diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/LoggerState.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/LoggerState.kt new file mode 100644 index 000000000..3244c5f34 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/LoggerState.kt @@ -0,0 +1,13 @@ +package de.rki.coronawarnapp.test.api.ui + +import de.rki.coronawarnapp.util.CWADebug + +data class LoggerState( + val isLogging: Boolean +) { + companion object { + internal fun CWADebug.toLoggerState() = LoggerState( + isLogging = fileLogger?.isLogging ?: false + ) + } +} 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 6f8e10d24..7acc24c29 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,5 +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 @@ -12,16 +13,17 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputMethodManager import android.widget.ImageView +import android.widget.RadioButton +import android.widget.RadioGroup import android.widget.Toast -import androidx.core.content.pm.PackageInfoCompat +import androidx.core.view.ViewCompat.generateViewId +import androidx.core.view.children import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.widget.ViewPager2 -import com.google.android.gms.common.GoogleApiAvailability -import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration import com.google.android.gms.nearby.exposurenotification.ExposureNotificationClient import com.google.android.gms.nearby.exposurenotification.ExposureSummary import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey @@ -52,18 +54,16 @@ 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.transaction.RiskLevelTransaction import de.rki.coronawarnapp.ui.viewmodel.TracingViewModel -import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.KeyFileHelper import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.di.AutoInject import de.rki.coronawarnapp.util.ui.observe2 +import de.rki.coronawarnapp.util.ui.setGone import de.rki.coronawarnapp.util.ui.viewBindingLazy import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider import de.rki.coronawarnapp.util.viewmodel.cwaViewModels import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.joda.time.DateTime @@ -108,7 +108,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), private var token: String? = null // ViewModel for MainActivity - private val tracingViewModel: TracingViewModel by activityViewModels() + private val tracingVM: TracingViewModel by activityViewModels() private lateinit var qrPager: ViewPager2 private lateinit var qrPagerAdapter: RecyclerView.Adapter<QRPagerAdapter.QRViewHolder> @@ -118,19 +118,11 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), private var lastSetCountries: List<String>? = null + @SuppressLint("SetTextI18n") override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.tracingViewModel = tracingViewModel - - val v: Long = PackageInfoCompat.getLongVersionCode( - activity?.packageManager!!.getPackageInfo( - GoogleApiAvailability.GOOGLE_PLAY_SERVICES_PACKAGE, - 0 - ) - ) - binding.labelGooglePlayServicesVersion.text = - "Google Play Services version: " + v.toString() + binding.tracingViewModel = tracingVM token = UUID.randomUUID().toString() @@ -143,150 +135,173 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), qrPagerAdapter = QRPagerAdapter() qrPager.adapter = qrPagerAdapter - // Load countries from App config and update Country UI element states - lifecycleScope.launch { - lastSetCountries = - ApplicationConfigurationService.asyncRetrieveApplicationConfiguration() - .supportedCountriesList - - binding.inputCountryCodesEditText.setText( - lastSetCountries?.joinToString( - "," - ) - ) - - updateCountryStatusLabel() + // Debug card + binding.threeHourModeToggle.apply { + setOnClickListener { vm.setLast3HoursMode(isChecked) } } - - binding.buttonApiTestStart.setOnClickListener { - start() + vm.last3HourToggleEvent.observe2(this) { + showToast("Last 3 Hours Mode is activated: $it") } - - binding.buttonApiGetExposureKeys.setOnClickListener { - getExposureKeys() + binding.backgroundNotificationsToggle.apply { + setOnClickListener { vm.setBackgroundNotifications(isChecked) } } - - val last3HoursSwitch = binding.testApiSwitchLastThreeHoursFromServer - last3HoursSwitch.isChecked = LocalData.last3HoursMode() - last3HoursSwitch.setOnClickListener { - vm.setLast3HoursMode(last3HoursSwitch.isChecked) + vm.backgroundNotificationsToggleEvent.observe2(this@TestForAPIFragment) { + showToast("Background Notifications are activated: $it") } - - vm.last3HourToggleEvent.observe2(this) { - showToast("Last 3 Hours Mode is activated: $it") + vm.debugOptionsState.observe2(this) { state -> + binding.apply { + backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled + threeHourModeToggle.isChecked = state.is3HourModeEnabled + } } - - val backgroundNotificationSwitch = binding.testApiSwitchBackgroundNotifications - backgroundNotificationSwitch.isChecked = LocalData.backgroundNotification() - backgroundNotificationSwitch.setOnClickListener { - val isBackgroundNotificationsActive = backgroundNotificationSwitch.isChecked - showToast("Background Notifications are activated: $isBackgroundNotificationsActive") - LocalData.backgroundNotification(isBackgroundNotificationsActive) + binding.testLogfileToggle.apply { + setOnClickListener { vm.setLoggerEnabled(isChecked) } } + vm.loggerState.observe2(this) { state -> + binding.apply { + testLogfileToggle.isChecked = state.isLogging + testLogfileShare.setGone(!state.isLogging) + } + } + binding.testLogfileShare.setOnClickListener { vm.shareLogFile() } + vm.logShareEvent.observe2(this) { showToast("Logfile copied to $it") } - val testCountriesSwitch = binding.testApiSwitchTestCountries - testCountriesSwitch.isChecked = vm.isCurrentEnvironmentAlternate() - testCountriesSwitch.setOnClickListener { - vm.toggleEnvironment(testCountriesSwitch.isChecked) + // Server environment card + binding.environmentToggleGroup.apply { + setOnCheckedChangeListener { group, checkedId -> + val chip = group.findViewById<RadioButton>(checkedId) + if (!chip.isPressed) return@setOnCheckedChangeListener + vm.selectEnvironmentTytpe(chip.text.toString()) + } } + vm.environmentState.observe2(this) { state -> + binding.apply { + if (environmentToggleGroup.childCount != state.available.size) { + environmentToggleGroup.removeAllViews() + state.available.forEach { type -> + RadioButton(requireContext()).apply { + id = generateViewId() + text = type.rawKey + layoutParams = RadioGroup.LayoutParams( + RadioGroup.LayoutParams.MATCH_PARENT, + RadioGroup.LayoutParams.WRAP_CONTENT + ) + environmentToggleGroup.addView(this) + } + } + } + + environmentToggleGroup.children.forEach { + it as RadioButton + it.isChecked = it.text == state.current.rawKey + } + + environmentCdnurlDownload.text = "Download CDN:\n${state.urlDownload}" + environmentCdnurlSubmission.text = "Submission CDN:\n${state.urlSubmission}" + environmentCdnurlVerification.text = "Verification CDN:\n${state.urlVerification}" + } + } vm.environmentChangeEvent.observe2(this) { - showSnackBar( - "Environment changed to: $it" + - "\nForce stop & restart the app!" - ) + showSnackBar("Environment changed to: $it\nForce stop & restart the app!") } - binding.buttonApiGetCheckExposure.setOnClickListener { - checkExposure() + // GMS Info card + vm.gmsState.observe2(this) { state -> + binding.googlePlayServicesVersionInfo.text = + "Google Play Services version: ${state.version}" } - binding.buttonApiScanQrCode.setOnClickListener { - IntentIntegrator.forSupportFragment(this) - .setOrientationLocked(false) - .setBeepEnabled(false) - .initiateScan() - } + // Test action card + binding.apply { + buttonApiTestStart.setOnClickListener { start() } + buttonApiGetExposureKeys.setOnClickListener { getExposureKeys() } + buttonApiGetCheckExposure.setOnClickListener { checkExposure() } - binding.buttonApiShareMyKeys.setOnClickListener { - shareMyKeys() - } + buttonApiScanQrCode.setOnClickListener { + IntentIntegrator.forSupportFragment(this@TestForAPIFragment) + .setOrientationLocked(false) + .setBeepEnabled(false) + .initiateScan() + } - binding.buttonApiEnterOtherKeys.setOnClickListener { - enterOtherKeys() - } + buttonApiShareMyKeys.setOnClickListener { shareMyKeys() } + buttonApiEnterOtherKeys.setOnClickListener { enterOtherKeys() } - binding.buttonApiSubmitKeys.setOnClickListener { - tracingViewModel.viewModelScope.launch { - try { - internalExposureNotificationPermissionHelper.requestPermissionToShareKeys() + buttonApiSubmitKeys.setOnClickListener { + tracingVM.viewModelScope.launch { + try { + internalExposureNotificationPermissionHelper.requestPermissionToShareKeys() - // SubmitDiagnosisKeysTransaction.start("123") - withContext(Dispatchers.Main) { - showToast("Key submission successful") + // SubmitDiagnosisKeysTransaction.start("123") + withContext(Dispatchers.Main) { + showToast("Key submission successful") + } + } catch (e: TransactionException) { + e.report(INTERNAL) } - } catch (e: TransactionException) { - e.report(INTERNAL) } } - } - binding.buttonCalculateRiskLevel.setOnClickListener { - tracingViewModel.viewModelScope.launch { - try { - RiskLevelTransaction.start() - } catch (e: TransactionException) { - e.report(INTERNAL) - } - } - } + buttonCalculateRiskLevel.setOnClickListener { vm.calculateRiskLevelClicked() } - binding.buttonInsertExposureSummary.setOnClickListener { - // Now broadcasts them to the worker. - val intent = Intent( - context, - ExposureStateUpdateReceiver::class.java - ) - intent.action = ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED - context?.sendBroadcast(intent) - } - - binding.buttonRetrieveExposureSummary.setOnClickListener { - tracingViewModel.viewModelScope.launch { - showToast( - ExposureSummaryRepository.getExposureSummaryRepository() - .getExposureSummaryEntities().toString() + buttonInsertExposureSummary.setOnClickListener { + // Now broadcasts them to the worker. + val intent = Intent( + context, + ExposureStateUpdateReceiver::class.java ) + intent.action = ExposureNotificationClient.ACTION_EXPOSURE_STATE_UPDATED + context?.sendBroadcast(intent) } - } - binding.buttonClearDb.setOnClickListener { - tracingViewModel.viewModelScope.launch { - withContext(Dispatchers.IO) { - AppDatabase.getInstance(requireContext()).clearAllTables() + buttonRetrieveExposureSummary.setOnClickListener { + tracingVM.viewModelScope.launch { + showToast( + ExposureSummaryRepository.getExposureSummaryRepository() + .getExposureSummaryEntities().toString() + ) } } - } - binding.buttonTracingIntervals.setOnClickListener { - tracingViewModel.viewModelScope.launch { - showToast( - TracingIntervalRepository.getDateRepository(requireContext()).getIntervals() - .toString() - ) + buttonClearDb.setOnClickListener { + tracingVM.viewModelScope.launch { + withContext(Dispatchers.IO) { + AppDatabase.getInstance(requireContext()).clearAllTables() + } + } + } + + buttonTracingIntervals.setOnClickListener { + tracingVM.viewModelScope.launch { + showToast( + TracingIntervalRepository.getDateRepository(requireContext()).getIntervals() + .toString() + ) + } } - } - binding.buttonTracingDurationInRetentionPeriod.setOnClickListener { - tracingViewModel.viewModelScope.launch { - showToast(TimeVariables.getActiveTracingDaysInRetentionPeriod().toString()) + buttonTracingDurationInRetentionPeriod.setOnClickListener { + tracingVM.viewModelScope.launch { + showToast(TimeVariables.getActiveTracingDaysInRetentionPeriod().toString()) + } } } - binding.buttonFilterCountryCodes.setOnClickListener { - filterCountryCodes() - } + // Country benchmark card + // Load countries from App config and update Country UI element states + lifecycleScope.launch { + lastSetCountries = + ApplicationConfigurationService.asyncRetrieveApplicationConfiguration() + .supportedCountriesList + + binding.inputCountryCodesEditText.setText( + lastSetCountries?.joinToString(",") + ) + updateCountryStatusLabel() + } + binding.buttonFilterCountryCodes.setOnClickListener { filterCountryCodes() } binding.buttonRetrieveDiagnosisKeysAndCalcRiskLevel.setOnClickListener { startKeyRetrievalAndRiskCalcBenchmark() } @@ -297,51 +312,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), } false } - - binding.testLogfileToggle.isChecked = CWADebug.fileLogger?.isLogging ?: false - binding.testLogfileToggle.setOnClickListener { buttonView -> - CWADebug.fileLogger?.let { - if (binding.testLogfileToggle.isChecked) { - it.start() - } else { - it.stop() - } - } - } - - binding.testLogfileShare.setOnClickListener { - CWADebug.fileLogger?.let { - lifecycleScope.launch { - val targetPath = withContext(Dispatchers.IO) { - async { - if (!it.logFile.exists()) return@async null - - val externalPath = File( - requireContext().getExternalFilesDir(null), - "LogFile-${System.currentTimeMillis()}.log" - ) - - it.logFile.copyTo(externalPath) - - return@async externalPath - } - }.await() - if (targetPath != null) { - Toast.makeText( - requireActivity(), - "Logfile copied to $targetPath", - Toast.LENGTH_SHORT - ).show() - } else { - Toast.makeText( - requireActivity(), - "No log file available", - Toast.LENGTH_SHORT - ).show() - } - } - } - } } override fun onResume() { @@ -372,7 +342,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), val rawCountryCodes = binding.inputCountryCodesEditText.text.toString() // Country codes can be separated by space or , - var countryCodes = rawCountryCodes.split(',', ' ').filter { it.isNotEmpty() } + val countryCodes = rawCountryCodes.split(',', ' ').filter { it.isNotEmpty() } lastSetCountries = countryCodes @@ -397,7 +367,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), */ private fun updateCountryStatusLabel() { binding.labelCountryCodeFilterStatus.text = "Country filter applied for: \n " + - "${lastSetCountries?.joinToString(",")}" + "${lastSetCountries?.joinToString(",")}" } private val prettyKey = { key: AppleLegacyKeyExchange.Key -> @@ -428,7 +398,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), IntentIntegrator.parseActivityResult(requestCode, resultCode, data) if (result != null) { if (result.contents == null) { - Toast.makeText(requireContext(), "Cancelled", Toast.LENGTH_LONG).show() + showToast("Cancelled") } else { ExposureSharingService.getOthersKeys(result.contents, onScannedKey) } @@ -484,11 +454,10 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), .build() ) - val dir = - File( - File(requireContext().getExternalFilesDir(null), "key-export"), - token ?: "" - ) + val dir = File( + File(requireContext().getExternalFilesDir(null), "key-export"), + token ?: "" + ) dir.mkdirs() var googleFileList: List<File> @@ -582,12 +551,11 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), } private fun showToast(message: String) { - val toast = Toast.makeText(context, message, Toast.LENGTH_LONG) - toast.show() + Toast.makeText(context, message, Toast.LENGTH_LONG).show() } private fun showSnackBar(message: String) { - view?.let { Snackbar.make(it, message, Snackbar.LENGTH_LONG) }?.show() + Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show() } override fun onFailure(exception: Exception?) { @@ -606,50 +574,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), updateKeysDisplay() } - private fun getCustomConfig(): ExposureConfiguration = ExposureConfiguration - .ExposureConfigurationBuilder() - .setAttenuationScores( - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE - ) - .setDaysSinceLastExposureScores( - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE - ) - .setDurationScores( - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE - ) - .setTransmissionRiskScores( - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE, - CONFIG_SCORE - ) - .build() - private inner class QRPagerAdapter : RecyclerView.Adapter<QRPagerAdapter.QRViewHolder>() { diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt index 935eb809c..5593009bb 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModel.kt @@ -1,31 +1,122 @@ package de.rki.coronawarnapp.test.api.ui +import android.content.Context +import androidx.core.content.pm.PackageInfoCompat +import androidx.lifecycle.viewModelScope +import com.google.android.gms.common.GoogleApiAvailability import com.squareup.inject.assisted.AssistedInject import de.rki.coronawarnapp.environment.EnvironmentSetup +import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType +import de.rki.coronawarnapp.exception.ExceptionCategory +import de.rki.coronawarnapp.exception.TransactionException +import de.rki.coronawarnapp.exception.reporting.report import de.rki.coronawarnapp.storage.LocalData +import de.rki.coronawarnapp.test.api.ui.EnvironmentState.Companion.toEnvironmentState +import de.rki.coronawarnapp.test.api.ui.LoggerState.Companion.toLoggerState +import de.rki.coronawarnapp.transaction.RiskLevelTransaction +import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.ui.SingleLiveEvent +import de.rki.coronawarnapp.util.ui.smartLiveData import de.rki.coronawarnapp.util.viewmodel.CWAViewModel import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File class TestForApiFragmentViewModel @AssistedInject constructor( + private val context: Context, private val envSetup: EnvironmentSetup ) : CWAViewModel() { + val debugOptionsState by smartLiveData { + DebugOptionsState( + areNotificationsEnabled = LocalData.backgroundNotification(), + is3HourModeEnabled = LocalData.last3HoursMode() + ) + } + val last3HourToggleEvent = SingleLiveEvent<Boolean>() + + fun setLast3HoursMode(enabled: Boolean) { + debugOptionsState.update { + LocalData.last3HoursMode(enabled) + it.copy(is3HourModeEnabled = enabled) + } + last3HourToggleEvent.postValue(enabled) + } + + val environmentState by smartLiveData { + envSetup.toEnvironmentState() + } val environmentChangeEvent = SingleLiveEvent<EnvironmentSetup.Type>() - fun setLast3HoursMode(isLast3HoursModeEnabled: Boolean) { - LocalData.last3HoursMode(isLast3HoursModeEnabled) - last3HourToggleEvent.postValue(isLast3HoursModeEnabled) + fun selectEnvironmentTytpe(type: String) { + environmentState.update { + envSetup.currentEnvironment = type.toEnvironmentType() + environmentChangeEvent.postValue(envSetup.currentEnvironment) + envSetup.toEnvironmentState() + } } - fun toggleEnvironment(isTestCountyEnabled: Boolean) { - envSetup.currentEnvironment = if (isTestCountyEnabled) envSetup.alternativeEnvironment else envSetup.defaultEnvironment - environmentChangeEvent.postValue(envSetup.currentEnvironment) + val backgroundNotificationsToggleEvent = SingleLiveEvent<Boolean>() + + fun setBackgroundNotifications(enabled: Boolean) { + debugOptionsState.update { + LocalData.backgroundNotification(enabled) + it.copy(areNotificationsEnabled = enabled) + } + backgroundNotificationsToggleEvent.postValue(enabled) + } + + val loggerState by smartLiveData { + CWADebug.toLoggerState() + } + + fun setLoggerEnabled(enable: Boolean) { + CWADebug.fileLogger?.let { + if (enable) it.start() else it.stop() + } + loggerState.update { CWADebug.toLoggerState() } + } + + fun calculateRiskLevelClicked() { + viewModelScope.launch { + try { + RiskLevelTransaction.start() + } catch (e: TransactionException) { + e.report(ExceptionCategory.INTERNAL) + } + } + } + + val logShareEvent = SingleLiveEvent<File?>() + + fun shareLogFile() { + CWADebug.fileLogger?.let { + viewModelScope.launch(context = Dispatchers.Default) { + if (!it.logFile.exists()) return@launch + + val externalPath = File( + context.getExternalFilesDir(null), + "LogFile-${System.currentTimeMillis()}.log" + ) + + it.logFile.copyTo(externalPath) + + logShareEvent.postValue(externalPath) + } + } } - fun isCurrentEnvironmentAlternate(): Boolean { - return envSetup.currentEnvironment == envSetup.alternativeEnvironment + val gmsState by smartLiveData { + GoogleServicesState( + version = PackageInfoCompat.getLongVersionCode( + context.packageManager.getPackageInfo( + GoogleApiAvailability.GOOGLE_PLAY_SERVICES_PACKAGE, + 0 + ) + ) + ) } @AssistedInject.Factory 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 313cde74b..9f51b5f86 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 @@ -1,6 +1,7 @@ <?xml version="1.0" encoding="utf-8"?> -<layout xmlns:tools="http://schemas.android.com/tools" - xmlns:android="http://schemas.android.com/apk/res/android" +<layout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" tools:ignore="HardcodedText"> <data> @@ -18,326 +19,456 @@ <LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_margin="@dimen/spacing_normal" + android:layout_margin="@dimen/spacing_tiny" android:orientation="vertical"> - <TextView - android:id="@+id/label_googlePlayServices_version" + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/debug_container" + style="@style/card" + android:layout_margin="@dimen/spacing_tiny" android:layout_width="match_parent" - android:layout_height="wrap_content" /> - - - <Switch - android:id="@+id/test_api_switch_last_three_hours_from_server" - style="@style/body1" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/test_api_switch_last_three_hours_from_server" - android:theme="@style/switchBase" /> - - <Switch - android:id="@+id/test_api_switch_background_notifications" - style="@style/body1" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingTop="8dp" - android:text="@string/test_api_switch_background_notifications" - android:theme="@style/switchBase" /> + android:layout_height="wrap_content"> - <Switch - android:id="@+id/test_api_switch_test_countries" - style="@style/body1" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:paddingTop="8dp" - android:text="Switch server environment" - android:theme="@style/switchBase" /> + <TextView + android:id="@+id/debug_container_title" + style="@style/headline6" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Debug options" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - <LinearLayout - android:layout_width="match_parent" - android:orientation="horizontal" - android:paddingTop="8dp" - android:layout_height="wrap_content"> + <Switch + android:id="@+id/three_hour_mode_toggle" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="0dp" + android:layout_marginTop="@dimen/spacing_small" + android:text="@string/test_api_switch_last_three_hours_from_server" + android:theme="@style/switchBase" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/debug_container_title" /> - <Button - android:layout_width="wrap_content" + <Switch + android:id="@+id/background_notifications_toggle" + style="@style/body1" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:id="@+id/test_logfile_share" - android:text="Share log" /> + android:layout_marginTop="@dimen/spacing_tiny" + android:text="@string/test_api_switch_background_notifications" + android:theme="@style/switchBase" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/three_hour_mode_toggle" /> <Switch android:id="@+id/test_logfile_toggle" style="@style/body1" - android:layout_weight="1" android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:layout_weight="1" android:text="Logfile enabled" - android:theme="@style/switchBase" /> - + android:theme="@style/switchBase" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/background_notifications_toggle" /> - </LinearLayout> - - <TextView - android:id="@+id/label_exposure_summary" - style="@style/headline6" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="16dp" - android:accessibilityHeading="true" - android:text="@string/test_api_exposure_summary_headline" /> + <Button + android:id="@+id/test_logfile_share" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="Share log" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@+id/test_logfile_toggle" /> + </androidx.constraintlayout.widget.ConstraintLayout> - <TextView - android:id="@+id/label_exposure_summary_matchedKeyCount" + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/environment_container" + style="@style/card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="@string/test_api_body_matchedKeyCount" /> + android:layout_margin="@dimen/spacing_tiny"> - <TextView - android:id="@+id/label_exposure_summary_daysSinceLastExposure" + <TextView + android:id="@+id/environment_title" + style="@style/headline6" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Server environment" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <TextView + android:id="@+id/environment_cdnurl_download" + style="@style/body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/environment_title" + tools:text="Download: ?" /> + + <TextView + android:id="@+id/environment_cdnurl_submission" + style="@style/body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_download" + tools:text="Submission: ?" /> + + <TextView + android:id="@+id/environment_cdnurl_verification" + style="@style/body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_submission" + tools:text="Verification: ?" /> + + <RadioGroup + android:id="@+id/environment_toggle_group" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:orientation="vertical" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/environment_cdnurl_verification" /> + </androidx.constraintlayout.widget.ConstraintLayout> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/gms_container" + style="@style/card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="@string/test_api_body_daysSinceLastExposure" /> + android:layout_margin="@dimen/spacing_tiny"> - <TextView - android:id="@+id/label_exposure_summary_maximumRiskScore" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/test_api_body_maximumRiskScore" /> + <TextView + android:id="@+id/gms_container_title" + style="@style/headline6" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="GMS Infos" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <TextView + android:id="@+id/google_play_services_version_info" + style="@style/body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="Google Play Services Version: ?" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/gms_container_title" /> - <TextView - android:id="@+id/label_exposure_summary_summationRiskScore" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="@string/test_api_body_summation_risk" /> + </androidx.constraintlayout.widget.ConstraintLayout> - <TextView - android:id="@+id/label_exposure_summary_attenuation" + <LinearLayout + android:id="@+id/exposure_summary_container" + style="@style/card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="@string/test_api_body_attenuation" /> + android:layout_margin="@dimen/spacing_tiny" + android:orientation="vertical"> - <Button - android:id="@+id/button_api_scan_qr_code" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="@string/test_api_button_scan_qr_code" /> + <TextView + android:id="@+id/label_exposure_summary" + style="@style/headline6" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/spacing_tiny" + android:text="@string/test_api_exposure_summary_headline" /> - <Button - android:id="@+id/button_api_enter_other_keys" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="@string/test_api_button_enter_other_keys" /> + <TextView + android:id="@+id/label_exposure_summary_matchedKeyCount" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/test_api_body_matchedKeyCount" /> - <Button - android:id="@+id/button_api_get_check_exposure" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="@string/test_api_button_check_exposure" /> + <TextView + android:id="@+id/label_exposure_summary_daysSinceLastExposure" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/test_api_body_daysSinceLastExposure" /> - <TextView - android:id="@+id/label_my_keys" - style="@style/headline6" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:accessibilityHeading="true" - android:text="@string/test_api_body_my_keys" /> + <TextView + android:id="@+id/label_exposure_summary_maximumRiskScore" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/test_api_body_maximumRiskScore" /> - <TextView - android:id="@+id/label_latest_key_date" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="Latest key is from: -" /> + <TextView + android:id="@+id/label_exposure_summary_summationRiskScore" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/test_api_body_summation_risk" /> - <TextView - android:id="@+id/text_my_keys" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:hint="Your keys will be displayed here" - android:lines="5" - android:visibility="gone" /> + <TextView + android:id="@+id/label_exposure_summary_attenuation" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/test_api_body_attenuation" /> - <androidx.viewpager2.widget.ViewPager2 - android:id="@+id/qr_code_viewpager" - android:layout_width="match_parent" - android:layout_height="200dp" /> + <Button + android:id="@+id/button_api_scan_qr_code" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_small" + android:text="@string/test_api_button_scan_qr_code" /> - <TextView - android:id="@+id/label_other_keys" - style="@style/headline6" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:accessibilityHeading="true" - android:text="@string/test_api_body_other_keys" /> + <Button + android:id="@+id/button_api_enter_other_keys" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="@string/test_api_button_enter_other_keys" /> - <TextView - android:id="@+id/text_scanned_key" - android:layout_width="match_parent" - android:layout_height="wrap_content" /> + <Button + android:id="@+id/button_api_get_check_exposure" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="@string/test_api_button_check_exposure" /> + </LinearLayout> - <Button - android:id="@+id/button_api_test_start" - style="@style/buttonPrimary" + <LinearLayout + android:id="@+id/mykeys_container" + style="@style/card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="@string/test_api_button_start" /> + android:layout_margin="@dimen/spacing_tiny" + android:orientation="vertical"> - <Button - android:id="@+id/button_api_get_exposure_keys" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="@string/test_api_button_get_exposure_keys" /> + <TextView + android:id="@+id/label_my_keys" + style="@style/headline6" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginBottom="@dimen/spacing_tiny" + android:text="@string/test_api_body_my_keys" /> - <Button - android:id="@+id/button_api_submit_keys" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="@string/test_api_button_submit_keys" /> + <TextView + android:id="@+id/label_latest_key_date" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Latest key is from: -" /> - <Button - android:id="@+id/button_api_share_my_keys" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="@string/test_api_button_share_my_keys" /> + <TextView + android:id="@+id/text_my_keys" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:hint="Your keys will be displayed here" + android:lines="5" + android:visibility="gone" /> + + <androidx.viewpager2.widget.ViewPager2 + android:id="@+id/qr_code_viewpager" + android:layout_width="match_parent" + android:layout_height="200dp" /> + + <TextView + android:id="@+id/label_other_keys" + style="@style/headline6" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/test_api_body_other_keys" /> - <Button - android:id="@+id/button_calculate_risk_level" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="@string/test_api_calculate_risk_level" /> + <TextView + android:id="@+id/text_scanned_key" + android:layout_width="match_parent" + android:layout_height="wrap_content" /> - <Button - android:id="@+id/button_insert_exposure_summary" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="Insert ExposureSummary" /> + </LinearLayout> - <Button - android:id="@+id/button_retrieve_exposure_summary" - style="@style/buttonPrimary" + <LinearLayout + android:id="@+id/testactions_container" + style="@style/card" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="Retrieve ExposureSummary" /> + android:layout_margin="@dimen/spacing_tiny" + android:orientation="vertical"> - <Button - android:id="@+id/button_clear_db" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="Clear Internal DB" /> + <Button + android:id="@+id/button_api_test_start" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/test_api_button_start" /> - <Button - android:id="@+id/button_tracing_intervals" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:text="Get Inactive Tracing Intervals" /> + <Button + android:id="@+id/button_api_get_exposure_keys" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="@string/test_api_button_get_exposure_keys" /> - <Button - android:id="@+id/button_tracing_duration_in_retention_period" - style="@style/buttonPrimary" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:layout_marginBottom="@dimen/spacing_normal" - android:text="Get Active Tracing Duration in Retention Period" /> + <Button + android:id="@+id/button_api_submit_keys" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="@string/test_api_button_submit_keys" /> - <TextView - android:id="@+id/label_country_filter" - style="@style/headline6" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginTop="@dimen/spacing_normal" - android:accessibilityHeading="true" - android:text="Country Settings" /> + <Button + android:id="@+id/button_api_share_my_keys" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="@string/test_api_button_share_my_keys" /> - <LinearLayout - android:layout_width="match_parent" - android:layout_height="wrap_content"> + <Button + android:id="@+id/button_calculate_risk_level" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="@string/test_api_calculate_risk_level" /> - <EditText - android:id="@+id/input_country_codes_editText" - android:layout_width="0dp" + <Button + android:id="@+id/button_insert_exposure_summary" + style="@style/buttonPrimary" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1" /> + android:layout_marginTop="@dimen/spacing_tiny" + android:text="Insert ExposureSummary" /> <Button - android:id="@+id/button_filter_country_codes" + android:id="@+id/button_retrieve_exposure_summary" style="@style/buttonPrimary" - android:layout_width="wrap_content" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:text="Apply" /> - </LinearLayout> + android:layout_marginTop="@dimen/spacing_tiny" + android:text="Retrieve ExposureSummary" /> - <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:"> + <Button + android:id="@+id/button_clear_db" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="Clear Internal DB" /> - </TextView> + <Button + android:id="@+id/button_tracing_intervals" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="Get Inactive Tracing Intervals" /> - <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:accessibilityHeading="true" - android:text="Statistics" /> + <Button + android:id="@+id/button_tracing_duration_in_retention_period" + style="@style/buttonPrimary" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + 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:gravity="center_vertical" - android:orientation="horizontal"> + android:layout_margin="@dimen/spacing_tiny" + android:orientation="vertical"> - <EditText - android:id="@+id/input_measure_risk_key_repeat_count" - android:layout_width="98dp" + <TextView + android:id="@+id/label_country_filter" + style="@style/headline6" + android:layout_width="match_parent" android:layout_height="wrap_content" - android:inputType="number" - android:text="1" /> + 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:"> - <Button - android:id="@+id/button_retrieve_diagnosis_keys_and_calc_risk_level" - style="@style/buttonPrimary" - android:layout_width="0dp" + </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:layout_marginBottom="@dimen/spacing_normal" - android:layout_weight="1" - android:imeOptions="actionDone" - android:text="Measure: Calculate Risk Level / Key Retrieval" /> + 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> - <TextView - android:id="@+id/label_test_api_measure_calc_key_status" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="Result: " /> - <de.rki.coronawarnapp.ui.calendar.CalendarView android:id="@+id/calendar_container" android:layout_width="match_parent" 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 index bf7accefe..0d18d7a27 100644 --- 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 @@ -10,7 +10,6 @@ import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration import de.rki.coronawarnapp.risk.TimeVariables import de.rki.coronawarnapp.storage.AppSettings import de.rki.coronawarnapp.storage.DeviceStorage -import de.rki.coronawarnapp.util.CWADebug import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -78,7 +77,7 @@ class KeyFileDownloader @Inject constructor( ) val availableKeys = - if (CWADebug.isDebugBuildOrMode && settings.isLast3HourModeEnabled) { + if (settings.isLast3HourModeEnabled) { syncMissing3Hours(filteredCountries, DEBUG_HOUR_LIMIT) keyCache.getEntriesForType(CachedKeyInfo.Type.COUNTRY_HOUR) } else { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/BuildConfigWrap.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/BuildConfigWrap.kt index b00775b2c..8fc88e77f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/BuildConfigWrap.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/BuildConfigWrap.kt @@ -8,5 +8,4 @@ object BuildConfigWrap { val ENVIRONMENT_JSONDATA = BuildConfig.ENVIRONMENT_JSONDATA val ENVIRONMENT_TYPE_DEFAULT = BuildConfig.ENVIRONMENT_TYPE_DEFAULT - val ENVIRONMENT_TYPE_ALTERNATIVE = BuildConfig.ENVIRONMENT_TYPE_ALTERNATIVE } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt index 1150661ab..6b4c9ae3e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt @@ -8,6 +8,7 @@ import de.rki.coronawarnapp.environment.EnvironmentSetup.ENVKEY.DOWNLOAD import de.rki.coronawarnapp.environment.EnvironmentSetup.ENVKEY.SUBMISSION import de.rki.coronawarnapp.environment.EnvironmentSetup.ENVKEY.VERIFICATION import de.rki.coronawarnapp.environment.EnvironmentSetup.ENVKEY.VERIFICATION_KEYS +import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType import de.rki.coronawarnapp.util.CWADebug import timber.log.Timber import javax.inject.Inject @@ -31,7 +32,13 @@ class EnvironmentSetup @Inject constructor( DEV("DEV"), WRU("WRU"), WRU_XA("WRU-XA"), // (aka ACME) - WRU_XD("WRU-XD") // (aka Germany) + WRU_XD("WRU-XD"); // (aka Germany) + + companion object { + internal fun String.toEnvironmentType(): Type = values().single { + it.rawKey == this + } + } } private val prefs by lazy { @@ -48,9 +55,6 @@ class EnvironmentSetup @Inject constructor( val defaultEnvironment: Type get() = BuildConfigWrap.ENVIRONMENT_TYPE_DEFAULT.toEnvironmentType() - val alternativeEnvironment: Type - get() = BuildConfigWrap.ENVIRONMENT_TYPE_ALTERNATIVE.toEnvironmentType() - var currentEnvironment: Type get() { return prefs @@ -95,10 +99,6 @@ class EnvironmentSetup @Inject constructor( val appConfigVerificationKey: String get() = getEnvironmentValue(VERIFICATION_KEYS) - private fun String.toEnvironmentType(): Type = Type.values().single { - it.rawKey == this - } - companion object { private const val PKEY_CURRENT_ENVINROMENT = "environment.current" } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/AppSettings.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/AppSettings.kt index f687e24ad..c89378bff 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/AppSettings.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/AppSettings.kt @@ -1,5 +1,6 @@ package de.rki.coronawarnapp.storage +import de.rki.coronawarnapp.util.CWADebug import javax.inject.Inject import javax.inject.Singleton @@ -7,5 +8,5 @@ import javax.inject.Singleton class AppSettings @Inject constructor() { val isLast3HourModeEnabled: Boolean - get() = LocalData.last3HoursMode() + get() = LocalData.last3HoursMode() && CWADebug.isDebugBuildOrMode } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/LiveDataExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/LiveDataExtensions.kt index 6ba82485f..99471c8af 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/LiveDataExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/LiveDataExtensions.kt @@ -2,6 +2,7 @@ package de.rki.coronawarnapp.util.ui import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer @@ -12,3 +13,17 @@ fun <T> LiveData<T>.observe2(fragment: Fragment, callback: (T) -> Unit) { fun <T> LiveData<T>.observe2(activity: AppCompatActivity, callback: (T) -> Unit) { observe(activity, Observer { callback.invoke(it) }) } + +fun <T> LiveData<T>.observeOnce(lifecycleOwner: LifecycleOwner? = null, observer: Observer<T>) { + val internalObserver = object : Observer<T> { + override fun onChanged(t: T?) { + observer.onChanged(t) + removeObserver(this) + } + } + if (lifecycleOwner == null) { + observeForever(internalObserver) + } else { + observe(lifecycleOwner, internalObserver) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/SmartLiveData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/SmartLiveData.kt new file mode 100644 index 000000000..cdd903d65 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/SmartLiveData.kt @@ -0,0 +1,53 @@ +package de.rki.coronawarnapp.util.ui + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty + +fun <T : Any> ViewModel.smartLiveData( + dispatcher: CoroutineDispatcher = Dispatchers.Default, + initAction: suspend () -> T +) = SmartLiveDataProperty(dispatcher, initAction) + +class SmartLiveDataProperty<T : Any>( + private val dispatcher: CoroutineDispatcher = Dispatchers.Default, + private val initialValueProvider: suspend () -> T +) : ReadOnlyProperty<ViewModel, SmartLiveData<T>> { + + private var liveData: SmartLiveData<T>? = null + + override fun getValue( + thisRef: ViewModel, + property: KProperty<*> + ): SmartLiveData<T> { + liveData?.let { + return@getValue it + } + + return SmartLiveData<T>(thisRef, dispatcher).also { + liveData = it + thisRef.viewModelScope.launch(context = dispatcher) { + it.postValue(initialValueProvider()) + } + } + } +} + +class SmartLiveData<T : Any>( + private val viewModel: ViewModel, + private val dispatcher: CoroutineDispatcher +) : MutableLiveData<T>() { + + fun update(updateAction: (T) -> T) { + observeOnce { + viewModel.viewModelScope.launch(context = dispatcher) { + postValue(updateAction(it)) + } + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/ViewExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/ViewExtensions.kt new file mode 100644 index 000000000..b5be61233 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ui/ViewExtensions.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.util.ui + +import android.view.View + +fun View.setGone(gone: Boolean) { + visibility = if (gone) View.GONE else View.VISIBLE +} + +fun View.setInvisible(invisible: Boolean) { + visibility = if (invisible) View.INVISIBLE else View.VISIBLE +} 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 index 25746dcf2..a39102dbe 100644 --- 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 @@ -10,7 +10,6 @@ import de.rki.coronawarnapp.diagnosiskeys.storage.legacy.LegacyKeyCacheMigration import de.rki.coronawarnapp.storage.AppSettings import de.rki.coronawarnapp.storage.DeviceStorage import de.rki.coronawarnapp.storage.InsufficientStorageException -import de.rki.coronawarnapp.util.CWADebug import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations @@ -20,7 +19,6 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.impl.annotations.MockK import io.mockk.mockk -import io.mockk.mockkObject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking import org.joda.time.Instant @@ -33,13 +31,16 @@ import org.junit.jupiter.api.extension.ExtendWith import testhelpers.BaseIOTest import testhelpers.extensions.CoroutinesTestExtension import testhelpers.extensions.InstantExecutorExtension +import testhelpers.flakyTest import timber.log.Timber import java.io.File import java.io.IOException +import kotlin.time.ExperimentalTime /** * CachedKeyFileHolder test. */ +@ExperimentalTime @ExperimentalCoroutinesApi @ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class) class KeyFileDownloaderTest : BaseIOTest() { @@ -68,8 +69,6 @@ class KeyFileDownloaderTest : BaseIOTest() { testDir.mkdirs() testDir.exists() shouldBe true - mockkObject(CWADebug) - every { CWADebug.isDebugBuildOrMode } returns false every { settings.isLast3HourModeEnabled } returns false coEvery { diagnosisKeyServer.getCountryIndex() } returns listOf( @@ -129,11 +128,10 @@ class KeyFileDownloaderTest : BaseIOTest() { val type = arg<CachedKeyInfo.Type>(0) keyRepoData.values.filter { it.type == type }.map { it to File(testDir, it.id) } } - coEvery { keyCache.getAllCachedKeys() } returns keyRepoData.values.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) @@ -227,7 +225,8 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `wanted country list is empty, day mode`() { + fun `wanted country list is empty, day mode`() = flakyTest { + val downloader = createDownloader() runBlocking { downloader.asyncFetchKeyFiles(emptyList()) shouldBe emptyList() @@ -235,8 +234,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `wanted country list is empty, hour mode`() { - every { CWADebug.isDebugBuildOrMode } returns true + fun `wanted country list is empty, hour mode`() = flakyTest { every { settings.isLast3HourModeEnabled } returns true val downloader = createDownloader() @@ -246,7 +244,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `fetching is aborted in day if not enough free storage`() { + fun `fetching is aborted in day if not enough free storage`() = flakyTest { coEvery { deviceStorage.requireSpacePrivateStorage(1048576L) } throws InsufficientStorageException( mockk(relaxed = true) ) @@ -261,8 +259,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `fetching is aborted in hour if not enough free storage`() { - every { CWADebug.isDebugBuildOrMode } returns true + fun `fetching is aborted in hour if not enough free storage`() = flakyTest { every { settings.isLast3HourModeEnabled } returns true coEvery { deviceStorage.requireSpacePrivateStorage(67584L) } throws InsufficientStorageException( @@ -279,7 +276,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `error during country index fetch`() { + fun `error during country index fetch`() = flakyTest { coEvery { diagnosisKeyServer.getCountryIndex() } throws IOException() val downloader = createDownloader() @@ -292,7 +289,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `day fetch without prior data`() { + fun `day fetch without prior data`() = flakyTest { val downloader = createDownloader() runBlocking { @@ -333,7 +330,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `day fetch with existing data`() { + fun `day fetch with existing data`() = flakyTest { mockAddData( type = CachedKeyInfo.Type.COUNTRY_DAY, location = LocationCode("DE"), @@ -380,7 +377,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `day fetch deletes stale data`() { + fun `day fetch deletes stale data`() = flakyTest { coEvery { diagnosisKeyServer.getDayIndex(LocationCode("DE")) } returns listOf( LocalDate.parse("2020-09-02") ) @@ -428,7 +425,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `day fetch skips single download failures`() { + fun `day fetch skips single download failures`() = flakyTest { var dlCounter = 0 coEvery { diagnosisKeyServer.downloadKeyFile(any(), any(), any(), any(), any()) } answers { dlCounter++ @@ -455,8 +452,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `last3Hours fetch without prior data`() { - every { CWADebug.isDebugBuildOrMode } returns true + fun `last3Hours fetch without prior data`() = flakyTest { every { settings.isLast3HourModeEnabled } returns true val downloader = createDownloader() @@ -515,8 +511,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `last3Hours fetch with prior data`() { - every { CWADebug.isDebugBuildOrMode } returns true + fun `last3Hours fetch with prior data`() = flakyTest { every { settings.isLast3HourModeEnabled } returns true mockAddData( @@ -582,8 +577,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `last3Hours fetch deletes stale data`() { - every { CWADebug.isDebugBuildOrMode } returns true + fun `last3Hours fetch deletes stale data`() = flakyTest { every { settings.isLast3HourModeEnabled } returns true val (staleKey1, _) = mockAddData( @@ -665,8 +659,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `last3Hours fetch skips single download failures`() { - every { CWADebug.isDebugBuildOrMode } returns true + fun `last3Hours fetch skips single download failures`() = flakyTest { every { settings.isLast3HourModeEnabled } returns true var dlCounter = 0 @@ -695,7 +688,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `not completed cache entries are overwritten`() { + fun `not completed cache entries are overwritten`() = flakyTest { mockAddData( type = CachedKeyInfo.Type.COUNTRY_DAY, location = LocationCode("DE"), @@ -723,7 +716,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `database errors do not abort the whole process`() { + fun `database errors do not abort the whole process`() = flakyTest { var completionCounter = 0 coEvery { keyCache.markKeyComplete(any(), any()) } answers { completionCounter++ @@ -751,7 +744,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `store server md5`() { + fun `store server md5`() = flakyTest { coEvery { diagnosisKeyServer.getCountryIndex() } returns listOf(LocationCode("DE")) coEvery { diagnosisKeyServer.getDayIndex(LocationCode("DE")) } returns listOf( LocalDate.parse("2020-09-01") @@ -781,7 +774,7 @@ class KeyFileDownloaderTest : BaseIOTest() { } @Test - fun `use local MD5 as fallback if there is none available from the server`() { + fun `use local MD5 as fallback if there is none available from the server`() = flakyTest { coEvery { diagnosisKeyServer.getCountryIndex() } returns listOf(LocationCode("DE")) coEvery { diagnosisKeyServer.getDayIndex(LocationCode("DE")) } returns listOf( LocalDate.parse("2020-09-01") diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/BuildConfigWrapTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/BuildConfigWrapTest.kt index c346d5ce6..cded06c92 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/BuildConfigWrapTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/BuildConfigWrapTest.kt @@ -1,7 +1,6 @@ package de.rki.coronawarnapp.environment -import de.rki.coronawarnapp.BuildConfig -import io.kotest.matchers.shouldBe +import io.kotest.matchers.collections.shouldBeIn import org.junit.jupiter.api.Test import testhelpers.BaseTest @@ -9,11 +8,6 @@ class BuildConfigWrapTest : BaseTest() { @Test fun `default environment type should be DEV`() { - BuildConfigWrap.ENVIRONMENT_TYPE_DEFAULT shouldBe BuildConfig.ENVIRONMENT_TYPE_DEFAULT - } - - @Test - fun `alternative environment type should be WRU-XD`() { - BuildConfigWrap.ENVIRONMENT_TYPE_ALTERNATIVE shouldBe BuildConfig.ENVIRONMENT_TYPE_ALTERNATIVE + BuildConfigWrap.ENVIRONMENT_TYPE_DEFAULT shouldBeIn listOf("DEV", "INT", "WRU-XD", "PROD") } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt index 3dc9e4407..ae27016b8 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt @@ -1,6 +1,7 @@ package de.rki.coronawarnapp.environment import android.content.Context +import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType import de.rki.coronawarnapp.util.CWADebug import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe @@ -70,34 +71,34 @@ class EnvironmentSetupTest : BaseTest() { @Test fun `default environment type is set correctly`() { - if (CWADebug.buildFlavor == CWADebug.BuildFlavor.DEVICE_FOR_TESTERS) { - createEnvSetup().defaultEnvironment shouldBe EnvironmentSetup.Type.DEV - createEnvSetup().alternativeEnvironment shouldBe EnvironmentSetup.Type.WRU_XA - } else { - createEnvSetup().defaultEnvironment shouldBe EnvironmentSetup.Type.PRODUCTION - createEnvSetup().alternativeEnvironment shouldBe EnvironmentSetup.Type.PRODUCTION - } + createEnvSetup().defaultEnvironment shouldBe BuildConfigWrap.ENVIRONMENT_TYPE_DEFAULT.toEnvironmentType() } @Test fun `switching the default type is persisted in storage (preferences)`() { + every { BuildConfigWrap.ENVIRONMENT_TYPE_DEFAULT } returns EnvironmentSetup.Type.DEV.rawKey if (CWADebug.buildFlavor == CWADebug.BuildFlavor.DEVICE_FOR_TESTERS) { createEnvSetup().apply { defaultEnvironment shouldBe EnvironmentSetup.Type.DEV - currentEnvironment shouldBe EnvironmentSetup.Type.DEV + currentEnvironment shouldBe defaultEnvironment currentEnvironment = EnvironmentSetup.Type.WRU currentEnvironment shouldBe EnvironmentSetup.Type.WRU } + mockPreferences.dataMapPeek.values.single() shouldBe EnvironmentSetup.Type.WRU.rawKey createEnvSetup().apply { defaultEnvironment shouldBe EnvironmentSetup.Type.DEV currentEnvironment shouldBe EnvironmentSetup.Type.WRU } } else { createEnvSetup().apply { - defaultEnvironment shouldBe EnvironmentSetup.Type.PRODUCTION - currentEnvironment shouldBe EnvironmentSetup.Type.PRODUCTION - currentEnvironment = EnvironmentSetup.Type.DEV - currentEnvironment shouldBe EnvironmentSetup.Type.PRODUCTION + defaultEnvironment shouldBe EnvironmentSetup.Type.DEV + currentEnvironment shouldBe defaultEnvironment + currentEnvironment = EnvironmentSetup.Type.WRU + currentEnvironment shouldBe defaultEnvironment + } + mockPreferences.dataMapPeek.values shouldBe emptyList() + createEnvSetup().apply { + currentEnvironment shouldBe defaultEnvironment } } } diff --git a/Corona-Warn-App/src/test/java/testhelpers/KotestExtensions.kt b/Corona-Warn-App/src/test/java/testhelpers/KotestExtensions.kt new file mode 100644 index 000000000..4841a1926 --- /dev/null +++ b/Corona-Warn-App/src/test/java/testhelpers/KotestExtensions.kt @@ -0,0 +1,27 @@ +package testhelpers + +import io.kotest.assertions.retry +import kotlinx.coroutines.runBlocking +import timber.log.Timber +import kotlin.time.ExperimentalTime +import kotlin.time.seconds + +/** + * TODO Remove flaky tests where possible + * A flaky test is a test that sometimes fails and sometimes doesn't + * Feel free to find usages of this method and to refactor them such that they are working reliably. + */ +@ExperimentalTime +fun <T> flakyTest(flakyAction: () -> T): Unit = runBlocking { + retry( + maxRetry = 10, + timeout = 60.seconds, + delay = 1.seconds, + multiplier = 1, + exceptionClass = Exception::class, + f = { + Timber.v("Flaky test try...") + flakyAction() + } + ) +} diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt index 26d1132fe..9003b9ce6 100644 --- a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt +++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/api/ui/TestForApiFragmentViewModelTest.kt @@ -1,32 +1,47 @@ package de.rki.coronawarnapp.test.api.ui +import android.content.Context +import androidx.lifecycle.Observer import de.rki.coronawarnapp.environment.EnvironmentSetup import io.kotest.matchers.shouldBe 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 kotlinx.coroutines.ExperimentalCoroutinesApi import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith import testhelpers.BaseTest +import testhelpers.extensions.CoroutinesTestExtension import testhelpers.extensions.InstantExecutorExtension +import testhelpers.flakyTest +import kotlin.time.ExperimentalTime -@ExtendWith(InstantExecutorExtension::class) +@ExperimentalTime +@ExperimentalCoroutinesApi +@ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class) class TestForApiFragmentViewModelTest : BaseTest() { @MockK private lateinit var environmentSetup: EnvironmentSetup + @MockK private lateinit var context: Context - var currentEnvironment = EnvironmentSetup.Type.DEV + private var currentEnvironment = EnvironmentSetup.Type.DEV @BeforeEach fun setup() { MockKAnnotations.init(this) + currentEnvironment = EnvironmentSetup.Type.DEV every { environmentSetup.defaultEnvironment } returns EnvironmentSetup.Type.DEV - every { environmentSetup.alternativeEnvironment } returns EnvironmentSetup.Type.WRU_XA + every { environmentSetup.submissionCdnUrl } returns "submissionUrl" + every { environmentSetup.downloadCdnUrl } returns "downloadUrl" + every { environmentSetup.verificationCdnUrl } returns "verificationUrl" every { environmentSetup.currentEnvironment = any() } answers { currentEnvironment = arg(0) @@ -42,24 +57,45 @@ class TestForApiFragmentViewModelTest : BaseTest() { clearAllMocks() } - private fun createViewModel(): TestForApiFragmentViewModel { - return TestForApiFragmentViewModel(environmentSetup) - } + private fun createViewModel(): TestForApiFragmentViewModel = TestForApiFragmentViewModel( + envSetup = environmentSetup, + context = context + ) @Test - fun `toggeling the env works`() { + fun `toggeling the env works`() = flakyTest { + currentEnvironment = EnvironmentSetup.Type.DEV val vm = createViewModel() - currentEnvironment = EnvironmentSetup.Type.DEV - vm.isCurrentEnvironmentAlternate() shouldBe false - currentEnvironment = EnvironmentSetup.Type.WRU_XA - vm.isCurrentEnvironmentAlternate() shouldBe true - - vm.environmentChangeEvent.value shouldBe null - vm.toggleEnvironment(true) - vm.environmentChangeEvent.value shouldBe EnvironmentSetup.Type.WRU_XA - verify { environmentSetup.currentEnvironment = EnvironmentSetup.Type.WRU_XA } - vm.toggleEnvironment(false) - verify { environmentSetup.currentEnvironment = EnvironmentSetup.Type.DEV } + val states = mutableListOf<EnvironmentState>() + val observerState = mockk<Observer<EnvironmentState>>() + every { observerState.onChanged(capture(states)) } just Runs + vm.environmentState.observeForever(observerState) + + val events = mutableListOf<EnvironmentSetup.Type>() + val observerEvent = mockk<Observer<EnvironmentSetup.Type>>() + every { observerEvent.onChanged(capture(events)) } just Runs + vm.environmentChangeEvent.observeForever(observerEvent) + + vm.selectEnvironmentTytpe(EnvironmentSetup.Type.DEV.rawKey) + vm.selectEnvironmentTytpe(EnvironmentSetup.Type.WRU_XA.rawKey) + + verify(exactly = 3, timeout = 3000) { observerState.onChanged(any()) } + verify(exactly = 2, timeout = 3000) { observerEvent.onChanged(any()) } + + states[0].apply { + current shouldBe EnvironmentSetup.Type.DEV + } + + states[1].apply { + current shouldBe EnvironmentSetup.Type.DEV + } + events[0] shouldBe EnvironmentSetup.Type.DEV + + + states[2].apply { + current shouldBe EnvironmentSetup.Type.WRU_XA + } + events[1] shouldBe EnvironmentSetup.Type.WRU_XA } } -- GitLab