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 6b8bf5b59a478b948e95f22704681ab8e82a3487..fbdd56b5352c37f695c0ffa31727a7074bc68af1 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 @@ -13,11 +13,7 @@ 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.view.ViewCompat.generateViewId -import androidx.core.view.children import androidx.fragment.app.Fragment import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView @@ -45,7 +41,6 @@ import de.rki.coronawarnapp.nearby.InternalExposureNotificationPermissionHelper import de.rki.coronawarnapp.receiver.ExposureStateUpdateReceiver import de.rki.coronawarnapp.risk.TimeVariables import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange -import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService import de.rki.coronawarnapp.sharing.ExposureSharingService import de.rki.coronawarnapp.storage.AppDatabase import de.rki.coronawarnapp.storage.ExposureSummaryRepository @@ -57,7 +52,6 @@ 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 @@ -131,75 +125,6 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), qrPagerAdapter = QRPagerAdapter() qrPager.adapter = qrPagerAdapter - // Debug card - binding.hourlyKeyPkgMode.apply { - setOnClickListener { vm.setHourlyKeyPkgMode(isChecked) } - } - - binding.backgroundNotificationsToggle.apply { - setOnClickListener { vm.setBackgroundNotifications(isChecked) } - } - vm.backgroundNotificationsToggleEvent.observe2(this@TestForAPIFragment) { - showToast("Background Notifications are activated: $it") - } - vm.debugOptionsState.observe2(this) { state -> - binding.apply { - backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled - hourlyKeyPkgMode.isChecked = state.isHourlyTestingMode - } - } - 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") } - - // 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!") - } - // GMS Info card vm.gmsState.observe2(this) { state -> binding.googlePlayServicesVersionInfo.text = @@ -292,9 +217,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), // Load countries from App config and update Country UI element states lifecycleScope.launch { lastSetCountries = - ApplicationConfigurationService.asyncRetrieveApplicationConfiguration() - .supportedCountriesList - + AppInjector.component.appConfigProvider.getAppConfig().supportedCountries binding.inputCountryCodesEditText.setText( lastSetCountries?.joinToString(",") ) @@ -469,7 +392,7 @@ class TestForAPIFragment : Fragment(R.layout.fragment_test_for_a_p_i), // only testing implementation: this is used to wait for the broadcastreceiver of the OS / EN API enfClient.provideDiagnosisKeys( googleFileList, - ApplicationConfigurationService.asyncRetrieveExposureConfiguration(), + AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration, token!! ) showToast("Provided ${appleKeyList.size} keys to Google API with token $token") 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 49c8127c47e179cfc87660ffdf8414b1a72665c7..f2ebfd26e4c413d2b3c80cb8cce605669310ee03 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 @@ -2,106 +2,25 @@ 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.risk.RiskLevelTask -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.EnvironmentState.Companion.toEnvironmentState -import de.rki.coronawarnapp.test.api.ui.LoggerState.Companion.toLoggerState -import de.rki.coronawarnapp.util.CWADebug import de.rki.coronawarnapp.util.di.AppContext -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( @AppContext private val context: Context, - private val envSetup: EnvironmentSetup, - private val testSettings: TestSettings, private val taskController: TaskController ) : CWAViewModel() { - val debugOptionsState by smartLiveData { - DebugOptionsState( - areNotificationsEnabled = LocalData.backgroundNotification(), - isHourlyTestingMode = testSettings.isHourKeyPkgMode - ) - } - - fun setHourlyKeyPkgMode(enabled: Boolean) { - debugOptionsState.update { - testSettings.isHourKeyPkgMode = enabled - it.copy(isHourlyTestingMode = enabled) - } - } - - val environmentState by smartLiveData { - envSetup.toEnvironmentState() - } - val environmentChangeEvent = SingleLiveEvent<EnvironmentSetup.Type>() - - fun selectEnvironmentTytpe(type: String) { - environmentState.update { - envSetup.currentEnvironment = type.toEnvironmentType() - environmentChangeEvent.postValue(envSetup.currentEnvironment) - envSetup.toEnvironmentState() - } - } - - 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() { taskController.submit(DefaultTaskRequest(RiskLevelTask::class)) } - 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) - } - } - } - val gmsState by smartLiveData { GoogleServicesState( version = PackageInfoCompat.getLongVersionCode( 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 new file mode 100644 index 0000000000000000000000000000000000000000..feedc56bc7d28d2f0be692f7410a2e026c6662b7 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragment.kt @@ -0,0 +1,58 @@ +package de.rki.coronawarnapp.test.appconfig.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import android.widget.Toast +import androidx.fragment.app.Fragment +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.FragmentTestAppconfigBinding +import de.rki.coronawarnapp.test.menu.ui.TestMenuItem +import de.rki.coronawarnapp.util.di.AutoInject +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 org.joda.time.DateTimeZone +import org.joda.time.format.ISODateTimeFormat +import javax.inject.Inject + +@SuppressLint("SetTextI18n") +class AppConfigTestFragment : Fragment(R.layout.fragment_test_appconfig), AutoInject { + + @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory + private val vm: AppConfigTestFragmentViewModel by cwaViewModels { viewModelFactory } + + private val binding: FragmentTestAppconfigBinding by viewBindingLazy() + + private val timeFormatter = ISODateTimeFormat.dateTime() + .withZone(DateTimeZone.forID("Europe/Berlin")) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + vm.currentConfig.observe2(this) { data -> + binding.currentConfiguration.text = + 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})" + } ?: "n/a" + } + + vm.errorEvent.observe2(this) { + Toast.makeText(requireContext(), it.toString(), Toast.LENGTH_LONG).show() + } + + binding.downloadAction.setOnClickListener { vm.download() } + binding.deleteAction.setOnClickListener { vm.clearConfig() } + } + + companion object { + val MENU_ITEM = TestMenuItem( + title = "Remote Config Data", + description = "View & Control the remote config.", + targetId = R.id.test_appconfig_fragment + ) + } +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..0ebba5dd4c2b262da0530e469a23571a2d44f009 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentModule.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.test.appconfig.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 AppConfigTestFragmentModule { + @Binds + @IntoMap + @CWAViewModelKey(AppConfigTestFragmentViewModel::class) + abstract fun testTaskControllerFragment( + factory: AppConfigTestFragmentViewModel.Factory + ): CWAViewModelFactory<out CWAViewModel> +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..98dfaf2ec999083114c0b47ac8224d127406a206 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/appconfig/ui/AppConfigTestFragmentViewModel.kt @@ -0,0 +1,39 @@ +package de.rki.coronawarnapp.test.appconfig.ui + +import androidx.lifecycle.asLiveData +import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.ui.SingleLiveEvent +import de.rki.coronawarnapp.util.viewmodel.CWAViewModel +import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory +import timber.log.Timber + +class AppConfigTestFragmentViewModel @AssistedInject constructor( + dispatcherProvider: DispatcherProvider, + private val appConfigProvider: AppConfigProvider +) : CWAViewModel(dispatcherProvider = dispatcherProvider) { + + val currentConfig = appConfigProvider.currentConfig.asLiveData() + val errorEvent = SingleLiveEvent<Exception>() + + fun download() { + launch { + try { + appConfigProvider.getAppConfig() + } catch (e: Exception) { + Timber.e(e, "Failed to get app config.") + errorEvent.postValue(e) + } + } + } + + fun clearConfig() { + launch { + appConfigProvider.clear() + } + } + + @AssistedInject.Factory + interface Factory : SimpleCWAViewModelFactory<AppConfigTestFragmentViewModel> +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..8015debc73d4333c59d03accd9e61e6e823e9005 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt @@ -0,0 +1,115 @@ +package de.rki.coronawarnapp.test.debugoptions.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import android.widget.RadioButton +import android.widget.RadioGroup +import androidx.core.view.ViewCompat +import androidx.core.view.children +import androidx.fragment.app.Fragment +import com.google.android.material.snackbar.Snackbar +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.FragmentTestDebugoptionsBinding +import de.rki.coronawarnapp.test.menu.ui.TestMenuItem +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 javax.inject.Inject + +@SuppressLint("SetTextI18n") +class DebugOptionsFragment : Fragment(R.layout.fragment_test_debugoptions), AutoInject { + + @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory + private val vm: DebugOptionsFragmentViewModel by cwaViewModels { viewModelFactory } + + private val binding: FragmentTestDebugoptionsBinding by viewBindingLazy() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + // Debug card + binding.hourlyKeyPkgMode.apply { + setOnClickListener { vm.setHourlyKeyPkgMode(isChecked) } + } + + binding.backgroundNotificationsToggle.apply { + setOnClickListener { vm.setBackgroundNotifications(isChecked) } + } + vm.backgroundNotificationsToggleEvent.observe2(this@DebugOptionsFragment) { + showSnackBar("Background Notifications are activated: $it") + } + vm.debugOptionsState.observe2(this) { state -> + binding.apply { + backgroundNotificationsToggle.isChecked = state.areNotificationsEnabled + hourlyKeyPkgMode.isChecked = state.isHourlyTestingMode + } + } + 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) { showSnackBar("Logfile copied to $it") } + + // 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 = ViewCompat.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!") + } + } + + private fun showSnackBar(message: String) { + Snackbar.make(requireView(), message, Snackbar.LENGTH_LONG).show() + } + + companion object { + val MENU_ITEM = TestMenuItem( + title = "Debug options", + description = "Server environment, logging, hourly mode...", + targetId = R.id.test_debugoptions_fragment + ) + } +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..f24dda5b1559bf054dd0cd7fadefb1af0c73e864 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentModule.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.test.debugoptions.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 DebugOptionsFragmentModule { + @Binds + @IntoMap + @CWAViewModelKey(DebugOptionsFragmentViewModel::class) + abstract fun testTaskControllerFragment( + factory: DebugOptionsFragmentViewModel.Factory + ): CWAViewModelFactory<out CWAViewModel> +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..784c9731edd17cf949a8bb4d22d8898732fb8691 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModel.kt @@ -0,0 +1,108 @@ +package de.rki.coronawarnapp.test.debugoptions.ui + +import android.content.Context +import androidx.lifecycle.viewModelScope +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.risk.RiskLevelTask +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 +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.di.AppContext +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 DebugOptionsFragmentViewModel @AssistedInject constructor( + @AppContext private val context: Context, + private val envSetup: EnvironmentSetup, + private val testSettings: TestSettings, + private val taskController: TaskController, + dispatcherProvider: DispatcherProvider +) : CWAViewModel(dispatcherProvider = dispatcherProvider) { + + val debugOptionsState by smartLiveData { + DebugOptionsState( + areNotificationsEnabled = LocalData.backgroundNotification(), + isHourlyTestingMode = testSettings.isHourKeyPkgMode + ) + } + + fun setHourlyKeyPkgMode(enabled: Boolean) { + debugOptionsState.update { + testSettings.isHourKeyPkgMode = enabled + it.copy(isHourlyTestingMode = enabled) + } + } + + val environmentState by smartLiveData { + envSetup.toEnvironmentState() + } + val environmentChangeEvent = SingleLiveEvent<EnvironmentSetup.Type>() + + fun selectEnvironmentTytpe(type: String) { + environmentState.update { + envSetup.currentEnvironment = type.toEnvironmentType() + environmentChangeEvent.postValue(envSetup.currentEnvironment) + envSetup.toEnvironmentState() + } + } + + 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() { + taskController.submit(DefaultTaskRequest(RiskLevelTask::class)) + } + + 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) + } + } + } + + @AssistedInject.Factory + interface Factory : SimpleCWAViewModelFactory<DebugOptionsFragmentViewModel> +} 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 aa58711c95116a6c9629216464e667b16fc3a9ce..d3316873e6c0bb8d17fc9cf190189b9e1eb4faed 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 @@ -3,7 +3,9 @@ package de.rki.coronawarnapp.test.menu.ui import androidx.lifecycle.MutableLiveData import com.squareup.inject.assisted.AssistedInject 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.risklevel.ui.TestRiskLevelCalculationFragment import de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment import de.rki.coronawarnapp.util.ui.SingleLiveEvent @@ -14,10 +16,12 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() { val testMenuData by lazy { listOf( - SettingsCrashReportFragment.MENU_ITEM, + DebugOptionsFragment.MENU_ITEM, + AppConfigTestFragment.MENU_ITEM, TestForAPIFragment.MENU_ITEM, TestRiskLevelCalculationFragment.MENU_ITEM, - TestTaskControllerFragment.MENU_ITEM + TestTaskControllerFragment.MENU_ITEM, + SettingsCrashReportFragment.MENU_ITEM ).let { MutableLiveData(it) } } val showTestScreenEvent = SingleLiveEvent<TestMenuItem>() 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 8513f470caa8b3826af3a554c686554cbce80ab9..0644ec8261959aac9dd0a9428a133b11b15d01f3 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 @@ -71,7 +71,6 @@ class TestRiskLevelCalculationFragment : Fragment(R.layout.fragment_test_risk_le binding.labelBackendParameters.text = state.backendParameters binding.labelExposureSummary.text = state.exposureSummary binding.labelFormula.text = state.formula - binding.labelFullConfig.text = state.fullConfig binding.labelExposureInfo.text = state.exposureInfo } vm.startENFObserver() diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt index d98931f3cc0d5dcbd364d896b28ba612635b73f4..add8047c5e6ceb4138286da92f5ebf02ec1bf4d0 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/risklevel/ui/TestRiskLevelCalculationFragmentCWAViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import com.google.android.gms.nearby.exposurenotification.ExposureInformation import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject +import de.rki.coronawarnapp.appconfig.RiskCalculationConfig import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository import de.rki.coronawarnapp.exception.ExceptionCategory import de.rki.coronawarnapp.exception.reporting.report @@ -18,7 +19,6 @@ import de.rki.coronawarnapp.risk.RiskLevelTask import de.rki.coronawarnapp.risk.RiskLevels import de.rki.coronawarnapp.risk.TimeVariables import de.rki.coronawarnapp.server.protocols.AppleLegacyKeyExchange -import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService import de.rki.coronawarnapp.storage.AppDatabase import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.storage.RiskLevelRepository @@ -29,6 +29,7 @@ import de.rki.coronawarnapp.ui.tracing.card.TracingCardStateProvider import de.rki.coronawarnapp.util.KeyFileHelper import de.rki.coronawarnapp.util.coroutine.DispatcherProvider import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.security.SecurityHelper import de.rki.coronawarnapp.util.ui.SingleLiveEvent import de.rki.coronawarnapp.util.viewmodel.CWAViewModel @@ -118,7 +119,6 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor( val backendParameters: String = "", val exposureSummary: String = "", val formula: String = "", - val fullConfig: String = "", val exposureInfo: String = "" ) @@ -131,11 +131,11 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor( val exposureSummary = InternalExposureNotificationClient.asyncGetExposureSummary(googleToken) - val appConfig = - ApplicationConfigurationService.asyncRetrieveApplicationConfiguration() + val expDetectConfig: RiskCalculationConfig = + AppInjector.component.appConfigProvider.getAppConfig() val riskLevelScore = riskLevels.calculateRiskScore( - appConfig.attenuationDuration, + expDetectConfig.attenuationDuration, exposureSummary ) @@ -153,17 +153,17 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor( workState = workState.copy(riskScoreMsg = riskAsString) val lowClass = - appConfig.riskScoreClasses?.riskClassesList?.find { low -> low.label == "LOW" } + expDetectConfig.riskScoreClasses.riskClassesList?.find { low -> low.label == "LOW" } val highClass = - appConfig.riskScoreClasses?.riskClassesList?.find { high -> high.label == "HIGH" } + expDetectConfig.riskScoreClasses.riskClassesList?.find { high -> high.label == "HIGH" } val configAsString = - "Attenuation Weight Low: ${appConfig.attenuationDuration?.weights?.low}\n" + - "Attenuation Weight Mid: ${appConfig.attenuationDuration?.weights?.mid}\n" + - "Attenuation Weight High: ${appConfig.attenuationDuration?.weights?.high}\n\n" + - "Attenuation Offset: ${appConfig.attenuationDuration?.defaultBucketOffset}\n" + + "Attenuation Weight Low: ${expDetectConfig.attenuationDuration.weights?.low}\n" + + "Attenuation Weight Mid: ${expDetectConfig.attenuationDuration.weights?.mid}\n" + + "Attenuation Weight High: ${expDetectConfig.attenuationDuration.weights?.high}\n\n" + + "Attenuation Offset: ${expDetectConfig.attenuationDuration.defaultBucketOffset}\n" + "Attenuation Normalization: " + - "${appConfig.attenuationDuration?.riskScoreNormalizationDivisor}\n\n" + + "${expDetectConfig.attenuationDuration.riskScoreNormalizationDivisor}\n\n" + "Risk Score Low Class: ${lowClass?.min ?: 0} - ${lowClass?.max ?: 0}\n" + "Risk Score High Class: ${highClass?.min ?: 0} - ${highClass?.max ?: 0}" @@ -185,19 +185,18 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor( workState = workState.copy(exposureSummary = summaryAsString) val maxRisk = exposureSummary.maximumRiskScore - val atWeights = appConfig.attenuationDuration?.weights + val atWeights = expDetectConfig.attenuationDuration.weights val attenuationDurationInMin = exposureSummary.attenuationDurationsInMinutes - val attenuationConfig = appConfig.attenuationDuration + val attenuationConfig = expDetectConfig.attenuationDuration val formulaString = - "($maxRisk / ${attenuationConfig?.riskScoreNormalizationDivisor}) * " + + "($maxRisk / ${attenuationConfig.riskScoreNormalizationDivisor}) * " + "(${attenuationDurationInMin?.get(0)} * ${atWeights?.low} " + "+ ${attenuationDurationInMin?.get(1)} * ${atWeights?.mid} " + "+ ${attenuationDurationInMin?.get(2)} * ${atWeights?.high} " + - "+ ${attenuationConfig?.defaultBucketOffset})" + "+ ${attenuationConfig.defaultBucketOffset})" - workState = - workState.copy(formula = formulaString, fullConfig = appConfig.toString()) + workState = workState.copy(formula = formulaString) val token = LocalData.googleApiToken() if (token != null) { @@ -273,7 +272,7 @@ class TestRiskLevelCalculationFragmentCWAViewModel @AssistedInject constructor( // only testing implementation: this is used to wait for the broadcastreceiver of the OS / EN API enfClient.provideDiagnosisKeys( googleFileList, - ApplicationConfigurationService.asyncRetrieveExposureConfiguration(), + AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration, token ) apiKeysProvidedEvent.postValue( 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 cdf98cf1aa010d2e993173b7209eceda722c256e..dae7827378498fa470ebb0df278741e4d1e00522 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 @@ -4,6 +4,10 @@ import dagger.Module import dagger.android.ContributesAndroidInjector import de.rki.coronawarnapp.test.api.ui.TestForAPIFragment import de.rki.coronawarnapp.test.api.ui.TestForApiFragmentModule +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.menu.ui.TestMenuFragment import de.rki.coronawarnapp.test.menu.ui.TestMenuFragmentModule import de.rki.coronawarnapp.test.risklevel.ui.TestRiskLevelCalculationFragment @@ -25,4 +29,10 @@ abstract class MainActivityTestModule { @ContributesAndroidInjector(modules = [TestTaskControllerFragmentModule::class]) abstract fun testTaskControllerFragment(): TestTaskControllerFragment + + @ContributesAndroidInjector(modules = [AppConfigTestFragmentModule::class]) + abstract fun appConfigTestFragment(): AppConfigTestFragment + + @ContributesAndroidInjector(modules = [DebugOptionsFragmentModule::class]) + abstract fun debugOptions(): DebugOptionsFragment } diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml new file mode 100644 index 0000000000000000000000000000000000000000..fb4d95036ce1e7f5e7e101beead64984209dc373 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_appconfig.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="utf-8"?> + +<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" + 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="wrap_content" + android:layout_margin="8dp" + android:orientation="vertical" + android:paddingBottom="32dp"> + + <LinearLayout + 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 + style="@style/TextAppearance.AppCompat.Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Last update" /> + + <TextView + android:id="@+id/last_update" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Last update: ??" /> + + <TextView + style="@style/TextAppearance.AppCompat.Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:text="Time offset to server" /> + + <TextView + android:id="@+id/time_offset" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="Last update: ??" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_tiny" + android:orientation="horizontal"> + + <Button + android:id="@+id/delete_action" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginEnd="@dimen/spacing_tiny" + android:layout_weight="1" + android:text="Delete" /> + + <Button + android:id="@+id/download_action" + android:layout_width="match_parent" + android:layout_marginStart="@dimen/spacing_tiny" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Download" /> + </LinearLayout> + </LinearLayout> + + <LinearLayout + 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/current_configuration" + style="@style/TextAppearance.AppCompat.Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:text="@string/lorem_ipsum" /> + + </LinearLayout> + + </LinearLayout> +</androidx.core.widget.NestedScrollView> \ No newline at end of file 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 new file mode 100644 index 0000000000000000000000000000000000000000..628201557567cccfde46d6928854209b410860e2 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml @@ -0,0 +1,146 @@ +<?xml version="1.0" encoding="utf-8"?> +<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"> + + <androidx.core.widget.NestedScrollView + android:layout_width="match_parent" + android:layout_height="match_parent" + android:fillViewport="true"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="@dimen/spacing_tiny" + android:orientation="vertical"> + + <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"> + + <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" /> + + <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" + android:layout_width="match_parent" + android:layout_height="wrap_content" + 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/hourly_key_pkg_mode" /> + + <Switch + android:id="@+id/test_logfile_toggle" + style="@style/body1" + 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" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/background_notifications_toggle" /> + + <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> + + <androidx.constraintlayout.widget.ConstraintLayout + android:id="@+id/environment_container" + style="@style/card" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="@dimen/spacing_tiny"> + + <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> + + </LinearLayout> + </androidx.core.widget.NestedScrollView> +</layout> 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 767e5f68d4038a3f1fde9349218f8f834b6c482b..a72eafd72f6db23a094411021dbf8dd578e9cfc2 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 @@ -15,132 +15,6 @@ android:layout_margin="@dimen/spacing_tiny" android:orientation="vertical"> - <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"> - - <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" /> - - <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" - android:layout_width="match_parent" - android:layout_height="wrap_content" - 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/hourly_key_pkg_mode" /> - - <Switch - android:id="@+id/test_logfile_toggle" - style="@style/body1" - 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" - app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintStart_toStartOf="parent" - app:layout_constraintTop_toBottomOf="@+id/background_notifications_toggle" /> - - <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> - - <androidx.constraintlayout.widget.ConstraintLayout - android:id="@+id/environment_container" - style="@style/card" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_margin="@dimen/spacing_tiny"> - - <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" diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml index 1746c0c05c91002998bef221502d0b3b8b009abd..807e881a75173a9c0229d1a547d2b588ec679a5c 100644 --- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_menu.xml @@ -13,7 +13,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:navigationIcon="@drawable/ic_coffee" - app:subtitle="For testers ;)" + app:subtitle="For testers & QA <3" app:title="Test Menu" /> <androidx.recyclerview.widget.RecyclerView diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml index 06f6d5af7843901137e191926078cad0a1066b5a..9e29d88d479a233ef358cbe19976dfd67bd4798f 100644 --- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_risk_level_calculation.xml @@ -191,21 +191,6 @@ android:layout_height="wrap_content" android:text="-" /> - <TextView - android:id="@+id/label_full_config_title" - style="@style/headline6" - android:accessibilityHeading="true" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:text="Full Backend Configuration" /> - - <TextView - android:id="@+id/label_full_config" - android:layout_width="match_parent" - android:layout_height="wrap_content" - android:layout_marginBottom="@dimen/spacing_normal" - android:text="-" /> - </LinearLayout> </ScrollView> </layout> \ 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 3aff7a83c201775dc37583d1da61ee3844b3eb99..1c53c77729006536c92d18efecd0b547370bb3cf 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 @@ -8,7 +8,8 @@ <fragment android:id="@+id/test_menu_fragment" android:name="de.rki.coronawarnapp.test.menu.ui.TestMenuFragment" - android:label="TestMenuFragment"> + android:label="TestMenuFragment" + tools:layout="@layout/fragment_test_menu"> <action android:id="@+id/action_testMenuFragment_to_settingsCrashReportFragment" app:destination="@id/test_bug_report_fragment" /> @@ -21,6 +22,12 @@ <action android:id="@+id/action_test_menu_fragment_to_testTaskControllerFragment" app:destination="@id/test_taskcontroller_fragment" /> + <action + android:id="@+id/action_test_menu_fragment_to_appConfigTestFragment" + app:destination="@id/test_appconfig_fragment" /> + <action + android:id="@+id/action_test_menu_fragment_to_debugOptionsFragment" + app:destination="@id/test_debugoptions_fragment" /> </fragment> <fragment @@ -61,5 +68,15 @@ android:name="de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment" android:label="TestTaskControllerFragment" tools:layout="@layout/fragment_test_task_controller" /> + <fragment + android:id="@+id/test_appconfig_fragment" + android:name="de.rki.coronawarnapp.test.appconfig.ui.AppConfigTestFragment" + android:label="AppConfigTestFragment" + tools:layout="@layout/fragment_test_appconfig" /> + <fragment + android:id="@+id/test_debugoptions_fragment" + android:name="de.rki.coronawarnapp.test.debugoptions.ui.DebugOptionsFragment" + android:label="DebugOptionsFragment" + tools:layout="@layout/fragment_test_debugoptions" /> </navigation> diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigModule.kt index 9216419bb2197d6e3736d33fe5da863ded200dbf..82936e52b38862e1288652fe250952d4e5669375 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 @@ -3,6 +3,12 @@ package de.rki.coronawarnapp.appconfig import android.content.Context import dagger.Module 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.RiskCalculationConfigMapper import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl import de.rki.coronawarnapp.util.di.AppContext @@ -54,6 +60,19 @@ class AppConfigModule { .create(AppConfigApiV1::class.java) } + @Provides + fun cwaMapper(mapper: CWAConfigMapper): CWAConfig.Mapper = mapper + + @Provides + fun downloadMapper(mapper: DownloadConfigMapper): KeyDownloadConfig.Mapper = mapper + + @Provides + fun exposurMapper(mapper: ExposureDetectionConfigMapper): ExposureDetectionConfig.Mapper = + mapper + + @Provides + fun riskMapper(mapper: RiskCalculationConfigMapper): RiskCalculationConfig.Mapper = mapper + companion object { private val HTTP_TIMEOUT_APPCONFIG = Duration.standardSeconds(10) } 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 4cbedfc9303397c2436237a51cdd51f4a515c854..91866abb5c8d0d302038a0ffce536ab629246140 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 @@ -1,149 +1,45 @@ package de.rki.coronawarnapp.appconfig -import androidx.annotation.VisibleForTesting -import dagger.Lazy -import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode -import de.rki.coronawarnapp.environment.download.DownloadCDNHomeCountry -import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration -import de.rki.coronawarnapp.util.ZipHelper.unzip -import de.rki.coronawarnapp.util.security.VerificationKeys -import kotlinx.coroutines.Dispatchers +import de.rki.coronawarnapp.util.coroutine.AppScope +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import kotlinx.coroutines.CoroutineScope +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 okhttp3.Cache import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @Singleton class AppConfigProvider @Inject constructor( - private val appConfigAPI: Lazy<AppConfigApiV1>, - private val verificationKeys: VerificationKeys, - @DownloadCDNHomeCountry private val homeCountry: LocationCode, - private val configStorage: AppConfigStorage, - @AppConfigHttpCache private val cache: Cache + private val source: AppConfigSource, + private val dispatcherProvider: DispatcherProvider, + @AppScope private val scope: CoroutineScope ) { private val mutex = Mutex() - private val configApi: AppConfigApiV1 - get() = appConfigAPI.get() + private val currentConfigInternal = MutableStateFlow<ConfigData?>(null) - @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - internal suspend fun downloadAppConfig(): ByteArray? { - Timber.tag(TAG).d("Fetching app config.") - var exportBinary: ByteArray? = null - var exportSignature: ByteArray? = null - configApi.getApplicationConfiguration(homeCountry.identifier).byteStream() - .unzip { entry, entryContent -> - if (entry.name == EXPORT_BINARY_FILE_NAME) exportBinary = - entryContent.copyOf() - if (entry.name == EXPORT_SIGNATURE_FILE_NAME) exportSignature = - entryContent.copyOf() - } - if (exportBinary == null || exportSignature == null) { - throw ApplicationConfigurationInvalidException() - } - - if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) { - throw ApplicationConfigurationCorruptException() - } - - return exportBinary!! - } - - private fun tryParseConfig(byteArray: ByteArray?): ApplicationConfiguration? { - Timber.v("Parsing config (size=%dB)", byteArray?.size) - if (byteArray == null) return null - return ApplicationConfiguration.parseFrom(byteArray) - } - - private suspend fun getNewAppConfig(): ApplicationConfiguration? { - val newConfigRaw = try { - downloadAppConfig() - } catch (e: Exception) { - Timber.w(e, "Failed to download latest AppConfig.") - if (configStorage.isAppConfigAvailable()) { - null - } else { - Timber.e("No fallback available, rethrowing!") - throw e - } - } - - val newConfigParsed = try { - tryParseConfig(newConfigRaw) - } catch (e: Exception) { - Timber.w(e, "Failed to parse latest AppConfig.") - null - } - - return newConfigParsed?.also { - Timber.d("Saving new valid config.") - Timber.v("New Config.supportedCountries: %s", it.supportedCountriesList) - configStorage.setAppConfigRaw(newConfigRaw) - } - } - - private suspend fun getFallback(): ApplicationConfiguration { - val lastValidConfig = tryParseConfig(configStorage.getAppConfigRaw()) - return if (lastValidConfig != null) { - Timber.d("Using fallback AppConfig.") - lastValidConfig - } else { - Timber.e("No valid fallback AppConfig available.") - throw ApplicationConfigurationInvalidException() - } - } - - suspend fun getAppConfig(): ApplicationConfiguration = mutex.withLock { - withContext(Dispatchers.IO) { - - val newAppConfig = getNewAppConfig() - - return@withContext if (newAppConfig != null) { - newAppConfig - } else { - Timber.w("No new config available, using last valid.") - getFallback() - } - }.performSanityChecks() - } + val currentConfig: Flow<ConfigData?> = currentConfigInternal suspend fun clear() = mutex.withLock { - withContext(Dispatchers.IO) { - configStorage.setAppConfigRaw(null) - - // We are using Dispatchers IO to make it appropriate - @Suppress("BlockingMethodInNonBlockingContext") - cache.evictAll() - } + Timber.tag(TAG).v("clear()") + source.clear() + currentConfigInternal.value = null } - private fun ApplicationConfiguration.performSanityChecks(): ApplicationConfiguration { - var sanityChecked = this - - if (sanityChecked.supportedCountriesList == null) { - sanityChecked = sanityChecked.toNewConfig { - clearSupportedCountries() - addAllSupportedCountries(emptyList<String>()) - } - } - - val countryCheck = sanityChecked.supportedCountriesList - if (countryCheck.size == 1 && !VALID_CC.matches(countryCheck.single())) { - Timber.w("Invalid country data, clearing. (%s)", this.supportedCountriesList) - sanityChecked = sanityChecked.toNewConfig { - clearSupportedCountries() + suspend fun getAppConfig(): ConfigData = mutex.withLock { + Timber.tag(TAG).v("getAppConfig()") + withContext(context = scope.coroutineContext + dispatcherProvider.IO) { + source.retrieveConfig().also { + currentConfigInternal.emit(it) } } - return sanityChecked } companion object { - private val VALID_CC = "^([A-Z]{2,3})$".toRegex() - private const val EXPORT_BINARY_FILE_NAME = "export.bin" - private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig" - private val TAG = AppConfigProvider::class.java.simpleName + private const val TAG = "AppConfigProvider" } } 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 new file mode 100644 index 0000000000000000000000000000000000000000..fd31afca8ef1e6ce15beef7927dab09473d752ca --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigSource.kt @@ -0,0 +1,82 @@ +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.mapping.ConfigParser +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppConfigSource @Inject constructor( + private val server: AppConfigServer, + private val storage: AppConfigStorage, + private val parser: ConfigParser, + private val dispatcherProvider: DispatcherProvider +) { + + suspend fun retrieveConfig(): ConfigData = withContext(dispatcherProvider.IO) { + Timber.v("retrieveConfig()") + val (serverBytes, serverError) = try { + server.downloadAppConfig() to null + } catch (e: Exception) { + Timber.tag(TAG).w(e, "Failed to download AppConfig from server .") + null to e + } + + var parsedConfig: ConfigData? = serverBytes?.let { configDownload -> + try { + parser.parse(configDownload.rawData).let { + Timber.tag(TAG).d("Got a valid AppConfig from server, saving.") + storage.setStoredConfig(configDownload) + DefaultConfigData( + mappedConfig = it, + serverTime = configDownload.serverTime, + localOffset = configDownload.localOffset, + isFallback = false + ) + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to parse AppConfig from server, trying fallback.") + null + } + } + + if (parsedConfig == null) { + parsedConfig = storage.getStoredConfig()?.let { storedDownloadConfig -> + try { + storedDownloadConfig.let { + DefaultConfigData( + mappedConfig = parser.parse(it.rawData), + serverTime = it.serverTime, + localOffset = it.localOffset, + isFallback = true + ) + } + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Fallback config exists but could not be parsed!") + throw e + } + } + } + + if (parsedConfig == null) { + throw ApplicationConfigurationInvalidException(serverError) + } + + return@withContext parsedConfig + } + + suspend fun clear() { + storage.setStoredConfig(null) + + server.clearCache() + } + + companion object { + private const val TAG = "AppConfigRetriever" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt deleted file mode 100644 index 54b6351b104cec7b0d98305dee5f7ba040f5572b..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigStorage.kt +++ /dev/null @@ -1,52 +0,0 @@ -package de.rki.coronawarnapp.appconfig - -import android.content.Context -import de.rki.coronawarnapp.util.di.AppContext -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import timber.log.Timber -import java.io.File -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class AppConfigStorage @Inject constructor( - @AppContext context: Context -) { - private val configDir = File(context.filesDir, "appconfig_storage") - private val configFile = File(configDir, "appconfig") - private val mutex = Mutex() - - suspend fun isAppConfigAvailable(): Boolean = mutex.withLock { - configFile.exists() && configFile.length() > MIN_VALID_CONFIG_BYTES - } - - suspend fun getAppConfigRaw(): ByteArray? = mutex.withLock { - Timber.v("get() AppConfig") - if (!configFile.exists()) return null - - val value = configFile.readBytes() - Timber.v("Read AppConfig of size %s and date %s", value.size, configFile.lastModified()) - return value - } - - suspend fun setAppConfigRaw(value: ByteArray?): Unit = mutex.withLock { - Timber.v("set(...) AppConfig: %dB", value?.size) - - if (configDir.mkdirs()) Timber.v("Parent folder created.") - - if (configFile.exists()) { - Timber.v("Overwriting %d from %s", configFile.length(), configFile.lastModified()) - } - if (value != null) { - configFile.writeBytes(value) - } else { - configFile.delete() - } - } - - companion object { - // The normal config is ~512B+, we just need to check for a non 0 value, 128 is fine. - private const val MIN_VALID_CONFIG_BYTES = 128 - } -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt deleted file mode 100644 index 153435397fcff0fa237e32723413fd5aae3f9c30..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationInvalidException.kt +++ /dev/null @@ -1,8 +0,0 @@ -package de.rki.coronawarnapp.appconfig - -import de.rki.coronawarnapp.exception.reporting.ErrorCodes -import de.rki.coronawarnapp.exception.reporting.ReportedException - -class ApplicationConfigurationInvalidException : ReportedException( - ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code, "the application configuration is invalid" -) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt new file mode 100644 index 0000000000000000000000000000000000000000..0a11bf822114ca77b02a3bdd283c0a83b6dad69a --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/CWAConfig.kt @@ -0,0 +1,16 @@ +package de.rki.coronawarnapp.appconfig + +import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper +import de.rki.coronawarnapp.server.protocols.internal.AppFeaturesOuterClass +import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig + +interface CWAConfig { + + val appVersion: AppVersionConfig.ApplicationVersionConfiguration + + val supportedCountries: List<String> + + val appFeatureus: AppFeaturesOuterClass.AppFeatures + + interface Mapper : ConfigMapper<CWAConfig> +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..6845e3d29be4db488fb1fe29e5bfdd9102040672 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ConfigData.kt @@ -0,0 +1,24 @@ +package de.rki.coronawarnapp.appconfig + +import de.rki.coronawarnapp.appconfig.mapping.ConfigMapping +import org.joda.time.Duration +import org.joda.time.Instant + +interface ConfigData : ConfigMapping { + + /** + * serverTime + localOffset = updatedAt + */ + val updatedAt: Instant + + /** + * If **[isFallback]** returns true, + * 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. + */ + val isFallback: Boolean +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..5fc918d095771bf9df33b81ec5cf591595ed46b5 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/DefaultConfigData.kt @@ -0,0 +1,14 @@ +package de.rki.coronawarnapp.appconfig + +import de.rki.coronawarnapp.appconfig.mapping.ConfigMapping +import org.joda.time.Duration +import org.joda.time.Instant + +data class DefaultConfigData( + val serverTime: Instant, + val mappedConfig: ConfigMapping, + override val localOffset: Duration, + override val isFallback: Boolean +) : 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 new file mode 100644 index 0000000000000000000000000000000000000000..5281c51ede2e8a7d12d55e56be4ccb92b8155031 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ExposureDetectionConfig.kt @@ -0,0 +1,13 @@ +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 + +interface ExposureDetectionConfig { + + val exposureDetectionConfiguration: ExposureConfiguration + val exposureDetectionParameters: ExposureDetectionParameters.ExposureDetectionParametersAndroid + + interface Mapper : ConfigMapper<ExposureDetectionConfig> +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..d82a6cd3a3ecc741986426e6de9013ee8a3626f6 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/KeyDownloadConfig.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.appconfig + +import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper +import de.rki.coronawarnapp.server.protocols.internal.KeyDownloadParameters + +interface KeyDownloadConfig { + + val keyDownloadParameters: KeyDownloadParameters.KeyDownloadParametersAndroid + + interface Mapper : ConfigMapper<KeyDownloadConfig> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt new file mode 100644 index 0000000000000000000000000000000000000000..2c19c0be637e843d0137d3db4983f1ccce8de97c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/RiskCalculationConfig.kt @@ -0,0 +1,16 @@ +package de.rki.coronawarnapp.appconfig + +import de.rki.coronawarnapp.appconfig.mapping.ConfigMapper +import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass +import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass + +interface RiskCalculationConfig { + + val minRiskScore: Int + + val attenuationDuration: AttenuationDurationOuterClass.AttenuationDuration + + val riskScoreClasses: RiskScoreClassificationOuterClass.RiskScoreClassification + + interface Mapper : ConfigMapper<RiskCalculationConfig> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt similarity index 71% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt index ae8898f1aabacd8f602ffc16065bcefe7040fb6a..0c3f61077cc884adf45aa8a02a78e2ed5fe7154d 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigApiV1.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiV1.kt @@ -1,6 +1,7 @@ -package de.rki.coronawarnapp.appconfig +package de.rki.coronawarnapp.appconfig.download import okhttp3.ResponseBody +import retrofit2.Response import retrofit2.http.GET import retrofit2.http.Path @@ -9,5 +10,5 @@ interface AppConfigApiV1 { @GET("/version/v1/configuration/country/{country}/app_config") suspend fun getApplicationConfiguration( @Path("country") country: String - ): ResponseBody + ): Response<ResponseBody> } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt similarity index 74% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt index a3aff4add4b1df85c9093fa97efe839f2f57a924..253ac97d3fbedc4a7c0c5429cad471f77cac0837 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/AppConfigHttpCache.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigHttpCache.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.appconfig +package de.rki.coronawarnapp.appconfig.download import javax.inject.Qualifier 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 new file mode 100644 index 0000000000000000000000000000000000000000..1d707c26bb248982443582fdba7ed672c640fe1e --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigServer.kt @@ -0,0 +1,99 @@ +package de.rki.coronawarnapp.appconfig.download + +import dagger.Lazy +import dagger.Reusable +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +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.security.VerificationKeys +import okhttp3.Cache +import org.joda.time.Duration +import org.joda.time.Instant +import org.joda.time.format.DateTimeFormat +import retrofit2.HttpException +import retrofit2.Response +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject + +@Reusable +class AppConfigServer @Inject constructor( + private val api: Lazy<AppConfigApiV1>, + private val verificationKeys: VerificationKeys, + private val timeStamper: TimeStamper, + @DownloadCDNHomeCountry private val homeCountry: LocationCode, + @AppConfigHttpCache private val cache: Cache +) { + + internal suspend fun downloadAppConfig(): ConfigDownload { + Timber.tag(TAG).d("Fetching app config.") + + 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" } + ) { + val fileMap = byteStream().unzip().readIntoMap() + + val exportBinary = fileMap[EXPORT_BINARY_FILE_NAME] + val exportSignature = fileMap[EXPORT_SIGNATURE_FILE_NAME] + + if (exportBinary == null || exportSignature == null) { + throw ApplicationConfigurationInvalidException(message = "Unknown files: ${fileMap.keys}") + } + + if (verificationKeys.hasInvalidSignature(exportBinary, exportSignature)) { + throw ApplicationConfigurationCorruptException() + } + + exportBinary + } + + val serverTime = response.getServerDate() ?: localTime + val offset = Duration(serverTime, localTime) + Timber.tag(TAG).v("Time offset was %dms", offset.millis) + + return ConfigDownload( + rawData = rawConfig, + serverTime = serverTime, + localOffset = offset + ) + } + + private fun <T> Response<T>.getServerDate(): Instant? = try { + val rawDate = headers()["Date"] ?: throw IllegalArgumentException( + "Server date unavailable: ${headers()}" + ) + Instant.parse(rawDate, DATE_FORMAT) + } catch (e: Exception) { + Timber.e("Failed to get server time.") + null + } + + private fun <T> Response<T>.getCacheTimestamp(): Instant? { + val cacheResponse = raw().cacheResponse + return cacheResponse?.sentRequestAtMillis?.let { + Instant.ofEpochMilli(it) + } + } + + internal fun clearCache() { + Timber.tag(TAG).v("clearCache()") + cache.evictAll() + } + + companion object { + private const val EXPORT_BINARY_FILE_NAME = "export.bin" + private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig" + private val DATE_FORMAT = DateTimeFormat + .forPattern("EEE, dd MMM yyyy HH:mm:ss zzz") + .withLocale(Locale.ROOT) + private val TAG = AppConfigServer::class.java.simpleName + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..c5a86a92887ab5c26dd0afe1dd54ee4220186e6f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorage.kt @@ -0,0 +1,87 @@ +package de.rki.coronawarnapp.appconfig.download + +import android.content.Context +import com.google.gson.Gson +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.serialization.BaseGson +import de.rki.coronawarnapp.util.serialization.adapter.DurationAdapter +import de.rki.coronawarnapp.util.serialization.adapter.InstantAdapter +import de.rki.coronawarnapp.util.serialization.fromJson +import de.rki.coronawarnapp.util.serialization.toJson +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.joda.time.Duration +import org.joda.time.Instant +import timber.log.Timber +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AppConfigStorage @Inject constructor( + @AppContext context: Context, + private val timeStamper: TimeStamper, + @BaseGson private val baseGson: Gson +) { + + private val gson by lazy { + baseGson.newBuilder() + .registerTypeAdapter(Instant::class.java, InstantAdapter()) + .registerTypeAdapter(Duration::class.java, DurationAdapter()) + .create() + } + private val configDir = File(context.filesDir, "appconfig_storage") + + // This is just the raw protobuf data + private val legacyConfigFile = File(configDir, "appconfig") + private val configFile = File(configDir, "appconfig.json") + private val mutex = Mutex() + + suspend fun getStoredConfig(): ConfigDownload? = mutex.withLock { + Timber.v("get() AppConfig") + + if (!configFile.exists() && legacyConfigFile.exists()) { + Timber.i("Returning legacy config.") + return@withLock try { + ConfigDownload( + rawData = legacyConfigFile.readBytes(), + serverTime = timeStamper.nowUTC, + localOffset = Duration.ZERO + ) + } catch (e: Exception) { + Timber.e(e, "Legacy config exits but couldn't be read.") + null + } + } + + return@withLock try { + gson.fromJson<ConfigDownload>(configFile) + } catch (e: Exception) { + Timber.e(e, "Couldn't load config.") + null + } + } + + suspend fun setStoredConfig(value: ConfigDownload?): Unit = mutex.withLock { + Timber.v("set(...) AppConfig: %s", value) + + if (configDir.mkdirs()) Timber.v("Parent folder created.") + + if (configFile.exists()) { + Timber.v("Overwriting %d from %s", configFile.length(), configFile.lastModified()) + } + + if (value != null) { + gson.toJson(value, configFile) + + if (legacyConfigFile.exists()) { + if (legacyConfigFile.delete()) { + Timber.i("Legacy config file deleted, superseeded.") + } + } + } else { + configFile.delete() + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt similarity index 86% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt index 51c5dfb8933f457956bf161cd29acc5defef8c94..bd6940034f8af15de4d110ab65ece6eb9a34cd94 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationCorruptException.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationCorruptException.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.appconfig +package de.rki.coronawarnapp.appconfig.download import de.rki.coronawarnapp.exception.reporting.ErrorCodes import de.rki.coronawarnapp.exception.reporting.ReportedException diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt new file mode 100644 index 0000000000000000000000000000000000000000..63cb1069e92febfd84f5a894a8d9dbf3c26ea618 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ApplicationConfigurationInvalidException.kt @@ -0,0 +1,13 @@ +package de.rki.coronawarnapp.appconfig.download + +import de.rki.coronawarnapp.exception.reporting.ErrorCodes +import de.rki.coronawarnapp.exception.reporting.ReportedException + +class ApplicationConfigurationInvalidException( + cause: Exception? = null, + message: String? = null +) : ReportedException( + code = ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code, + message = message, + cause = cause +) 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 new file mode 100644 index 0000000000000000000000000000000000000000..1f9ba9050b61f2a5d802e2f5d692cdeba3881369 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/download/ConfigDownload.kt @@ -0,0 +1,31 @@ +package de.rki.coronawarnapp.appconfig.download + +import com.google.gson.annotations.SerializedName +import org.joda.time.Duration +import org.joda.time.Instant + +data class ConfigDownload( + @SerializedName("rawData") val rawData: ByteArray, + @SerializedName("serverTime") val serverTime: Instant, + @SerializedName("localOffset") val localOffset: Duration +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as ConfigDownload + + if (!rawData.contentEquals(other.rawData)) return false + if (serverTime != other.serverTime) return false + if (localOffset != other.localOffset) return false + + return true + } + + override fun hashCode(): Int { + var result = rawData.contentHashCode() + result = 31 * result + serverTime.hashCode() + result = 31 * result + localOffset.hashCode() + return result + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt similarity index 86% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt index 9ac63ab9afff3274371dfeb60d95bd4ffb3fd5d9..9177f5ae96a596077080a79bb5a581f9a8a112cf 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensions.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.appconfig +package de.rki.coronawarnapp.appconfig.mapping import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..8d78dddcdb0e87acb6e06cb32e7f3405e426a2b1 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapper.kt @@ -0,0 +1,42 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import androidx.annotation.VisibleForTesting +import dagger.Reusable +import de.rki.coronawarnapp.appconfig.CWAConfig +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import de.rki.coronawarnapp.server.protocols.internal.AppFeaturesOuterClass +import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class CWAConfigMapper @Inject constructor() : CWAConfig.Mapper { + override fun map(rawConfig: AppConfig.ApplicationConfiguration): CWAConfig { + return CWAConfigContainer( + appVersion = rawConfig.appVersion, + supportedCountries = rawConfig.getMappedSupportedCountries(), + appFeatureus = rawConfig.appFeatures + ) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun AppConfig.ApplicationConfiguration.getMappedSupportedCountries(): List<String> = + when { + supportedCountriesList == null -> emptyList() + supportedCountriesList.size == 1 && !VALID_CC.matches(supportedCountriesList.single()) -> { + Timber.w("Invalid country data, clearing. (%s)", supportedCountriesList) + emptyList() + } + else -> supportedCountriesList + } + + data class CWAConfigContainer( + override val appVersion: AppVersionConfig.ApplicationVersionConfiguration, + override val supportedCountries: List<String>, + override val appFeatureus: AppFeaturesOuterClass.AppFeatures + ) : CWAConfig + + companion object { + private val VALID_CC = "^([A-Z]{2,3})$".toRegex() + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..58c4b88b2f7beaff9765715e7c1ed54a4916f199 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapper.kt @@ -0,0 +1,7 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import de.rki.coronawarnapp.server.protocols.internal.AppConfig + +interface ConfigMapper<T> { + fun map(rawConfig: AppConfig.ApplicationConfiguration): T +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt new file mode 100644 index 0000000000000000000000000000000000000000..9858ec812bfc0b74c37330b2d44704decd085a5d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigMapping.kt @@ -0,0 +1,17 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import de.rki.coronawarnapp.appconfig.CWAConfig +import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.appconfig.RiskCalculationConfig +import de.rki.coronawarnapp.server.protocols.internal.AppConfig + +interface ConfigMapping : + CWAConfig, + KeyDownloadConfig, + ExposureDetectionConfig, + RiskCalculationConfig { + + @Deprecated("Try to access a more specific config type, avoid the RAW variant.") + val rawConfig: AppConfig.ApplicationConfiguration +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt new file mode 100644 index 0000000000000000000000000000000000000000..8449b81b7cf3c5e996dc8121f97de585bc0ef693 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParser.kt @@ -0,0 +1,39 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import dagger.Reusable +import de.rki.coronawarnapp.appconfig.CWAConfig +import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.appconfig.RiskCalculationConfig +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class ConfigParser @Inject constructor( + private val cwaConfigMapper: CWAConfig.Mapper, + private val keyDownloadConfigMapper: KeyDownloadConfig.Mapper, + private val exposureDetectionConfigMapper: ExposureDetectionConfig.Mapper, + private val riskCalculationConfigMapper: RiskCalculationConfig.Mapper +) { + + fun parse(configBytes: ByteArray): ConfigMapping = try { + parseRawArray(configBytes).let { + DefaultConfigMapping( + rawConfig = it, + cwaConfig = cwaConfigMapper.map(it), + keyDownloadConfig = keyDownloadConfigMapper.map(it), + exposureDetectionConfig = exposureDetectionConfigMapper.map(it), + riskCalculationConfig = riskCalculationConfigMapper.map(it) + ) + } + } catch (e: Exception) { + Timber.w(e, "Failed to parse AppConfig: %s", configBytes) + throw e + } + + private fun parseRawArray(configBytes: ByteArray): AppConfig.ApplicationConfiguration { + Timber.v("Parsing config (size=%dB)", configBytes.size) + return AppConfig.ApplicationConfiguration.parseFrom(configBytes) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt new file mode 100644 index 0000000000000000000000000000000000000000..783385ddfdd3e7c06198eaff8c0ef5ba5a2961ff --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DefaultConfigMapping.kt @@ -0,0 +1,19 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import de.rki.coronawarnapp.appconfig.CWAConfig +import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.appconfig.RiskCalculationConfig +import de.rki.coronawarnapp.server.protocols.internal.AppConfig + +data class DefaultConfigMapping( + override val rawConfig: AppConfig.ApplicationConfiguration, + val cwaConfig: CWAConfig, + val keyDownloadConfig: KeyDownloadConfig, + val exposureDetectionConfig: ExposureDetectionConfig, + val riskCalculationConfig: RiskCalculationConfig +) : ConfigMapping, + CWAConfig by cwaConfig, + KeyDownloadConfig by keyDownloadConfig, + ExposureDetectionConfig by exposureDetectionConfig, + RiskCalculationConfig by riskCalculationConfig 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 new file mode 100644 index 0000000000000000000000000000000000000000..752f41cb176c35ca3ba562185dcf172548e098d1 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapper.kt @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..c010e25af42a0d9b70693ae7522488c33d5bcc4c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapper.kt @@ -0,0 +1,74 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import androidx.annotation.VisibleForTesting +import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration +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 javax.inject.Inject + +@Reusable +class ExposureDetectionConfigMapper @Inject constructor() : ExposureDetectionConfig.Mapper { + override fun map(rawConfig: AppConfig.ApplicationConfiguration): ExposureDetectionConfig = + ExposureDetectionConfigContainer( + exposureDetectionConfiguration = rawConfig.mapRiskScoreToExposureConfiguration(), + exposureDetectionParameters = rawConfig.androidExposureDetectionParameters + ) + + data class ExposureDetectionConfigContainer( + override val exposureDetectionConfiguration: ExposureConfiguration, + override val exposureDetectionParameters: ExposureDetectionParametersAndroid + ) : ExposureDetectionConfig +} + +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +fun AppConfig.ApplicationConfiguration.mapRiskScoreToExposureConfiguration(): ExposureConfiguration = + ExposureConfiguration + .ExposureConfigurationBuilder() + .setTransmissionRiskScores( + this.exposureConfig.transmission.appDefined1Value, + this.exposureConfig.transmission.appDefined2Value, + this.exposureConfig.transmission.appDefined3Value, + this.exposureConfig.transmission.appDefined4Value, + this.exposureConfig.transmission.appDefined5Value, + this.exposureConfig.transmission.appDefined6Value, + this.exposureConfig.transmission.appDefined7Value, + this.exposureConfig.transmission.appDefined8Value + ) + .setDurationScores( + this.exposureConfig.duration.eq0MinValue, + this.exposureConfig.duration.gt0Le5MinValue, + this.exposureConfig.duration.gt5Le10MinValue, + this.exposureConfig.duration.gt10Le15MinValue, + this.exposureConfig.duration.gt15Le20MinValue, + this.exposureConfig.duration.gt20Le25MinValue, + this.exposureConfig.duration.gt25Le30MinValue, + this.exposureConfig.duration.gt30MinValue + ) + .setDaysSinceLastExposureScores( + this.exposureConfig.daysSinceLastExposure.ge14DaysValue, + this.exposureConfig.daysSinceLastExposure.ge12Lt14DaysValue, + this.exposureConfig.daysSinceLastExposure.ge10Lt12DaysValue, + this.exposureConfig.daysSinceLastExposure.ge8Lt10DaysValue, + this.exposureConfig.daysSinceLastExposure.ge6Lt8DaysValue, + this.exposureConfig.daysSinceLastExposure.ge4Lt6DaysValue, + this.exposureConfig.daysSinceLastExposure.ge2Lt4DaysValue, + this.exposureConfig.daysSinceLastExposure.ge0Lt2DaysValue + ) + .setAttenuationScores( + this.exposureConfig.attenuation.gt73DbmValue, + this.exposureConfig.attenuation.gt63Le73DbmValue, + this.exposureConfig.attenuation.gt51Le63DbmValue, + this.exposureConfig.attenuation.gt33Le51DbmValue, + this.exposureConfig.attenuation.gt27Le33DbmValue, + this.exposureConfig.attenuation.gt15Le27DbmValue, + this.exposureConfig.attenuation.gt10Le15DbmValue, + this.exposureConfig.attenuation.le10DbmValue + ) + .setMinimumRiskScore(this.minRiskScore) + .setDurationAtAttenuationThresholds( + this.attenuationDuration.thresholds.lower, + this.attenuationDuration.thresholds.upper + ) + .build() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..dd36d4ea99f8f56af32e83fee682d90cc164460f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapper.kt @@ -0,0 +1,26 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import dagger.Reusable +import de.rki.coronawarnapp.appconfig.RiskCalculationConfig +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import de.rki.coronawarnapp.server.protocols.internal.AttenuationDurationOuterClass +import de.rki.coronawarnapp.server.protocols.internal.RiskScoreClassificationOuterClass +import javax.inject.Inject + +@Reusable +class RiskCalculationConfigMapper @Inject constructor() : RiskCalculationConfig.Mapper { + + override fun map(rawConfig: AppConfig.ApplicationConfiguration): RiskCalculationConfig { + return RiskCalculationContainer( + minRiskScore = rawConfig.minRiskScore, + riskScoreClasses = rawConfig.riskScoreClasses, + attenuationDuration = rawConfig.attenuationDuration + ) + } + + data class RiskCalculationContainer( + override val minRiskScore: Int, + override val attenuationDuration: AttenuationDurationOuterClass.AttenuationDuration, + override val riskScoreClasses: RiskScoreClassificationOuterClass.RiskScoreClassification + ) : RiskCalculationConfig +} 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/calculationtracker/CalculationTrackerStorage.kt index 1aaf4d63b9fe1806db9ec52b6c7760786fe27017..18fb150e31c1164039079bf0130f5037bdd2c533 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/calculationtracker/CalculationTrackerStorage.kt @@ -3,11 +3,13 @@ package de.rki.coronawarnapp.nearby.modules.calculationtracker import android.content.Context import com.google.gson.Gson import de.rki.coronawarnapp.util.di.AppContext -import de.rki.coronawarnapp.util.gson.fromJson -import de.rki.coronawarnapp.util.gson.toJson import de.rki.coronawarnapp.util.serialization.BaseGson +import de.rki.coronawarnapp.util.serialization.fromJson +import de.rki.coronawarnapp.util.serialization.getDefaultGsonTypeAdapter +import de.rki.coronawarnapp.util.serialization.toJson import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import org.joda.time.Instant import timber.log.Timber import java.io.File import javax.inject.Inject @@ -16,8 +18,14 @@ import javax.inject.Singleton @Singleton class CalculationTrackerStorage @Inject constructor( @AppContext private val context: Context, - @BaseGson private val gson: Gson + @BaseGson gson: Gson ) { + private val gson by lazy { + gson.newBuilder() + .registerTypeAdapter(Instant::class.java, Instant::class.getDefaultGsonTypeAdapter()) + .create() + } + private val mutex = Mutex() private val storageDir by lazy { File(context.filesDir, "calcuation_tracker").apply { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt deleted file mode 100644 index 16ac02b7963ed29b9d0e3239d480d891abcfaa39..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/applicationconfiguration/ApplicationConfigurationService.kt +++ /dev/null @@ -1,65 +0,0 @@ -package de.rki.coronawarnapp.service.applicationconfiguration - -import com.google.android.gms.nearby.exposurenotification.ExposureConfiguration -import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration -import de.rki.coronawarnapp.util.di.AppInjector - -object ApplicationConfigurationService { - suspend fun asyncRetrieveApplicationConfiguration(): ApplicationConfiguration { - return AppInjector.component.appConfigProvider.getAppConfig() - } - - suspend fun asyncRetrieveExposureConfiguration(): ExposureConfiguration = - asyncRetrieveApplicationConfiguration() - .mapRiskScoreToExposureConfiguration() - - private fun ApplicationConfiguration.mapRiskScoreToExposureConfiguration(): ExposureConfiguration = - ExposureConfiguration - .ExposureConfigurationBuilder() - .setTransmissionRiskScores( - this.exposureConfig.transmission.appDefined1Value, - this.exposureConfig.transmission.appDefined2Value, - this.exposureConfig.transmission.appDefined3Value, - this.exposureConfig.transmission.appDefined4Value, - this.exposureConfig.transmission.appDefined5Value, - this.exposureConfig.transmission.appDefined6Value, - this.exposureConfig.transmission.appDefined7Value, - this.exposureConfig.transmission.appDefined8Value - ) - .setDurationScores( - this.exposureConfig.duration.eq0MinValue, - this.exposureConfig.duration.gt0Le5MinValue, - this.exposureConfig.duration.gt5Le10MinValue, - this.exposureConfig.duration.gt10Le15MinValue, - this.exposureConfig.duration.gt15Le20MinValue, - this.exposureConfig.duration.gt20Le25MinValue, - this.exposureConfig.duration.gt25Le30MinValue, - this.exposureConfig.duration.gt30MinValue - ) - .setDaysSinceLastExposureScores( - this.exposureConfig.daysSinceLastExposure.ge14DaysValue, - this.exposureConfig.daysSinceLastExposure.ge12Lt14DaysValue, - this.exposureConfig.daysSinceLastExposure.ge10Lt12DaysValue, - this.exposureConfig.daysSinceLastExposure.ge8Lt10DaysValue, - this.exposureConfig.daysSinceLastExposure.ge6Lt8DaysValue, - this.exposureConfig.daysSinceLastExposure.ge4Lt6DaysValue, - this.exposureConfig.daysSinceLastExposure.ge2Lt4DaysValue, - this.exposureConfig.daysSinceLastExposure.ge0Lt2DaysValue - ) - .setAttenuationScores( - this.exposureConfig.attenuation.gt73DbmValue, - this.exposureConfig.attenuation.gt63Le73DbmValue, - this.exposureConfig.attenuation.gt51Le63DbmValue, - this.exposureConfig.attenuation.gt33Le51DbmValue, - this.exposureConfig.attenuation.gt27Le33DbmValue, - this.exposureConfig.attenuation.gt15Le27DbmValue, - this.exposureConfig.attenuation.gt10Le15DbmValue, - this.exposureConfig.attenuation.le10DbmValue - ) - .setMinimumRiskScore(this.minRiskScore) - .setDurationAtAttenuationThresholds( - this.attenuationDuration.thresholds.lower, - this.attenuationDuration.thresholds.upper - ) - .build() -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt index b29b2469819cf00badd319ae1a24ef7c3a6fafbf..f14a53f5ff4656b76205d9e1e847d695e4cf3e76 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/storage/interoperability/InteroperabilityRepository.kt @@ -40,7 +40,7 @@ class InteroperabilityRepository @Inject constructor( runBlocking { try { val countries = appConfigProvider.getAppConfig() - .supportedCountriesList + .supportedCountries .mapNotNull { rawCode -> val countryCode = rawCode.toLowerCase(Locale.ROOT) 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 8c1bb01b5ac4ff83b96705428fdf5b2c508c9213..7b605f10eaed4cc0b218eb2819301e931d15c7a3 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 @@ -2,10 +2,8 @@ package de.rki.coronawarnapp.submission import com.google.android.gms.nearby.exposurenotification.TemporaryExposureKey import de.rki.coronawarnapp.appconfig.AppConfigProvider -import de.rki.coronawarnapp.appconfig.toNewConfig import de.rki.coronawarnapp.playbook.Playbook import de.rki.coronawarnapp.server.protocols.external.exposurenotification.TemporaryExposureKeyExportOuterClass -import de.rki.coronawarnapp.server.protocols.internal.AppConfig import de.rki.coronawarnapp.service.submission.SubmissionService import de.rki.coronawarnapp.task.Task import de.rki.coronawarnapp.task.TaskCancellationException @@ -31,19 +29,20 @@ class SubmissionTask @Inject constructor( private var isCanceled = false override suspend fun run(arguments: Task.Arguments) = try { - arguments as Arguments Timber.d("Running with arguments=%s", arguments) + arguments as Arguments + Playbook.SubmissionData( arguments.registrationToken, arguments.getHistory(), true, - applicationConfiguration().supportedCountriesList.also { - Timber.w("supported countries = $it") - } + getSupportedCountries() ) .also { checkCancel() } .let { playbook.submit(it) } + SubmissionService.submissionSuccessful() + object : Task.Result {} } catch (error: Exception) { Timber.tag(TAG).e(error) @@ -59,17 +58,15 @@ class SubmissionTask @Inject constructor( symptoms ) - private suspend fun applicationConfiguration(): AppConfig.ApplicationConfiguration { - var result = appConfigProvider.getAppConfig() - - if (result.supportedCountriesList.isEmpty()) { - result = result.toNewConfig { - addSupportedCountries(FALLBACK_COUNTRY) + private suspend fun getSupportedCountries(): List<String> { + val countries = appConfigProvider.getAppConfig().supportedCountries + return when { + countries.isEmpty() -> { + Timber.w("Country list was empty, corrected") + listOf(FALLBACK_COUNTRY) } - Timber.w("Country list was empty, corrected") - } - - return result + else -> countries + }.also { Timber.i("Supported countries = $it") } } private fun checkCancel() { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt index 036905b788a065b9b14684da8811ad8b42bd32f4..493bb0c9e41d74b398d64b5c83012a72da296d5f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransaction.kt @@ -26,7 +26,6 @@ import de.rki.coronawarnapp.diagnosiskeys.storage.KeyCacheRepository import de.rki.coronawarnapp.environment.EnvironmentSetup import de.rki.coronawarnapp.nearby.ENFClient import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient -import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.API_SUBMISSION import de.rki.coronawarnapp.transaction.RetrieveDiagnosisKeysTransaction.RetrieveDiagnosisKeysTransactionState.CLOSE @@ -195,11 +194,10 @@ object RetrieveDiagnosisKeysTransaction : Transaction() { val countries = if (environmentSetup.useEuropeKeyPackageFiles) { listOf("EUR") } else { - requestedCountries ?: ApplicationConfigurationService - .asyncRetrieveApplicationConfiguration() - .supportedCountriesList + requestedCountries + ?: AppInjector.component.appConfigProvider.getAppConfig().supportedCountries } - invokeSubmissionStartedInDebugOrBuildMode() + invokeSubmissionStartedInDebugOrBuildMode() val availableKeyFiles = executeFetchKeyFilesFromServer(countries) @@ -295,7 +293,7 @@ object RetrieveDiagnosisKeysTransaction : Transaction() { */ private suspend fun executeRetrieveRiskScoreParams() = executeState(RETRIEVE_RISK_SCORE_PARAMS) { - ApplicationConfigurationService.asyncRetrieveExposureConfiguration() + AppInjector.component.appConfigProvider.getAppConfig().exposureDetectionConfiguration } /** diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt index b203364fc37e060d4a8d6a56533f4818e0c1e06b..42dfb2f60231fc8461b4b164d8d14bad74a37160 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/update/UpdateChecker.kt @@ -6,10 +6,11 @@ import androidx.appcompat.app.AlertDialog import androidx.core.content.ContextCompat.startActivity import de.rki.coronawarnapp.BuildConfig import de.rki.coronawarnapp.R -import de.rki.coronawarnapp.appconfig.ApplicationConfigurationCorruptException +import de.rki.coronawarnapp.appconfig.CWAConfig +import de.rki.coronawarnapp.appconfig.download.ApplicationConfigurationCorruptException import de.rki.coronawarnapp.server.protocols.internal.AppVersionConfig.SemanticVersion -import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService import de.rki.coronawarnapp.ui.LauncherActivity +import de.rki.coronawarnapp.util.di.AppInjector import timber.log.Timber class UpdateChecker(private val activity: LauncherActivity) { @@ -66,10 +67,9 @@ class UpdateChecker(private val activity: LauncherActivity) { } private suspend fun checkIfUpdatesNeededFromServer(): Boolean { - val applicationConfigurationFromServer = - ApplicationConfigurationService.asyncRetrieveApplicationConfiguration() + val cwaAppConfig: CWAConfig = AppInjector.component.appConfigProvider.getAppConfig() - val minVersionFromServer = applicationConfigurationFromServer.appVersion.android.min + val minVersionFromServer = cwaAppConfig.appVersion.android.min val minVersionFromServerString = constructSemanticVersionString(minVersionFromServer) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt index b99b9f2ccd8768f361e697298f185bb96511e5dc..97839c1c3b2f9e10205070f7cfbbb5747dae35da 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/HashExtensions.kt @@ -6,21 +6,29 @@ import java.util.Locale internal object HashExtensions { + fun ByteArray.toSHA256() = this.hashByteArray("SHA-256") + + fun ByteArray.toSHA1() = this.hashByteArray("SHA-1") + + fun ByteArray.toMD5() = this.hashByteArray("MD5") + fun String.toSHA256() = this.hashString("SHA-256") fun String.toSHA1() = this.hashString("SHA-1") fun String.toMD5() = this.hashString("MD5") - private fun ByteArray.formatHash(): String = this - .joinToString(separator = "") { String.format("%02X", it) } - .toLowerCase(Locale.ROOT) + private fun String.hashString(type: String): String = toByteArray().hashByteArray(type) - private fun String.hashString(type: String): String = MessageDigest + private fun ByteArray.hashByteArray(type: String): String = MessageDigest .getInstance(type) - .digest(this.toByteArray()) + .digest(this) .formatHash() + private fun ByteArray.formatHash(): String = this + .joinToString(separator = "") { String.format("%02X", it) } + .toLowerCase(Locale.ROOT) + fun File.hashToMD5(): String = this.hashTo("MD5") private fun File.hashTo(type: String): String = MessageDigest diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt index b296cced9c324d4a7499b2512b716e069a38abc0..14a58b2a1ec43f8a4f14d49c6570904e2f7465ad 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/ZipHelper.kt @@ -73,15 +73,21 @@ object ZipHelper { zipOutputStream.closeEntry() } - fun InputStream.unzip(callback: (entry: ZipEntry, entryContent: ByteArray) -> Any) = - ZipInputStream(this).use { + fun InputStream.unzip(): Sequence<Pair<ZipEntry, InputStream>> = sequence { + ZipInputStream(this@unzip).use { do { val entry = it.nextEntry if (entry != null) { - Timber.v("read zip entry ${entry.name}") - callback(entry, it.readBytes()) + Timber.v("Reading zip entry ${entry.name}") + yield(entry to it) it.closeEntry() } } while (entry != null) } + } + + fun Sequence<Pair<ZipEntry, InputStream>>.readIntoMap() = + fold(emptyMap()) { last: Map<String, ByteArray>, (entry, stream) -> + last.plus(entry.name to stream.readBytes()) + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt index 9e5ce2d20f6407093dc93fcd451926eeed9a6813..e6a64015278bb9ab2a0067a626f4b433860ca64e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/database/CommonConverters.kt @@ -23,7 +23,7 @@ import androidx.room.TypeConverter import com.google.gson.Gson import com.google.gson.reflect.TypeToken import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode -import de.rki.coronawarnapp.util.gson.fromJson +import de.rki.coronawarnapp.util.serialization.fromJson import org.joda.time.Instant import org.joda.time.LocalDate import org.joda.time.LocalTime 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 359c2e948ed6215083176ba02a1635aa7bbdf50c..c3d6d5787f6c991b9a8498e6cc3b3ede70afa522 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 @@ -1,7 +1,6 @@ package de.rki.coronawarnapp.util.flow import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -10,7 +9,8 @@ import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.shareIn @@ -21,8 +21,9 @@ import kotlin.coroutines.CoroutineContext class HotDataFlow<T : Any>( loggingTag: String, scope: CoroutineScope, - coroutineContext: CoroutineContext = Dispatchers.Default, + coroutineContext: CoroutineContext = scope.coroutineContext, sharingBehavior: SharingStarted = SharingStarted.WhileSubscribed(), + forwardException: Boolean = true, private val startValueProvider: suspend CoroutineScope.() -> T ) { private val tag = "$loggingTag:HD" @@ -37,34 +38,71 @@ class HotDataFlow<T : Any>( onBufferOverflow = BufferOverflow.SUSPEND ) - private val internalFlow = channelFlow { + private val internalProducer: Flow<Holder<T>> = channelFlow { var currentValue = startValueProvider().also { Timber.tag(tag).v("startValue=%s", it) - send(it) + val updatedBy: suspend T.() -> T = { it } + send(Holder.Data(value = it, updatedBy = updatedBy)) } updateActions.collect { updateAction -> currentValue = updateAction(currentValue).also { currentValue = it - send(it) + send(Holder.Data(value = it, updatedBy = updateAction)) } } } - val data: Flow<T> = internalFlow - .distinctUntilChanged() - .onStart { Timber.tag(tag).v("internal onStart") } + private val internalFlow = internalProducer + .onStart { Timber.tag(tag).v("Internal onStart") } .catch { - Timber.tag(tag).e(it, "internal Error") - throw it + if (forwardException) { + Timber.tag(tag).w(it, "Forwarding internal Error") + // Wrap the error to get it past `sharedIn` + emit(Holder.Error(error = it)) + } else { + Timber.tag(tag).e(it, "Throwing internal Error") + throw it + } } - .onCompletion { Timber.tag(tag).v("internal onCompletion") } + .onCompletion { Timber.tag(tag).v("Internal onCompletion") } .shareIn( scope = scope + coroutineContext, replay = 1, started = sharingBehavior ) - .mapNotNull { it } + .map { + when (it) { + is Holder.Data<T> -> it + is Holder.Error<T> -> throw it.error + } + } + + val data: Flow<T> = internalFlow.map { it.value }.distinctUntilChanged() fun updateSafely(update: suspend T.() -> T) = updateActions.tryEmit(update) + + 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) } + } + + internal sealed class Holder<T> { + data class Data<T>( + val value: T, + val updatedBy: suspend T.() -> T + ) : Holder<T>() + + data class Error<T>( + val error: Throwable + ) : Holder<T>() + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt index e6df66e5b7996381dda133b616e34eb87f5b13c5..1ba8319fe9aae755a9d33947fc8744baca812e7a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/security/VerificationKeys.kt @@ -25,8 +25,8 @@ class VerificationKeys @Inject constructor( Signature.getInstance(SecurityConstants.EXPORT_FILE_SIGNATURE_VERIFICATION_ALGORITHM) fun hasInvalidSignature( - export: ByteArray?, - signatureListBinary: ByteArray? + export: ByteArray, + signatureListBinary: ByteArray ): Boolean = SecurityHelper.withSecurityCatch { signature.getValidSignaturesForExport(export, signatureListBinary) .isEmpty() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt similarity index 67% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt index b79e725c5d935b80971ac86bf3ff39505eb5267a..601f833c3ded2777122e62d6814e07edff3380a3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/gson/GsonExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/GsonExtensions.kt @@ -1,8 +1,10 @@ -package de.rki.coronawarnapp.util.gson +package de.rki.coronawarnapp.util.serialization import com.google.gson.Gson +import com.google.gson.TypeAdapter import com.google.gson.reflect.TypeToken import java.io.File +import kotlin.reflect.KClass inline fun <reified T> Gson.fromJson(json: String): T = fromJson( json, @@ -16,3 +18,5 @@ inline fun <reified T> Gson.fromJson(file: File): T = file.reader().use { inline fun <reified T> Gson.toJson(data: T, file: File) = file.writer().use { writer -> toJson(data, writer) } + +fun <T : Any> KClass<T>.getDefaultGsonTypeAdapter(): TypeAdapter<T> = Gson().getAdapter(this.java) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt index e638c3811aad327869e5c7b6ba50deb0fefe409d..c2c33f43b43745b32465362577c0a0bffae874b9 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/SerializationModule.kt @@ -5,6 +5,11 @@ import com.google.gson.GsonBuilder import dagger.Module import dagger.Provides import dagger.Reusable +import de.rki.coronawarnapp.util.serialization.adapter.ByteArrayAdapter +import de.rki.coronawarnapp.util.serialization.adapter.DurationAdapter +import de.rki.coronawarnapp.util.serialization.adapter.InstantAdapter +import org.joda.time.Duration +import org.joda.time.Instant @Module class SerializationModule { @@ -12,5 +17,9 @@ class SerializationModule { @BaseGson @Reusable @Provides - fun baseGson(): Gson = GsonBuilder().create() + fun baseGson(): Gson = GsonBuilder() + .registerTypeAdapter(Instant::class.java, InstantAdapter()) + .registerTypeAdapter(Duration::class.java, DurationAdapter()) + .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter()) + .create() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..10388e0c471d1324b63d868325e2e4c8a3d3f0c9 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapter.kt @@ -0,0 +1,24 @@ +package de.rki.coronawarnapp.util.serialization.adapter + +import com.google.gson.JsonParseException +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString +import org.json.JSONObject.NULL + +class ByteArrayAdapter : TypeAdapter<ByteArray>() { + override fun write(out: JsonWriter, value: ByteArray?) { + if (value == null) out.nullValue() + else value.toByteString().base64().let { out.value(it) } + } + + override fun read(reader: JsonReader): ByteArray? = when (reader.peek()) { + NULL -> reader.nextNull().let { null } + else -> { + val raw = reader.nextString() + raw.decodeBase64()?.toByteArray() ?: throw JsonParseException("Can't decode base64 ByteArray: $raw") + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..0d194661ea3c1cbc85717aca1fc25ddb7d003a96 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/DurationAdapter.kt @@ -0,0 +1,27 @@ +package de.rki.coronawarnapp.util.serialization.adapter + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import org.joda.time.Duration +import org.json.JSONObject + +class DurationAdapter : TypeAdapter<Duration>() { + override fun write(out: JsonWriter, value: Duration?) { + if (value == null) { + out.nullValue() + } else { + out.value(value.millis) + } + } + + override fun read(reader: JsonReader): Duration? = when (reader.peek()) { + JSONObject.NULL -> { + reader.nextNull() + null + } + else -> { + Duration.millis(reader.nextLong()) + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..99ba6e6e18285126ddf9bf69c43ab57a8ae641c7 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/InstantAdapter.kt @@ -0,0 +1,27 @@ +package de.rki.coronawarnapp.util.serialization.adapter + +import com.google.gson.TypeAdapter +import com.google.gson.stream.JsonReader +import com.google.gson.stream.JsonWriter +import org.joda.time.Instant +import org.json.JSONObject + +class InstantAdapter : TypeAdapter<Instant>() { + override fun write(out: JsonWriter, value: Instant?) { + if (value == null) { + out.nullValue() + } else { + out.value(value.millis) + } + } + + override fun read(reader: JsonReader): Instant? = when (reader.peek()) { + JSONObject.NULL -> { + reader.nextNull() + null + } + else -> { + Instant.ofEpochMilli(reader.nextLong()) + } + } +} 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 972136da0c94f4ce8b65306cbfec4c9102c445d9..00fcd329fc1e5456231e032cdfe1788531ba5f6f 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 @@ -1,37 +1,37 @@ package de.rki.coronawarnapp.appconfig -import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode -import de.rki.coronawarnapp.util.security.VerificationKeys -import io.kotest.assertions.throwables.shouldThrow +import de.rki.coronawarnapp.util.TimeStamper 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.coVerifySequence import io.mockk.every import io.mockk.impl.annotations.MockK -import io.mockk.mockk -import io.mockk.verify -import okhttp3.ResponseBody.Companion.toResponseBody -import okio.ByteString.Companion.decodeHex +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 import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import testhelpers.BaseIOTest +import testhelpers.TestDispatcherProvider +import testhelpers.coroutines.runBlockingTest2 +import testhelpers.coroutines.test import java.io.File -import java.io.IOException class AppConfigProviderTest : BaseIOTest() { - @MockK lateinit var api: AppConfigApiV1 - @MockK lateinit var verificationKeys: VerificationKeys - @MockK lateinit var appConfigStorage: AppConfigStorage + @MockK lateinit var source: AppConfigSource + @MockK lateinit var configData: ConfigData + @MockK lateinit var timeStamper: TimeStamper private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) - private val defaultHomeCountry = LocationCode("DE") - - private var mockConfigStorage: ByteArray? = null + private lateinit var testConfigDownload: ConfigData @BeforeEach fun setup() { @@ -39,9 +39,16 @@ class AppConfigProviderTest : BaseIOTest() { testDir.mkdirs() testDir.exists() shouldBe true - coEvery { appConfigStorage.isAppConfigAvailable() } answers { mockConfigStorage != null } - coEvery { appConfigStorage.getAppConfigRaw() } answers { mockConfigStorage } - coEvery { appConfigStorage.setAppConfigRaw(any()) } answers { mockConfigStorage = arg(0) } + testConfigDownload = DefaultConfigData( + serverTime = Instant.parse("2020-11-03T05:35:16.000Z"), + localOffset = Duration.ZERO, + mappedConfig = configData, + isFallback = false + ) + coEvery { source.clear() } just Runs + coEvery { source.retrieveConfig() } returns testConfigDownload + + every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z") } @AfterEach @@ -50,160 +57,53 @@ class AppConfigProviderTest : BaseIOTest() { testDir.deleteRecursively() } - private fun createDownloadServer( - homeCountry: LocationCode = defaultHomeCountry - ) = AppConfigProvider( - appConfigAPI = { api }, - verificationKeys = verificationKeys, - homeCountry = homeCountry, - configStorage = appConfigStorage, - cache = mockk() + private fun createInstance(scope: CoroutineScope) = AppConfigProvider( + source = source, + dispatcherProvider = TestDispatcherProvider, + scope = scope ) @Test - suspend fun `application config download`() { - coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody() - - every { verificationKeys.hasInvalidSignature(any(), any()) } returns false - - val downloadServer = createDownloadServer() - - val rawConfig = downloadServer.downloadAppConfig() - rawConfig shouldBe APPCONFIG_RAW.toByteArray() - - verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) } - } - - @Test - suspend fun `application config data is faulty`() { - coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex() - .toResponseBody() - - every { verificationKeys.hasInvalidSignature(any(), any()) } returns false - - val downloadServer = createDownloadServer() + fun `appConfig is observable`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) - shouldThrow<ApplicationConfigurationInvalidException> { - downloadServer.downloadAppConfig() - } - } + val testCollector = instance.currentConfig.test(startOnScope = this) - @Test - suspend fun `application config verification fails`() { - coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE - .toResponseBody() + instance.getAppConfig() + instance.clear() + instance.getAppConfig() - every { verificationKeys.hasInvalidSignature(any(), any()) } returns true + advanceUntilIdle() - val downloadServer = createDownloadServer() + testCollector.latestValues shouldBe listOf( + null, + testConfigDownload, + null, + testConfigDownload + ) - shouldThrow<ApplicationConfigurationCorruptException> { - downloadServer.downloadAppConfig() + coVerifySequence { + source.retrieveConfig() + source.clear() + source.retrieveConfig() } } @Test - suspend fun `successful download stores new config`() { - coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE - .toResponseBody() + fun `clear clears storage and current config`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) - every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + instance.getAppConfig() shouldBe testConfigDownload + instance.currentConfig.first() shouldBe testConfigDownload - val downloadServer = createDownloadServer() - downloadServer.getAppConfig() + instance.clear() - mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray() - coVerify { appConfigStorage.setAppConfigRaw(APPCONFIG_RAW.toByteArray()) } - } - - @Test - suspend fun `failed download doesn't overwrite valid config`() { - mockConfigStorage = APPCONFIG_RAW.toByteArray() - coEvery { api.getApplicationConfiguration("DE") } throws IOException() + instance.currentConfig.first() shouldBe null - every { verificationKeys.hasInvalidSignature(any(), any()) } returns false - - createDownloadServer().getAppConfig() - - coVerify(exactly = 0) { appConfigStorage.setAppConfigRaw(any()) } - mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray() - } - - @Test - suspend fun `failed verification doesn't overwrite valid config`() { - mockConfigStorage = APPCONFIG_RAW.toByteArray() - coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE - .toResponseBody() + coVerifySequence { + source.retrieveConfig() - every { verificationKeys.hasInvalidSignature(any(), any()) } returns true - - createDownloadServer().getAppConfig() - - coVerify(exactly = 0) { appConfigStorage.setAppConfigRaw(any()) } - mockConfigStorage shouldBe APPCONFIG_RAW.toByteArray() - } - - @Test - suspend fun `fallback to last config if verification fails`() { - mockConfigStorage = APPCONFIG_RAW.toByteArray() - - coEvery { api.getApplicationConfiguration("DE") } returns "123ABC".decodeHex() - .toResponseBody() - - every { verificationKeys.hasInvalidSignature(any(), any()) } throws Exception() - createDownloadServer().getAppConfig().minRiskScore shouldBe 11 - - every { verificationKeys.hasInvalidSignature(any(), any()) } returns true - createDownloadServer().getAppConfig().minRiskScore shouldBe 11 - } - - @Test - suspend fun `fallback to last config if download fails`() { - mockConfigStorage = APPCONFIG_RAW.toByteArray() - - coEvery { api.getApplicationConfiguration("DE") } throws Exception() - - every { verificationKeys.hasInvalidSignature(any(), any()) } returns false - - createDownloadServer().getAppConfig().minRiskScore shouldBe 11 - } - - // Because the UI requires this to detect when to show alternative UI elements - @Test - suspend fun `if supportedCountryList is empty, we do not insert DE as fallback`() { - coEvery { api.getApplicationConfiguration("DE") } returns APPCONFIG_BUNDLE.toResponseBody() - every { verificationKeys.hasInvalidSignature(any(), any()) } returns false - - createDownloadServer().getAppConfig().supportedCountriesList shouldBe emptyList() - } - - companion object { - private val APPCONFIG_BUNDLE = - ( - "504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" + - "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" + - "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" + - "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" + - "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" + - "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" + - "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" + - "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" + - "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" + - "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" + - "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" + - "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" + - "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" + - "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" + - "0200020070000000ae0100000000" - ).decodeHex() - private val APPCONFIG_RAW = - ( - "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" + - "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" + - "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" + - "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" + - "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" + - "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804" - ).decodeHex() + 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 new file mode 100644 index 0000000000000000000000000000000000000000..a4ba54e99f2a9ad6630597cbfcec4f3601a91021 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigSourceTest.kt @@ -0,0 +1,144 @@ +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.mapping.ConfigParser +import de.rki.coronawarnapp.util.TimeStamper +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.coVerifyOrder +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import okio.ByteString.Companion.decodeHex +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.TestDispatcherProvider +import testhelpers.coroutines.runBlockingTest2 +import java.io.File +import java.io.IOException + +class AppConfigSourceTest : BaseIOTest() { + + @MockK lateinit var configServer: AppConfigServer + @MockK lateinit var configStorage: AppConfigStorage + @MockK lateinit var configParser: ConfigParser + @MockK lateinit var configData: ConfigData + @MockK lateinit var timeStamper: TimeStamper + + 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) + ) + + private var mockConfigStorage: ConfigDownload? = null + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + + coEvery { configStorage.getStoredConfig() } answers { mockConfigStorage } + coEvery { configStorage.setStoredConfig(any()) } answers { + mockConfigStorage = arg(0) + } + + coEvery { configServer.downloadAppConfig() } returns testConfigDownload + every { configServer.clearCache() } just Runs + + every { configParser.parse(APPCONFIG_RAW) } returns configData + + every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z") + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createInstance() = AppConfigSource( + server = configServer, + storage = configStorage, + parser = configParser, + dispatcherProvider = TestDispatcherProvider + ) + + @Test + fun `successful download stores new config`() = runBlockingTest2(ignoreActive = true) { + val source = createInstance() + source.retrieveConfig() shouldBe DefaultConfigData( + serverTime = mockConfigStorage!!.serverTime, + localOffset = mockConfigStorage!!.localOffset, + mappedConfig = configData, + isFallback = false + ) + + mockConfigStorage shouldBe testConfigDownload + + coVerify { configStorage.setStoredConfig(testConfigDownload) } + } + + @Test + fun `fallback to last config if download fails`() = runBlockingTest2(ignoreActive = true) { + mockConfigStorage = testConfigDownload + coEvery { configServer.downloadAppConfig() } throws Exception() + + createInstance().retrieveConfig() shouldBe DefaultConfigData( + serverTime = mockConfigStorage!!.serverTime, + localOffset = mockConfigStorage!!.localOffset, + mappedConfig = configData, + isFallback = true + ) + } + + @Test + fun `failed download doesn't overwrite valid config`() = runBlockingTest2(ignoreActive = true) { + mockConfigStorage = testConfigDownload + coEvery { configServer.downloadAppConfig() } throws IOException() + + createInstance().retrieveConfig() + + mockConfigStorage shouldBe testConfigDownload + + coVerify(exactly = 0) { configStorage.setStoredConfig(any()) } + } + + @Test + fun `clear clears caches`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance() + + instance.clear() + + advanceUntilIdle() + + coVerifyOrder { + configStorage.setStoredConfig(null) + configServer.clearCache() + } + } + + companion object { + private val APPCONFIG_RAW = ( + "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" + + "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" + + "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" + + "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" + + "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" + + "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804" + ).decodeHex().toByteArray() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt deleted file mode 100644 index aea45f221b950b4b2ac503079adb1c0691004e64..0000000000000000000000000000000000000000 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigStorageTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package de.rki.coronawarnapp.appconfig - -import android.content.Context -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 AppConfigStorageTest : BaseIOTest() { - - @MockK private lateinit var context: Context - - private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) - private val privateFiles = File(testDir, "files") - private val storageDir = File(privateFiles, "appconfig_storage") - private val configPath = File(storageDir, "appconfig") - private val testByteArray = "The Cake Is A Lie".toByteArray() - - @BeforeEach - fun setup() { - MockKAnnotations.init(this) - every { context.filesDir } returns privateFiles - } - - @AfterEach - fun teardown() { - clearAllMocks() - testDir.deleteRecursively() - } - - private fun createStorage() = AppConfigStorage(context) - - @Test - suspend fun `config availability is determined by file existence and min size`() { - storageDir.mkdirs() - val storage = createStorage() - storage.isAppConfigAvailable() shouldBe false - configPath.createNewFile() - storage.isAppConfigAvailable() shouldBe false - - configPath.writeBytes(ByteArray(128) { 1 }) - storage.isAppConfigAvailable() shouldBe false - - configPath.writeBytes(ByteArray(129) { 1 }) - storage.isAppConfigAvailable() shouldBe true - } - - @Test - suspend fun `simple read and write config`() { - configPath.exists() shouldBe false - val storage = createStorage() - configPath.exists() shouldBe false - - storage.setAppConfigRaw(testByteArray) - - configPath.exists() shouldBe true - configPath.readBytes() shouldBe testByteArray - - storage.getAppConfigRaw() shouldBe testByteArray - } - - @Test - suspend fun `nulling and overwriting`() { - val storage = createStorage() - configPath.exists() shouldBe false - - storage.getAppConfigRaw() shouldBe null - storage.setAppConfigRaw(null) - configPath.exists() shouldBe false - - storage.getAppConfigRaw() shouldBe null - storage.setAppConfigRaw(testByteArray) - storage.getAppConfigRaw() shouldBe testByteArray - configPath.exists() shouldBe true - configPath.readBytes() shouldBe testByteArray - - storage.setAppConfigRaw(null) - storage.getAppConfigRaw() shouldBe null - configPath.exists() shouldBe false - } -} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt similarity index 83% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt index 671fd3ab13b4be98186c78f0e730204372e60efa..da1ab2f7d7e9dd50d4286a5f55827b76f55e2ad9 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigApiTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigApiTest.kt @@ -1,6 +1,7 @@ -package de.rki.coronawarnapp.appconfig +package de.rki.coronawarnapp.appconfig.download import android.content.Context +import de.rki.coronawarnapp.appconfig.AppConfigModule import de.rki.coronawarnapp.environment.download.DownloadCDNModule import de.rki.coronawarnapp.http.HttpModule import io.kotest.matchers.shouldBe @@ -75,7 +76,9 @@ class AppConfigApiTest : BaseIOTest() { webServer.enqueue(MockResponse().setBody("~appconfig")) runBlocking { - api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" + api.getApplicationConfiguration("DE").apply { + body()!!.string() shouldBe "~appconfig" + } } val request = webServer.takeRequest(5, TimeUnit.SECONDS)!! @@ -94,7 +97,9 @@ class AppConfigApiTest : BaseIOTest() { webServer.enqueue(configResponse) runBlocking { - api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" + api.getApplicationConfiguration("DE").apply { + body()!!.string() shouldBe "~appconfig" + } } cacheDir.exists() shouldBe true cacheDir.listFiles()!!.size shouldBe 3 @@ -106,7 +111,9 @@ class AppConfigApiTest : BaseIOTest() { webServer.enqueue(configResponse) runBlocking { - api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" + api.getApplicationConfiguration("DE").apply { + body()!!.string() shouldBe "~appconfig" + } } cacheDir.exists() shouldBe true cacheDir.listFiles()!!.size shouldBe 3 @@ -117,7 +124,9 @@ class AppConfigApiTest : BaseIOTest() { webServer.enqueue(configResponse) runBlocking { - api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" + api.getApplicationConfiguration("DE").apply { + body()!!.string() shouldBe "~appconfig" + } } cacheDir.exists() shouldBe true cacheDir.listFiles()!!.size shouldBe 3 @@ -139,7 +148,9 @@ class AppConfigApiTest : BaseIOTest() { webServer.enqueue(configResponse) runBlocking { - api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" + api.getApplicationConfiguration("DE").apply { + body()!!.string() shouldBe "~appconfig" + } } cacheDir.exists() shouldBe true cacheDir.listFiles()!!.size shouldBe 3 @@ -152,7 +163,9 @@ class AppConfigApiTest : BaseIOTest() { webServer.enqueue(MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_DURING_REQUEST_BODY)) runBlocking { - api.getApplicationConfiguration("DE").string() shouldBe "~appconfig" + api.getApplicationConfiguration("DE").apply { + body()!!.string() shouldBe "~appconfig" + } } } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt similarity index 92% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt index fdae6dd05d6b673b95c5b49a3a5edf39d76c9124..eae101cd8e6281f82ba2658056819e564473fbe0 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/AppConfigModuleTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigModuleTest.kt @@ -1,6 +1,7 @@ -package de.rki.coronawarnapp.appconfig +package de.rki.coronawarnapp.appconfig.download import android.content.Context +import de.rki.coronawarnapp.appconfig.AppConfigModule import io.kotest.assertions.throwables.shouldNotThrowAny import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations 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 new file mode 100644 index 0000000000000000000000000000000000000000..eda32b98ef41d9a00ff05020c9e24f3551e5cbd8 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigServerTest.kt @@ -0,0 +1,199 @@ +package de.rki.coronawarnapp.appconfig.download + +import de.rki.coronawarnapp.diagnosiskeys.server.LocationCode +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.security.VerificationKeys +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.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.test.runBlockingTest +import okhttp3.Headers +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.ByteString.Companion.decodeHex +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 retrofit2.Response +import testhelpers.BaseIOTest +import java.io.File + +class AppConfigServerTest : BaseIOTest() { + + @MockK lateinit var api: AppConfigApiV1 + @MockK lateinit var verificationKeys: VerificationKeys + @MockK lateinit var timeStamper: TimeStamper + private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) + + private val defaultHomeCountry = LocationCode("DE") + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + testDir.mkdirs() + testDir.exists() shouldBe true + + every { timeStamper.nowUTC } returns Instant.ofEpochMilli(123456789) + every { verificationKeys.hasInvalidSignature(any(), any()) } returns false + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createInstance(homeCountry: LocationCode = defaultHomeCountry) = AppConfigServer( + api = { api }, + verificationKeys = verificationKeys, + homeCountry = homeCountry, + cache = mockk(), + timeStamper = timeStamper + ) + + @Test + 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") + ) + + val downloadServer = createInstance() + + val configDownload = downloadServer.downloadAppConfig() + configDownload shouldBe ConfigDownload( + rawData = APPCONFIG_RAW, + serverTime = Instant.parse("2020-11-03T08:46:03.000Z"), + localOffset = Duration( + Instant.parse("2020-11-03T08:46:03.000Z"), + Instant.ofEpochMilli(123456789) + ) + ) + + verify(exactly = 1) { verificationKeys.hasInvalidSignature(any(), any()) } + } + + @Test + fun `application config data is faulty`() = runBlockingTest { + coEvery { api.getApplicationConfiguration("DE") } returns Response.success( + "123ABC".decodeHex().toResponseBody() + ) + + val downloadServer = createInstance() + + shouldThrow<ApplicationConfigurationInvalidException> { + downloadServer.downloadAppConfig() + } + } + + @Test + fun `application config verification fails`() = runBlockingTest { + coEvery { api.getApplicationConfiguration("DE") } returns Response.success( + APPCONFIG_BUNDLE.toResponseBody() + ) + every { verificationKeys.hasInvalidSignature(any(), any()) } returns true + + val downloadServer = createInstance() + + shouldThrow<ApplicationConfigurationCorruptException> { + downloadServer.downloadAppConfig() + } + } + + @Test + fun `missing server date leads to local time fallback`() = runBlockingTest { + coEvery { api.getApplicationConfiguration("DE") } returns Response.success( + APPCONFIG_BUNDLE.toResponseBody() + ) + + val downloadServer = createInstance() + + val configDownload = downloadServer.downloadAppConfig() + configDownload shouldBe ConfigDownload( + rawData = APPCONFIG_RAW, + serverTime = Instant.ofEpochMilli(123456789), + localOffset = Duration.ZERO + ) + } + + @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") + ) + every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z") + + val downloadServer = createInstance() + + downloadServer.downloadAppConfig() shouldBe ConfigDownload( + rawData = APPCONFIG_RAW, + serverTime = Instant.parse("2020-11-03T06:35:16.000Z"), + localOffset = Duration.standardHours(-1) + ) + } + + @Test + fun `local offset uses cached timestamps for cached responses`() = runBlockingTest { + val response = spyk( + Response.success( + APPCONFIG_BUNDLE.toResponseBody(), + Headers.headersOf("Date", "Tue, 03 Nov 2020 06:35:16 GMT") + ) + ) + + val mockCacheResponse = mockk<okhttp3.Response>() + // The cached one is 2 hours before our local time, so the offset will be -2 hours + every { mockCacheResponse.sentRequestAtMillis } returns Instant.parse("2020-11-03T04:35:16.000Z").millis + every { response.raw().cacheResponse } returns mockCacheResponse + + coEvery { api.getApplicationConfiguration("DE") } returns response + every { timeStamper.nowUTC } returns Instant.parse("2020-11-03T05:35:16.000Z") + + val downloadServer = createInstance() + + downloadServer.downloadAppConfig() shouldBe ConfigDownload( + rawData = APPCONFIG_RAW, + serverTime = Instant.parse("2020-11-03T06:35:16.000Z"), + localOffset = Duration.standardHours(-2) + ) + } + + companion object { + private val APPCONFIG_BUNDLE = + ( + "504b0304140008080800856b22510000000000000000000000000a0000006578706f72742e62696ee3e016" + + "f2e552e662f6f10f97e05792ca28292928b6d2d72f2f2fd74bce2fcacf4b2c4f2ccad34b2c28e0" + + "52e362f1f074f710e097f0c0a74e2a854b80835180498259814583d580cd82dd814390010c3c1d" + + "a4b8141835180d182d181d181561825a021cac02ac12ac0aac40f5ac16ac0eac86102913072b3e" + + "01460946841e47981e25192e160e73017b21214e88d0077ba8250fec1524b5a4b8b8b858043824" + + "98849804588578806a19255884c02400504b0708df2c788daf000000f1000000504b0304140008" + + "080800856b22510000000000000000000000000a0000006578706f72742e736967018a0075ff0a" + + "87010a380a1864652e726b692e636f726f6e617761726e6170702d6465761a0276312203323632" + + "2a13312e322e3834302e31303034352e342e332e321001180122473045022100cf32ff24ea18a1" + + "ffcc7ff4c9fe8d1808cecbc5a37e3e1d4c9ce682120450958c022064bf124b6973a9b510a43d47" + + "9ff93e0ef97a5b893c7af4abc4a8d399969cd8a0504b070813c517c68f0000008a000000504b01" + + "021400140008080800856b2251df2c788daf000000f10000000a00000000000000000000000000" + + "000000006578706f72742e62696e504b01021400140008080800856b225113c517c68f0000008a" + + "0000000a00000000000000000000000000e70000006578706f72742e736967504b050600000000" + + "0200020070000000ae0100000000" + ).decodeHex() + private val APPCONFIG_RAW = + ( + "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" + + "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" + + "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" + + "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" + + "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" + + "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804" + ).decodeHex().toByteArray() + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..fa15cab904896ee99211904a97fbf2411fbe4621 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/download/AppConfigStorageTest.kt @@ -0,0 +1,149 @@ +package de.rki.coronawarnapp.appconfig.download + +import android.content.Context +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.serialization.SerializationModule +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runBlockingTest +import okio.ByteString.Companion.decodeHex +import okio.ByteString.Companion.toByteString +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.extensions.toComparableJson +import java.io.File + +class AppConfigStorageTest : BaseIOTest() { + + @MockK private lateinit var context: Context + @MockK private lateinit var timeStamper: TimeStamper + + private val testDir = File(IO_TEST_BASEDIR, this::class.java.simpleName) + private val privateFiles = File(testDir, "files") + private val storageDir = File(privateFiles, "appconfig_storage") + private val legacyConfigPath = File(storageDir, "appconfig") + private val configPath = File(storageDir, "appconfig.json") + + private val testConfigDownload = ConfigDownload( + rawData = APPCONFIG_RAW, + serverTime = Instant.parse("2020-11-03T05:35:16.000Z"), + localOffset = Duration.standardHours(1) + ) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + every { context.filesDir } returns privateFiles + + every { timeStamper.nowUTC } returns Instant.ofEpochMilli(1234) + } + + @AfterEach + fun teardown() { + clearAllMocks() + testDir.deleteRecursively() + } + + private fun createStorage() = AppConfigStorage( + context = context, + timeStamper = timeStamper, + baseGson = SerializationModule().baseGson() + ) + + @Test + fun `simple read and write config`() = runBlockingTest { + configPath.exists() shouldBe false + val storage = createStorage() + configPath.exists() shouldBe false + + storage.setStoredConfig(testConfigDownload) + + configPath.exists() shouldBe true + configPath.readText().toComparableJson() shouldBe """ + { + "rawData": "$APPCONFIG_BASE64", + "serverTime": 1604381716000, + "localOffset": 3600000 + } + """.toComparableJson() + + storage.getStoredConfig() shouldBe testConfigDownload + } + + @Test + fun `nulling and overwriting`() = runBlockingTest { + val storage = createStorage() + configPath.exists() shouldBe false + + storage.getStoredConfig() shouldBe null + storage.setStoredConfig(null) + configPath.exists() shouldBe false + + storage.getStoredConfig() shouldBe null + storage.setStoredConfig(testConfigDownload) + storage.getStoredConfig() shouldBe testConfigDownload + + configPath.exists() shouldBe true + configPath.readText().toComparableJson() shouldBe """ + { + "rawData": "$APPCONFIG_BASE64", + "serverTime": 1604381716000, + "localOffset": 3600000 + } + """.toComparableJson() + + storage.setStoredConfig(null) + storage.getStoredConfig() shouldBe null + configPath.exists() shouldBe false + } + + @Test + fun `if no fallback exists, but we have a legacy config, use that`() = runBlockingTest { + configPath.exists() shouldBe false + legacyConfigPath.exists() shouldBe false + + legacyConfigPath.parentFile!!.mkdirs() + legacyConfigPath.writeBytes(APPCONFIG_RAW) + + val storage = createStorage() + + storage.getStoredConfig() shouldBe ConfigDownload( + rawData = APPCONFIG_RAW, + serverTime = Instant.ofEpochMilli(1234), + localOffset = Duration.ZERO + ) + } + + @Test + fun `writing a new config deletes any legacy configsconfig`() = runBlockingTest { + legacyConfigPath.parentFile!!.mkdirs() + legacyConfigPath.writeBytes(APPCONFIG_RAW) + configPath.exists() shouldBe false + + val storage = createStorage() + storage.setStoredConfig(testConfigDownload) + + legacyConfigPath.exists() shouldBe false + configPath.exists() shouldBe true + } + + companion object { + private val APPCONFIG_RAW = ( + "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" + + "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" + + "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" + + "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" + + "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" + + "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804" + ).decodeHex().toByteArray() + + private val APPCONFIG_BASE64 = APPCONFIG_RAW.toByteString().base64() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt similarity index 93% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt index d817a316743f01bdfd03b5966a627f93af82ce5a..ad61b6ac73aa40167bddf07bb1f9b2578d879247 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/ApplicationConfigurationExtensionsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ApplicationConfigurationExtensionsTest.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.appconfig +package de.rki.coronawarnapp.appconfig.mapping import de.rki.coronawarnapp.server.protocols.internal.AppConfig.ApplicationConfiguration import io.kotest.matchers.shouldBe diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2ef853f468f8e5b58bf7db38802a617b5c0d76b5 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/CWAConfigMapperTest.kt @@ -0,0 +1,45 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class CWAConfigMapperTest : BaseTest() { + + private fun createInstance() = CWAConfigMapper() + + @Test + fun `simple creation`() { + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .addAllSupportedCountries(listOf("DE", "NL")) + .build() + createInstance().map(rawConfig).apply { + this.appVersion shouldBe rawConfig.appVersion + this.supportedCountries shouldBe listOf("DE", "NL") + } + } + + @Test + fun `invalid supported countries are filtered out`() { + // Could happen due to protobuf scheme missmatch + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .addAllSupportedCountries(listOf("plausible deniability")) + .build() + createInstance().map(rawConfig).apply { + this.appVersion shouldBe rawConfig.appVersion + this.supportedCountries shouldBe emptyList() + } + } + + @Test + fun `if supportedCountryList is empty, we do not insert DE as fallback`() { + // Because the UI requires this to detect when to show alternative UI elements + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .build() + createInstance().map(rawConfig).apply { + this.appVersion shouldBe rawConfig.appVersion + this.supportedCountries shouldBe emptyList() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..18ee07f72f83cfdbfccae776f6df5edc21a23083 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ConfigParserTest.kt @@ -0,0 +1,70 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import de.rki.coronawarnapp.appconfig.CWAConfig +import de.rki.coronawarnapp.appconfig.ExposureDetectionConfig +import de.rki.coronawarnapp.appconfig.KeyDownloadConfig +import de.rki.coronawarnapp.appconfig.RiskCalculationConfig +import io.mockk.MockKAnnotations +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.verifySequence +import okio.ByteString.Companion.decodeHex +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class ConfigParserTest : BaseTest() { + @MockK lateinit var cwaConfigMapper: CWAConfig.Mapper + @MockK lateinit var keyDownloadConfigMapper: KeyDownloadConfig.Mapper + @MockK lateinit var exposureDetectionConfigMapper: ExposureDetectionConfig.Mapper + @MockK lateinit var riskCalculationConfigMapper: RiskCalculationConfig.Mapper + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + every { cwaConfigMapper.map(any()) } returns mockk() + every { keyDownloadConfigMapper.map(any()) } returns mockk() + every { exposureDetectionConfigMapper.map(any()) } returns mockk() + every { riskCalculationConfigMapper.map(any()) } returns mockk() + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createInstance(): ConfigParser = ConfigParser( + cwaConfigMapper = cwaConfigMapper, + keyDownloadConfigMapper = keyDownloadConfigMapper, + exposureDetectionConfigMapper = exposureDetectionConfigMapper, + riskCalculationConfigMapper = riskCalculationConfigMapper + ) + + @Test + fun `simple init`() { + createInstance().parse(APPCONFIG_RAW.toByteArray()).apply { + + verifySequence { + cwaConfigMapper.map(any()) + keyDownloadConfigMapper.map(any()) + exposureDetectionConfigMapper.map(any()) + riskCalculationConfigMapper.map(any()) + } + } + } + + companion object { + private val APPCONFIG_RAW = ( + "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" + + "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" + + "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" + + "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" + + "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" + + "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804" + ).decodeHex() + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..d8ce3fd278925df0151f609f257866f3db3371b9 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/DownloadConfigMapperTest.kt @@ -0,0 +1,19 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class DownloadConfigMapperTest : BaseTest() { + private fun createInstance() = DownloadConfigMapper() + + @Test + fun `simple creation`() { + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .build() + createInstance().map(rawConfig).apply { + keyDownloadParameters shouldBe rawConfig.androidKeyDownloadParameters + } + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..2552a72dc3554e0879ad28a1bfaf2da77d2dca15 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/ExposureDetectionConfigMapperTest.kt @@ -0,0 +1,22 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class ExposureDetectionConfigMapperTest : BaseTest() { + + private fun createInstance() = ExposureDetectionConfigMapper() + + @Test + fun `simple creation`() { + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .setMinRiskScore(1) + .build() + createInstance().map(rawConfig).apply { + exposureDetectionConfiguration shouldBe rawConfig.mapRiskScoreToExposureConfiguration() + exposureDetectionParameters shouldBe rawConfig.androidExposureDetectionParameters + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..e0cf0e3c09a213d3f214cb292efca309a4824e67 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/appconfig/mapping/RiskCalculationConfigMapperTest.kt @@ -0,0 +1,22 @@ +package de.rki.coronawarnapp.appconfig.mapping + +import de.rki.coronawarnapp.server.protocols.internal.AppConfig +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class RiskCalculationConfigMapperTest : BaseTest() { + + private fun createInstance() = RiskCalculationConfigMapper() + + @Test + fun `simple creation`() { + val rawConfig = AppConfig.ApplicationConfiguration.newBuilder() + .build() + createInstance().map(rawConfig).apply { + this.attenuationDuration shouldBe rawConfig.attenuationDuration + this.minRiskScore shouldBe rawConfig.minRiskScore + this.riskScoreClasses shouldBe rawConfig.riskScoreClasses + } + } +} 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/calculationtracker/CalculationTrackerStorageTest.kt index 5b1be36e8bec29072f2aa32140cbc1a4a091214b..4a7c2a019b42c627cf506154457c74be5bf5b1b4 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/calculationtracker/CalculationTrackerStorageTest.kt @@ -2,8 +2,8 @@ package de.rki.coronawarnapp.nearby.modules.calculationtracker import android.content.Context import com.google.gson.GsonBuilder -import de.rki.coronawarnapp.util.gson.fromJson import de.rki.coronawarnapp.util.serialization.SerializationModule +import de.rki.coronawarnapp.util.serialization.fromJson import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations import io.mockk.clearAllMocks 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/calculationtracker/DefaultCalculationTrackerTest.kt index 067680cac59871576df6c49a6acb9eec6426e300..976ba6a4b0cc0015cc0636c19c747fac90921fee 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/calculationtracker/DefaultCalculationTrackerTest.kt @@ -61,7 +61,7 @@ class DefaultCalculationTrackerTest : BaseTest() { } @Test - fun `data is restored from storage`() = runBlockingTest2(permanentJobs = true) { + fun `data is restored from storage`() = runBlockingTest2(ignoreActive = true) { val calcData = Calculation( identifier = UUID.randomUUID().toString(), startedAt = Instant.EPOCH @@ -73,7 +73,7 @@ class DefaultCalculationTrackerTest : BaseTest() { } @Test - fun `tracking a new calculation`() = runBlockingTest2(permanentJobs = true) { + fun `tracking a new calculation`() = runBlockingTest2(ignoreActive = true) { createInstance(scope = this).apply { val expectedIdentifier = UUID.randomUUID().toString() trackNewCalaculation(expectedIdentifier) @@ -101,7 +101,7 @@ class DefaultCalculationTrackerTest : BaseTest() { } @Test - fun `finish an existing calcluation`() = runBlockingTest2(permanentJobs = true) { + fun `finish an existing calcluation`() = runBlockingTest2(ignoreActive = true) { val calcData = Calculation( identifier = UUID.randomUUID().toString(), startedAt = Instant.EPOCH @@ -136,7 +136,7 @@ class DefaultCalculationTrackerTest : BaseTest() { } @Test - fun `a late calculation overwrites timeout state`() = runBlockingTest2(permanentJobs = true) { + fun `a late calculation overwrites timeout state`() = runBlockingTest2(ignoreActive = true) { val calcData = Calculation( identifier = UUID.randomUUID().toString(), startedAt = Instant.EPOCH, @@ -165,7 +165,7 @@ class DefaultCalculationTrackerTest : BaseTest() { } @Test - fun `no more than 10 calcluations are tracked`() = runBlockingTest2(permanentJobs = true) { + fun `no more than 10 calcluations are tracked`() = runBlockingTest2(ignoreActive = true) { val calcData = (1..15L).map { val calcData = Calculation( identifier = "$it", @@ -189,7 +189,7 @@ class DefaultCalculationTrackerTest : BaseTest() { } @Test - fun `15 minute timeout on ongoing calcs`() = runBlockingTest2(permanentJobs = true) { + fun `15 minute timeout on ongoing calcs`() = runBlockingTest2(ignoreActive = true) { every { timeStamper.nowUTC } returns Instant.EPOCH .plus(Duration.standardMinutes(15)) .plus(2) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt index 81581412be578ed1f02be138075b250f0c7dcf01..4cbe2bda9a570500b6b6d56b6b23d3bcf139fc19 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/transaction/RetrieveDiagnosisKeysTransactionTest.kt @@ -1,9 +1,10 @@ package de.rki.coronawarnapp.transaction +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.appconfig.ConfigData import de.rki.coronawarnapp.environment.EnvironmentSetup import de.rki.coronawarnapp.nearby.ENFClient import de.rki.coronawarnapp.nearby.InternalExposureNotificationClient -import de.rki.coronawarnapp.service.applicationconfiguration.ApplicationConfigurationService import de.rki.coronawarnapp.storage.LocalData import de.rki.coronawarnapp.util.GoogleAPIVersion import de.rki.coronawarnapp.util.di.AppInjector @@ -37,6 +38,8 @@ class RetrieveDiagnosisKeysTransactionTest { @MockK lateinit var mockEnfClient: ENFClient @MockK lateinit var environmentSetup: EnvironmentSetup + @MockK lateinit var configProvider: AppConfigProvider + @MockK lateinit var configData: ConfigData @BeforeEach fun setUp() { @@ -50,17 +53,21 @@ class RetrieveDiagnosisKeysTransactionTest { mockEnfClient, environmentSetup ) + every { appConfigProvider } returns configProvider } + + coEvery { configProvider.getAppConfig() } returns configData + every { configData.supportedCountries } returns emptyList() + every { configData.exposureDetectionConfiguration } returns mockk() + every { AppInjector.component } returns appComponent mockkObject(InternalExposureNotificationClient) - mockkObject(ApplicationConfigurationService) mockkObject(RetrieveDiagnosisKeysTransaction) mockkObject(LocalData) coEvery { InternalExposureNotificationClient.asyncIsEnabled() } returns true - coEvery { ApplicationConfigurationService.asyncRetrieveExposureConfiguration() } returns mockk() every { LocalData.googleApiToken(any()) } just Runs every { LocalData.lastTimeDiagnosisKeysFromServerFetch() } returns Date() every { LocalData.lastTimeDiagnosisKeysFromServerFetch(any()) } just Runs diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt index ad8d1c58d286307a36d310eff65450b97042a18e..3384063bf964993859ccb719cdf16ffe23aa32d1 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/HashExtensionsTest.kt @@ -14,7 +14,8 @@ import java.io.File class HashExtensionsTest : BaseIOTest() { - private val testInput = "The Cake Is A Lie" + private val testInputText = "The Cake Is A Lie" + private val testInputByteArray = testInputText.toByteArray() private val testDir = File(IO_TEST_BASEDIR, this::class.simpleName!!) @BeforeEach @@ -31,17 +32,32 @@ class HashExtensionsTest : BaseIOTest() { @Test fun `hash string to MD5`() { - testInput.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179" + testInputText.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179" } @Test fun `hash string to SHA256`() { - testInput.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61" + testInputText.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61" } @Test fun `hash string to SHA1`() { - testInput.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76" + testInputText.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76" + } + + @Test + fun `hash bytearray to MD5`() { + testInputByteArray.toMD5() shouldBe "e42997e37d8d70d4927b0b396254c179" + } + + @Test + fun `hash bytearray to SHA256`() { + testInputByteArray.toSHA256() shouldBe "3afc82e0c5df81d1733fe0c289538a1a1f7a5038d5c261860a5c83952f4bcb61" + } + + @Test + fun `hash bytearray to SHA1`() { + testInputByteArray.toSHA1() shouldBe "4d57f806e5f714ebdb5a74a12fda9523fae21d76" } @Test diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt index e5bbbc32bf1b36461320d765152b9771a9a65607..3de7010f7bf7105aa25a8738dbcd6772f0c140b4 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/flow/HotDataFlowTest.kt @@ -1,5 +1,6 @@ package de.rki.coronawarnapp.util.flow +import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.shouldBe import io.kotest.matchers.types.instanceOf import io.mockk.coEvery @@ -7,10 +8,13 @@ import io.mockk.coVerify import io.mockk.mockk import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.withTimeoutOrNull import org.junit.jupiter.api.Test import testhelpers.BaseTest import testhelpers.coroutines.runBlockingTest2 @@ -22,23 +26,47 @@ import kotlin.concurrent.thread class HotDataFlowTest : BaseTest() { @Test - fun `init call only happens on first collection`() { + fun `init happens on first collection and exception is forwarded`() { val testScope = TestCoroutineScope() val hotData = HotDataFlow<String>( loggingTag = "tag", scope = testScope, coroutineContext = Dispatchers.Unconfined, - startValueProvider = { - throw IOException() - } + startValueProvider = { throw IOException() } ) - testScope.apply { - runBlockingTest2(permanentJobs = true) { + runBlocking { + // This blocking scope get's the init exception as the first caller + shouldThrow<IOException> { hotData.data.first() } - uncaughtExceptions.single() shouldBe instanceOf(IOException::class) } + + testScope.advanceUntilIdle() + + testScope.uncaughtExceptions.singleOrNull() shouldBe null + } + + @Test + fun `exception is not forwarded if flag is set`() { + val testScope = TestCoroutineScope() + val hotData = HotDataFlow<String>( + loggingTag = "tag", + scope = testScope, + coroutineContext = Dispatchers.Unconfined, + forwardException = false, + startValueProvider = { throw IOException() } + ) + runBlocking { + withTimeoutOrNull(500) { + // This blocking scope get's the init exception as the first caller + hotData.data.firstOrNull() + } shouldBe null + } + + testScope.advanceUntilIdle() + + testScope.uncaughtExceptions.single() shouldBe instanceOf(IOException::class) } @Test @@ -179,4 +207,35 @@ class HotDataFlowTest : BaseTest() { } coVerify(exactly = 1) { valueProvider.invoke(any()) } } + + @Test + fun `blocking update is actually blocking`() = runBlocking { + val testScope = TestCoroutineScope() + val hotData = HotDataFlow( + loggingTag = "tag", + scope = testScope, + coroutineContext = testScope.coroutineContext, + startValueProvider = { + delay(2000) + 2 + }, + sharingBehavior = SharingStarted.Lazily + ) + + hotData.updateSafely { + delay(2000) + this + 1 + } + + val testCollector = hotData.data.test(startOnScope = testScope) + + testScope.advanceUntilIdle() + + hotData.updateBlocking { this - 3 } shouldBe 0 + + testCollector.await { list, i -> i == 3 } + testCollector.latestValues shouldBe listOf(2, 3, 0) + + testCollector.cancel() + } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f5e88d201075dd4f3f38dd53c5a5772422862a55 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/serialization/adapter/ByteArrayAdapterTest.kt @@ -0,0 +1,73 @@ +package de.rki.coronawarnapp.util.serialization.adapter + +import com.google.gson.GsonBuilder +import com.google.gson.JsonParseException +import de.rki.coronawarnapp.util.serialization.fromJson +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import okio.ByteString.Companion.decodeHex +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class ByteArrayAdapterTest : BaseTest() { + + private val gson = GsonBuilder() + .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter()) + .create() + + // This is actually an app config, some cases like did not trigger a few serialization issues in the server test. + private val goodByteArray = ( + "080b124d0a230a034c4f57180f221a68747470733a2f2f777777" + + "2e636f726f6e617761726e2e6170700a260a0448494748100f1848221a68747470733a2f2f7777772e636f7" + + "26f6e617761726e2e6170701a640a10080110021803200428053006380740081100000000000049401a0a20" + + "0128013001380140012100000000000049402a1008051005180520052805300538054005310000000000003" + + "4403a0e1001180120012801300138014001410000000000004940221c0a040837103f121209000000000000" + + "f03f11000000000000e03f20192a1a0a0a0a041008180212021005120c0a0408011804120408011804" + ).decodeHex().toByteArray() + + @Test + fun `serialize and deserialize`() { + val serialized: String = gson.toJson(TestData(goodByteArray)) + + gson.fromJson<TestData>(serialized) shouldBe TestData(goodByteArray) + } + + @Test + fun `malformed base64 should throw specific exception`() { + shouldThrow<JsonParseException> { + """ + { + "byteArray": "Don't feed this to your base 64 decoder :(" + } + """.trimIndent().let { gson.fromJson<TestData>(it) } + } + } + + @Test + fun `empty base64 string is OK`() { + """ + { + "byteArray": "" + } + """.trimIndent().let { + gson.fromJson<TestData>(it) shouldBe TestData(ByteArray(0)) + } + } + + data class TestData( + val byteArray: ByteArray + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as TestData + + if (!byteArray.contentEquals(other.byteArray)) return false + + return true + } + + override fun hashCode(): Int = byteArray.contentHashCode() + } +} 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 0fc5027bee9cc61a1545b2cab0ad52bdde4b1488..b5a62565aa2ee00e0fb214a9d1c6d53453d7199a 100644 --- a/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt +++ b/Corona-Warn-App/src/test/java/testhelpers/coroutines/TestExtensions.kt @@ -13,13 +13,13 @@ fun TestCoroutineScope.runBlockingTest2( permanentJobs: Boolean = false, block: suspend TestCoroutineScope.() -> Unit ): Unit = runBlockingTest2( - permanentJobs = permanentJobs, + ignoreActive = permanentJobs, context = coroutineContext, testBody = block ) fun runBlockingTest2( - permanentJobs: Boolean = false, + ignoreActive: Boolean = false, context: CoroutineContext = EmptyCoroutineContext, testBody: suspend TestCoroutineScope.() -> Unit ) { @@ -31,11 +31,11 @@ fun runBlockingTest2( testBody = testBody ) } catch (e: UncompletedCoroutinesError) { - if (!permanentJobs) throw e + if (!ignoreActive) throw e } } } catch (e: Exception) { - if (!permanentJobs || (e.message != "This job has not completed yet")) { + if (!ignoreActive || (e.message != "This job has not completed yet")) { throw e } } 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 83b3f2cfeeec433dc06a057e4404c92cfef5507d..b132c1dec5cc6518c811289293f39578b911b531 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,38 +1,22 @@ package de.rki.coronawarnapp.test.api.ui import android.content.Context -import androidx.lifecycle.Observer import de.rki.coronawarnapp.environment.EnvironmentSetup -import de.rki.coronawarnapp.storage.TestSettings import de.rki.coronawarnapp.task.TaskController -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 -@ExperimentalTime -@ExperimentalCoroutinesApi @ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class) class TestForApiFragmentViewModelTest : BaseTest() { - @MockK private lateinit var environmentSetup: EnvironmentSetup @MockK private lateinit var context: Context - @MockK private lateinit var testSettings: TestSettings @MockK lateinit var taskController: TaskController private var currentEnvironment = EnvironmentSetup.Type.DEV @@ -40,20 +24,6 @@ class TestForApiFragmentViewModelTest : BaseTest() { @BeforeEach fun setup() { MockKAnnotations.init(this) - currentEnvironment = EnvironmentSetup.Type.DEV - - every { environmentSetup.defaultEnvironment } returns EnvironmentSetup.Type.DEV - every { environmentSetup.submissionCdnUrl } returns "submissionUrl" - every { environmentSetup.downloadCdnUrl } returns "downloadUrl" - every { environmentSetup.verificationCdnUrl } returns "verificationUrl" - - every { environmentSetup.currentEnvironment = any() } answers { - currentEnvironment = arg(0) - Unit - } - every { environmentSetup.currentEnvironment } answers { - currentEnvironment - } } @AfterEach @@ -62,45 +32,7 @@ class TestForApiFragmentViewModelTest : BaseTest() { } private fun createViewModel(): TestForApiFragmentViewModel = TestForApiFragmentViewModel( - envSetup = environmentSetup, context = context, - testSettings = testSettings, taskController = taskController ) - - @Test - fun `toggeling the env works`() = flakyTest { - currentEnvironment = EnvironmentSetup.Type.DEV - val vm = createViewModel() - - 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 - } } diff --git a/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..42ad7c156bb1ceff1fb4a3603f1849edb92f444c --- /dev/null +++ b/Corona-Warn-App/src/testDeviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragmentViewModelTest.kt @@ -0,0 +1,105 @@ +package de.rki.coronawarnapp.test.debugoptions.ui + +import android.content.Context +import androidx.lifecycle.Observer +import de.rki.coronawarnapp.environment.EnvironmentSetup +import de.rki.coronawarnapp.storage.TestSettings +import de.rki.coronawarnapp.task.TaskController +import de.rki.coronawarnapp.test.api.ui.EnvironmentState +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 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.TestDispatcherProvider +import testhelpers.extensions.CoroutinesTestExtension +import testhelpers.extensions.InstantExecutorExtension +import testhelpers.flakyTest + +@ExtendWith(InstantExecutorExtension::class, CoroutinesTestExtension::class) +class DebugOptionsFragmentViewModelTest : BaseTest() { + + @MockK private lateinit var environmentSetup: EnvironmentSetup + @MockK private lateinit var context: Context + @MockK private lateinit var testSettings: TestSettings + @MockK lateinit var taskController: TaskController + + 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.submissionCdnUrl } returns "submissionUrl" + every { environmentSetup.downloadCdnUrl } returns "downloadUrl" + every { environmentSetup.verificationCdnUrl } returns "verificationUrl" + + every { environmentSetup.currentEnvironment = any() } answers { + currentEnvironment = arg(0) + Unit + } + every { environmentSetup.currentEnvironment } answers { + currentEnvironment + } + } + + @AfterEach + fun teardown() { + clearAllMocks() + } + + private fun createViewModel(): DebugOptionsFragmentViewModel = DebugOptionsFragmentViewModel( + context = context, + taskController = taskController, + envSetup = environmentSetup, + testSettings = testSettings, + dispatcherProvider = TestDispatcherProvider + ) + + @Test + fun `toggeling the env works`() = flakyTest { + currentEnvironment = EnvironmentSetup.Type.DEV + val vm = createViewModel() + + 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 + } +}