diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt index 55b3107f616ba28a906790bd20e8feef339c4096..df7f430bbe48c42f64406e73b64ed417d2750c51 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/DataReset.kt @@ -25,6 +25,7 @@ import de.rki.coronawarnapp.storage.TracingSettings import de.rki.coronawarnapp.submission.SubmissionRepository import de.rki.coronawarnapp.submission.SubmissionSettings import de.rki.coronawarnapp.ui.presencetracing.TraceLocationPreferences +import de.rki.coronawarnapp.vaccination.core.repository.ValueSetsRepository import de.rki.coronawarnapp.vaccination.core.VaccinationPreferences import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository import kotlinx.coroutines.sync.Mutex @@ -63,6 +64,7 @@ class DataReset @Inject constructor( private val traceWarningRepository: TraceWarningRepository, private val coronaTestRepository: CoronaTestRepository, private val ratProfileSettings: RATProfileSettings, + private val valueSetsRepository: ValueSetsRepository, private val vaccinationPreferences: VaccinationPreferences, private val vaccinationRepository: VaccinationRepository, ) { @@ -88,7 +90,6 @@ class DataReset @Inject constructor( riskLevelStorage.clear() contactDiaryPreferences.clear() traceLocationPreferences.clear() - cwaSettings.clear() surveySettings.clear() analyticsSettings.clear() @@ -110,6 +111,7 @@ class DataReset @Inject constructor( coronaTestRepository.clear() ratProfileSettings.deleteProfile() + valueSetsRepository.clear() vaccinationRepository.clear() vaccinationPreferences.clear() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/ValueSetsRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/ValueSetsRepository.kt index e3497c090778dcdf8079163b72f2bd4082338107..ca5f244a11c0785cd63b06bede6576f00c49579a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/ValueSetsRepository.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/ValueSetsRepository.kt @@ -1,17 +1,77 @@ package de.rki.coronawarnapp.vaccination.core.repository -import de.rki.coronawarnapp.submission.server.SubmissionServer +import dagger.Reusable +import de.rki.coronawarnapp.util.coroutine.AppScope +import de.rki.coronawarnapp.vaccination.core.repository.storage.ValueSetsStorage +import de.rki.coronawarnapp.vaccination.core.server.valueset.DefaultVaccinationValueSet +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationServer import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet - +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import timber.log.Timber +import java.util.Locale import javax.inject.Inject -import javax.inject.Singleton -@Singleton +@Reusable class ValueSetsRepository @Inject constructor( - private val submissionServer: SubmissionServer + private val vaccinationServer: VaccinationServer, + private val valueSetsStorage: ValueSetsStorage, + @AppScope appScope: CoroutineScope ) { - val latestValueSet: Flow<VaccinationValueSet?> = flowOf(null) + private val internalFlow = MutableStateFlow<Locale?>(null) + + val latestValueSet: Flow<VaccinationValueSet?> = internalFlow + .filterNotNull() + .map { + fromServer(it) ?: fromLocalStorage() ?: fromServer(Locale.ENGLISH) ?: createEmptyValueSet(it) + // Return empty value set as last resort + } + .stateIn( + scope = appScope, + started = SharingStarted.Lazily, + initialValue = createEmptyValueSet(Locale.ENGLISH) + ) + + fun reloadValueSet(languageCode: Locale) { + Timber.d("reloadValueSet(languageCode=%s)", languageCode) + internalFlow.value = languageCode + } + + private suspend fun fromServer(languageCode: Locale): VaccinationValueSet? = try { + Timber.d("fromServer(languageCode=%s)", languageCode) + vaccinationServer.getVaccinationValueSets(languageCode = languageCode)?.also { + Timber.d("Saving new value sets %s", it) + valueSetsStorage.vaccinationValueSet = it + } + } catch (e: Exception) { + Timber.w(e, "fromServer(): %s", e.message) + null + } + + private fun fromLocalStorage(): VaccinationValueSet? = try { + Timber.d("fromLocalStorage()") + valueSetsStorage.vaccinationValueSet + } catch (e: Exception) { + Timber.w(e, "fromLocalStorage(): %s", e.message) + null + } + + private fun createEmptyValueSet(languageCode: Locale) = DefaultVaccinationValueSet( + languageCode = languageCode, + vp = DefaultVaccinationValueSet.DefaultValueSet(items = emptyList()), + mp = DefaultVaccinationValueSet.DefaultValueSet(items = emptyList()), + ma = DefaultVaccinationValueSet.DefaultValueSet(items = emptyList()) + ) + + fun clear() { + Timber.d("Clearing value sets") + vaccinationServer.clear() + valueSetsStorage.clear() + } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt index 76ca4e4b5c2840800c9f0c0c19be965abad73ba9..d9320fb3136e8f1ea406c7f620cc605153b3a077 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt @@ -13,6 +13,7 @@ import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateData import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationQRCodeExtractor import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet +import de.rki.coronawarnapp.vaccination.core.server.valueset.getDisplayText import org.joda.time.Instant import org.joda.time.LocalDate diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ValueSetsStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ValueSetsStorage.kt new file mode 100644 index 0000000000000000000000000000000000000000..e9d91b475b72a8d521cbf6e649407bc7f7c08f4d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ValueSetsStorage.kt @@ -0,0 +1,98 @@ +package de.rki.coronawarnapp.vaccination.core.repository.storage + +import android.content.Context +import android.content.SharedPreferences +import androidx.annotation.Keep +import androidx.core.content.edit +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import dagger.Reusable +import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.preferences.clearAndNotify +import de.rki.coronawarnapp.util.serialization.BaseGson +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject + +@Reusable +class ValueSetsStorage @Inject constructor( + @AppContext private val context: Context, + @BaseGson private val gson: Gson +) { + + private val prefs: SharedPreferences by lazy { + context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + } + + var vaccinationValueSet: VaccinationValueSet? + get() = getValueSet() + set(value) = setValueSet(value) + + private fun getValueSet(): VaccinationValueSet? { + Timber.d("Loading value set") + return prefs.getString(PKEY_VALUE_SETS_PREFIX, null)?.let { + gson.fromJson(it, StoredVaccinationValueSet::class.java).also { loaded -> Timber.d("Loaded %s", loaded) } + }.also { Timber.d("Returning %s", it) } + } + + private fun setValueSet(value: VaccinationValueSet?) { + Timber.d("Saving %s", value) + value?.let { + prefs.edit { + val storeValue = it.toStoredVaccinationValueSet() + val json = gson.toJson(storeValue, StoredVaccinationValueSet::class.java) + Timber.d("String %s", json) + putString(PKEY_VALUE_SETS_PREFIX, json) + } + } + } + + fun clear() { + Timber.d("Clearing local storage") + prefs.clearAndNotify() + } + + @Keep + private data class StoredVaccinationValueSet( + @SerializedName("languageCode") override val languageCode: Locale, + @SerializedName("vp") override val vp: StoredValueSet, + @SerializedName("mp") override val mp: StoredValueSet, + @SerializedName("ma") override val ma: StoredValueSet + ) : VaccinationValueSet { + + @Keep + data class StoredValueSet( + @SerializedName("items") override val items: List<StoredItem> + ) : VaccinationValueSet.ValueSet { + + @Keep + data class StoredItem( + @SerializedName("key") override val key: String, + @SerializedName("displayText") override val displayText: String + ) : VaccinationValueSet.ValueSet.Item + } + } + + private fun VaccinationValueSet.toStoredVaccinationValueSet(): StoredVaccinationValueSet = + StoredVaccinationValueSet( + languageCode = languageCode, + vp = vp.toStoredValueSet(), + mp = mp.toStoredValueSet(), + ma = ma.toStoredValueSet() + ) + + private fun VaccinationValueSet.ValueSet.toStoredValueSet(): StoredVaccinationValueSet.StoredValueSet = + StoredVaccinationValueSet.StoredValueSet( + items = items.map { it.toStoredItem() } + ) + + private fun VaccinationValueSet.ValueSet.Item.toStoredItem(): StoredVaccinationValueSet.StoredValueSet.StoredItem = + StoredVaccinationValueSet.StoredValueSet.StoredItem( + key = key, + displayText = displayText + ) +} + +private const val PREF_NAME = "valuesets_local" +private const val PKEY_VALUE_SETS_PREFIX = "valuesets" diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/DefaultVaccinationValueSet.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/DefaultVaccinationValueSet.kt new file mode 100644 index 0000000000000000000000000000000000000000..1962cf330dbe26a192497bc4eeaeeb0e052b00d9 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/DefaultVaccinationValueSet.kt @@ -0,0 +1,21 @@ +package de.rki.coronawarnapp.vaccination.core.server.valueset + +import java.util.Locale + +data class DefaultVaccinationValueSet( + override val languageCode: Locale, + override val vp: VaccinationValueSet.ValueSet, + override val mp: VaccinationValueSet.ValueSet, + override val ma: VaccinationValueSet.ValueSet +) : VaccinationValueSet { + + data class DefaultValueSet( + override val items: List<VaccinationValueSet.ValueSet.Item> + ) : VaccinationValueSet.ValueSet { + + data class DefaultItem( + override val key: String, + override val displayText: String + ) : VaccinationValueSet.ValueSet.Item + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationServer.kt index cb64715a67d263d703c095a9e64ad11c925e006c..0d108cd2aac3b3b439cf8a679a82e029eb8d788e 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationServer.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationServer.kt @@ -1,7 +1,19 @@ package de.rki.coronawarnapp.vaccination.core.server.valueset +import dagger.Lazy import dagger.Reusable +import de.rki.coronawarnapp.server.protocols.internal.dgc.ValueSetsOuterClass +import de.rki.coronawarnapp.util.ZipHelper.readIntoMap +import de.rki.coronawarnapp.util.ZipHelper.unzip +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.security.SignatureValidation +import de.rki.coronawarnapp.vaccination.core.server.valueset.internal.ValueSetInvalidSignatureException +import de.rki.coronawarnapp.vaccination.core.server.valueset.internal.toVaccinationValueSet +import kotlinx.coroutines.withContext import okhttp3.Cache +import okhttp3.ResponseBody +import retrofit2.HttpException +import retrofit2.Response import timber.log.Timber import java.util.Locale import javax.inject.Inject @@ -11,11 +23,52 @@ import javax.inject.Inject */ @Reusable class VaccinationServer @Inject constructor( - @VaccinationValueSetHttpClient private val cache: Cache + @ValueSet private val cache: Cache, + private val apiV1: Lazy<VaccinationValueSetApiV1>, + private val dispatcherProvider: DispatcherProvider, + private val signatureValidation: SignatureValidation ) { - suspend fun getVaccinationValueSets(languageCode: Locale): VaccinationValueSet { - throw NotImplementedError() + suspend fun getVaccinationValueSets(languageCode: Locale): VaccinationValueSet? = + withContext(dispatcherProvider.Default) { + return@withContext try { + val response = requestValueSets(languageCode.language) + if (!response.isSuccessful) throw HttpException(response) + + val body = requireNotNull(response.body()) { "Body of response was null" } + val valueSetsProtobuf = body.parseBody() + valueSetsProtobuf.toVaccinationValueSet(languageCode = languageCode) + } catch (e: Exception) { + Timber.e(e, "Getting vaccination value sets from server failed cause: ${e.message}") + null + } + } + + private suspend fun requestValueSets(languageCode: String): Response<ResponseBody> = + withContext(dispatcherProvider.IO) { + Timber.d("Requesting value sets for language $languageCode from server") + apiV1.get().getValueSets(languageCode = languageCode) + } + + private fun ResponseBody.parseBody(): ValueSetsOuterClass.ValueSets { + val fileMap = this.byteStream().unzip().readIntoMap() + + val exportBinary = fileMap[EXPORT_BINARY_FILE_NAME] + val exportSignature = fileMap[EXPORT_SIGNATURE_FILE_NAME] + + if (exportBinary == null || exportSignature == null) + throw ValueSetInvalidSignatureException(msg = "Unknown files ${fileMap.entries}") + + val hasValidSignature = signatureValidation.hasValidSignature( + toVerify = exportBinary, + signatureList = SignatureValidation.parseTEKStyleSignature(exportSignature) + ) + + if (!hasValidSignature) { + throw ValueSetInvalidSignatureException(msg = "Signature of value sets did not match") + } + + return ValueSetsOuterClass.ValueSets.parseFrom(exportBinary) } fun clear() { @@ -24,3 +77,6 @@ class VaccinationServer @Inject constructor( cache.evictAll() } } + +private const val EXPORT_BINARY_FILE_NAME = "export.bin" +private const val EXPORT_SIGNATURE_FILE_NAME = "export.sig" diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSet.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSet.kt index 6bace45c474e8760501732e684653ab8c7360ea8..2ebc6e08a1f3ce0f9de7d1f0deeaa12ff9acebf2 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSet.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSet.kt @@ -1,9 +1,27 @@ package de.rki.coronawarnapp.vaccination.core.server.valueset +import androidx.annotation.Keep import java.util.Locale +@Keep interface VaccinationValueSet { val languageCode: Locale + val vp: ValueSet + val mp: ValueSet + val ma: ValueSet - fun getDisplayText(key: String): String? + interface ValueSet { + val items: List<Item> + + // Use custom item instead of map to allow for future extensions + interface Item { + val key: String + val displayText: String + } + } } + +fun VaccinationValueSet.getDisplayText(key: String): String? = + vp.getDisplayText(key) ?: mp.getDisplayText(key) ?: ma.getDisplayText(key) + +fun VaccinationValueSet.ValueSet.getDisplayText(key: String): String? = items.find { key == it.key }?.displayText diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetModule.kt index 1a4d73746910ef349635ffe3e5e95f0c1a0f0807..0fd80cda3bf80f09da0109692a134f4995a9ff1f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetModule.kt @@ -4,48 +4,67 @@ import android.content.Context import dagger.Module import dagger.Provides import dagger.Reusable -import de.rki.coronawarnapp.environment.vaccination.VaccinationCertificateCDNUrl -import de.rki.coronawarnapp.http.HttpClientDefault +import de.rki.coronawarnapp.environment.download.DownloadCDNHttpClient +import de.rki.coronawarnapp.environment.download.DownloadCDNServerUrl import de.rki.coronawarnapp.util.di.AppContext import okhttp3.Cache +import okhttp3.CacheControl +import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.Response import retrofit2.Retrofit import java.io.File +import java.util.concurrent.TimeUnit @Module class VaccinationValueSetModule { @Reusable - @VaccinationValueSetHttpClient + @ValueSet @Provides fun cache( @AppContext context: Context ): Cache { val cacheDir = File(context.cacheDir, "vaccination_value") - val cacheFile = File(cacheDir, "http_cache") - return Cache(cacheFile, CACHE_SIZE_5MB) + return Cache(cacheDir, CACHE_SIZE_5MB) } - @Reusable - @VaccinationValueSetHttpClient - @Provides - fun httpClient( - @HttpClientDefault defaultHttpClient: OkHttpClient, - @VaccinationValueSetHttpClient cache: Cache - ): OkHttpClient = defaultHttpClient.newBuilder() - .cache(cache) - .build() - @Reusable @Provides fun api( - @VaccinationValueSetHttpClient httpClient: OkHttpClient, - @VaccinationCertificateCDNUrl url: String - ): VaccinationValueSetApiV1 = Retrofit.Builder() - .client(httpClient) - .baseUrl(url) - .build() - .create(VaccinationValueSetApiV1::class.java) + @DownloadCDNHttpClient httpClient: OkHttpClient, + @DownloadCDNServerUrl url: String, + @ValueSet cache: Cache + ): VaccinationValueSetApiV1 { + val client = httpClient.newBuilder() + .addNetworkInterceptor(CacheInterceptor()) + .cache(cache) + .build() + + return Retrofit.Builder() + .client(client) + .baseUrl(url) + .build() + .create(VaccinationValueSetApiV1::class.java) + } + + private class CacheInterceptor : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + + val cacheControl = CacheControl.Builder() + .maxAge(300, TimeUnit.SECONDS) + .build() + + // We cache as we please + val cacheHeader = "Cache-Control" + return response.newBuilder() + .removeHeader("Pragma") + .removeHeader(cacheHeader) + .addHeader(cacheHeader, cacheControl.toString()) + .build() + } + } companion object { private const val CACHE_SIZE_5MB = 5 * 1024 * 1024L // 5MB diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetHttpClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/ValueSet.kt similarity index 77% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetHttpClient.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/ValueSet.kt index 1650bcb5f61039656a1cc3e51485dacf03e5317b..1300c045c0d8f448781ddcaa327bb7f2959fe09f 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetHttpClient.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/ValueSet.kt @@ -5,4 +5,4 @@ import javax.inject.Qualifier @Qualifier @MustBeDocumented @Retention(AnnotationRetention.RUNTIME) -annotation class VaccinationValueSetHttpClient +annotation class ValueSet diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/internal/VaccinationValueSetMapper.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/internal/VaccinationValueSetMapper.kt new file mode 100644 index 0000000000000000000000000000000000000000..ad13f40aace7a84a4d73ac99b0d5b836fa8a2185 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/internal/VaccinationValueSetMapper.kt @@ -0,0 +1,28 @@ +package de.rki.coronawarnapp.vaccination.core.server.valueset.internal + +import de.rki.coronawarnapp.server.protocols.internal.dgc.ValueSetsOuterClass +import de.rki.coronawarnapp.vaccination.core.server.valueset.DefaultVaccinationValueSet +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet +import timber.log.Timber +import java.util.Locale + +internal fun ValueSetsOuterClass.ValueSets.toVaccinationValueSet(languageCode: Locale): VaccinationValueSet { + Timber.d("toVaccinationValueSet(valueSets=%s, languageCode=%s)", this, languageCode) + return DefaultVaccinationValueSet( + languageCode = languageCode, + vp = vp.toValueSet(), + mp = mp.toValueSet(), + ma = ma.toValueSet() + ).also { Timber.tag(TAG).d("Created %s", it) } +} + +internal fun ValueSetsOuterClass.ValueSet.toValueSet(): VaccinationValueSet.ValueSet = + DefaultVaccinationValueSet.DefaultValueSet(items = itemsList.map { it.toItem() }) + +internal fun ValueSetsOuterClass.ValueSetItem.toItem(): VaccinationValueSet.ValueSet.Item = + DefaultVaccinationValueSet.DefaultValueSet.DefaultItem( + key = key, + displayText = displayText + ) + +private const val TAG: String = "ValueSetMapper" diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/internal/ValueSetInvalidSignatureException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/internal/ValueSetInvalidSignatureException.kt new file mode 100644 index 0000000000000000000000000000000000000000..6de24adafb228da191c3f7d076ef1729b8ad3540 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/internal/ValueSetInvalidSignatureException.kt @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.vaccination.core.server.valueset.internal + +import de.rki.coronawarnapp.exception.reporting.ErrorCodes +import de.rki.coronawarnapp.util.security.InvalidSignatureException + +class ValueSetInvalidSignatureException(msg: String) : InvalidSignatureException( + code = ErrorCodes.APPLICATION_CONFIGURATION_INVALID.code, + message = msg +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListViewModel.kt index 0ed0550a991257e869ab11c699eacc6153f71707..a8dbcba0f7b644b09a507dda3bf45869e9b8ea71 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListViewModel.kt @@ -1,17 +1,21 @@ package de.rki.coronawarnapp.vaccination.ui.list +import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.asLiveData import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import de.rki.coronawarnapp.contactdiary.util.getLocale import de.rki.coronawarnapp.util.TimeAndDateExtensions.toDayFormat +import de.rki.coronawarnapp.util.di.AppContext import de.rki.coronawarnapp.util.ui.SingleLiveEvent import de.rki.coronawarnapp.util.viewmodel.CWAViewModel import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson.Status.COMPLETE import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import de.rki.coronawarnapp.vaccination.core.repository.ValueSetsRepository import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListItem import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListIncompleteTopCardItemVH.VaccinationListIncompleteTopCardItem import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListNameCardItemVH.VaccinationListNameCardItem @@ -21,9 +25,15 @@ import kotlinx.coroutines.flow.map class VaccinationListViewModel @AssistedInject constructor( vaccinationRepository: VaccinationRepository, + valueSetsRepository: ValueSetsRepository, + @AppContext context: Context, @Assisted private val personIdentifierCodeSha256: String ) : CWAViewModel() { + init { + valueSetsRepository.reloadValueSet(languageCode = context.getLocale()) + } + val events = SingleLiveEvent<Event>() private val vaccinatedPersonFlow = vaccinationRepository.vaccinationInfos.map { vaccinatedPersonSet -> diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/DataResetTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/DataResetTest.kt index 1dfedac57403a304eb753e1193f923402fbab5b2..5577560dc72f94ae8b79716c375227c270df1e73 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/DataResetTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/DataResetTest.kt @@ -26,6 +26,7 @@ import de.rki.coronawarnapp.submission.SubmissionSettings import de.rki.coronawarnapp.ui.presencetracing.TraceLocationPreferences import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository import de.rki.coronawarnapp.vaccination.core.VaccinationPreferences +import de.rki.coronawarnapp.vaccination.core.repository.ValueSetsRepository import io.mockk.MockKAnnotations import io.mockk.coVerify import io.mockk.impl.annotations.MockK @@ -62,6 +63,7 @@ internal class DataResetTest : BaseTest() { @MockK lateinit var ratProfileSettings: RATProfileSettings @MockK lateinit var vaccinationRepository: VaccinationRepository @MockK lateinit var vaccinationPreferences: VaccinationPreferences + @MockK lateinit var valueSetsRepository: ValueSetsRepository @BeforeEach fun setUp() { @@ -95,6 +97,7 @@ internal class DataResetTest : BaseTest() { ratProfileSettings = ratProfileSettings, vaccinationPreferences = vaccinationPreferences, vaccinationRepository = vaccinationRepository, + valueSetsRepository = valueSetsRepository ) @Test diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/ValueSetsRepositoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/ValueSetsRepositoryTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..3edc082661868d7de4748dcb8356778d988835a2 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/ValueSetsRepositoryTest.kt @@ -0,0 +1,213 @@ +package de.rki.coronawarnapp.vaccination.core.repository + +import de.rki.coronawarnapp.vaccination.core.repository.storage.ValueSetsStorage +import de.rki.coronawarnapp.vaccination.core.server.valueset.DefaultVaccinationValueSet +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationServer +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.runs +import io.mockk.verify +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import java.util.Locale + +class ValueSetsRepositoryTest : BaseTest() { + + @MockK lateinit var vaccinationServer: VaccinationServer + @MockK lateinit var valueSetsStorage: ValueSetsStorage + + private val testScope = TestCoroutineScope() + + private val emptyValueSetEN = createValueSet(languageCode = Locale.ENGLISH) + private val emptyValueSetDE = createValueSet(languageCode = Locale.GERMAN) + + private val valueSetEN = createValueSet( + languageCode = Locale.ENGLISH, + vpItems = listOf( + DefaultVaccinationValueSet.DefaultValueSet.DefaultItem( + key = "1119305005", + displayText = "Vaccine-Name" + ) + ), + mpItems = listOf( + DefaultVaccinationValueSet.DefaultValueSet.DefaultItem( + key = "EU/1/21/1529", + displayText = "MedicalProduct-Name" + ) + ), + maItems = listOf( + DefaultVaccinationValueSet.DefaultValueSet.DefaultItem( + key = "ORG-100001699", + displayText = "Manufactorer-Name" + ) + ) + ) + + private val valueSetDE = createValueSet( + languageCode = Locale.GERMAN, + vpItems = listOf( + DefaultVaccinationValueSet.DefaultValueSet.DefaultItem( + key = "1119305005", + displayText = "Impfstoff-Name" + ) + ), + mpItems = listOf( + DefaultVaccinationValueSet.DefaultValueSet.DefaultItem( + key = "EU/1/21/1529", + displayText = "Arzneimittel-Name" + ) + ), + maItems = listOf( + DefaultVaccinationValueSet.DefaultValueSet.DefaultItem( + key = "ORG-100001699", + displayText = "Hersteller-Name" + ) + ) + ) + + private fun createValueSet( + languageCode: Locale, + vpItems: List<DefaultVaccinationValueSet.DefaultValueSet.DefaultItem> = emptyList(), + mpItems: List<DefaultVaccinationValueSet.DefaultValueSet.DefaultItem> = emptyList(), + maItems: List<DefaultVaccinationValueSet.DefaultValueSet.DefaultItem> = emptyList() + ) = DefaultVaccinationValueSet( + languageCode = languageCode, + vp = DefaultVaccinationValueSet.DefaultValueSet(items = vpItems), + mp = DefaultVaccinationValueSet.DefaultValueSet(items = mpItems), + ma = DefaultVaccinationValueSet.DefaultValueSet(items = maItems) + ) + + private fun createInstance() = ValueSetsRepository( + vaccinationServer = vaccinationServer, + valueSetsStorage = valueSetsStorage, + appScope = testScope + ) + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + coEvery { vaccinationServer.getVaccinationValueSets(any()) } returns null + every { vaccinationServer.clear() } just runs + every { valueSetsStorage.vaccinationValueSet } returns null + every { valueSetsStorage.vaccinationValueSet = any() } just runs + every { valueSetsStorage.clear() } just runs + } + + @Test + fun `default value is an empty value set EN`() = runBlockingTest { + createInstance().run { + latestValueSet.first() shouldBe emptyValueSetEN + + coVerify(exactly = 0) { + vaccinationServer.getVaccinationValueSets(any()) + valueSetsStorage.vaccinationValueSet + } + } + } + + @Test + fun `falls back to empty value set with specified language code`() = runBlockingTest { + createInstance().run { + reloadValueSet(languageCode = Locale.GERMAN) + latestValueSet.first() shouldBe emptyValueSetDE + + coVerify(exactly = 1) { + valueSetsStorage.vaccinationValueSet + vaccinationServer.getVaccinationValueSets(Locale.GERMAN) + vaccinationServer.getVaccinationValueSets(Locale.ENGLISH) + } + } + } + + @Test + fun `returns value set for specified language from server`() = runBlockingTest { + coEvery { vaccinationServer.getVaccinationValueSets(Locale.GERMAN) } returns valueSetDE + coEvery { vaccinationServer.getVaccinationValueSets(Locale.ENGLISH) } returns valueSetEN + + createInstance().run { + reloadValueSet(Locale.GERMAN) + latestValueSet.first() shouldBe valueSetDE + + reloadValueSet(Locale.ENGLISH) + latestValueSet.first() shouldBe valueSetEN + } + + verify(exactly = 0) { + valueSetsStorage.vaccinationValueSet + } + + coVerify(exactly = 1) { + vaccinationServer.getVaccinationValueSets(Locale.GERMAN) + vaccinationServer.getVaccinationValueSets(Locale.ENGLISH) + } + } + + @Test + fun `if no value set is available for specified language fall back to EN`() = runBlockingTest { + coEvery { vaccinationServer.getVaccinationValueSets(Locale.ENGLISH) } returns valueSetEN + + createInstance().run { + reloadValueSet(Locale.GERMAN) + latestValueSet.first() shouldBe valueSetEN + + coVerify(exactly = 1) { + vaccinationServer.getVaccinationValueSets(Locale.GERMAN) + vaccinationServer.getVaccinationValueSets(Locale.ENGLISH) + } + } + } + + @Test + fun `use local storage if server returns nothing`() = runBlockingTest { + every { valueSetsStorage.vaccinationValueSet } returns valueSetDE + + createInstance().run { + reloadValueSet(Locale.GERMAN) + latestValueSet.first() shouldBe valueSetDE + + coVerify(exactly = 1) { + valueSetsStorage.vaccinationValueSet + vaccinationServer.getVaccinationValueSets(any()) + } + } + } + + @Test + fun `user errors will not crash the app`() = runBlockingTest { + val userError = Exception("User error") + coEvery { vaccinationServer.getVaccinationValueSets(any()) } throws userError + every { valueSetsStorage.vaccinationValueSet } throws userError + + createInstance().run { + reloadValueSet(Locale.GERMAN) + latestValueSet.first() shouldBe emptyValueSetDE + + coVerify(exactly = 1) { + valueSetsStorage.vaccinationValueSet + vaccinationServer.getVaccinationValueSets(Locale.GERMAN) + vaccinationServer.getVaccinationValueSets(Locale.ENGLISH) + } + } + } + + @Test + fun `clear() clears server and local storage`() { + createInstance().run { + clear() + + coVerify(exactly = 1) { + vaccinationServer.clear() + valueSetsStorage.clear() + } + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt index ec211990b19279cdc68d54302cbcbab0f21d022b..6aaf73ff21566a7612d6f288eb44178045e764f8 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt @@ -83,11 +83,31 @@ class VaccinationContainerTest : BaseTest() { @Test fun `mapping to user facing data - with valueset`() { + val vpItem = mockk<VaccinationValueSet.ValueSet.Item> { + every { key } returns "1119305005" + every { displayText } returns "Vaccine-Name" + } + + val mpItem = mockk<VaccinationValueSet.ValueSet.Item> { + every { key } returns "EU/1/21/1529" + every { displayText } returns "MedicalProduct-Name" + } + + val maItem = mockk<VaccinationValueSet.ValueSet.Item> { + every { key } returns "ORG-100001699" + every { displayText } returns "Manufactorer-Name" + } + + val vpMockk = mockk<VaccinationValueSet.ValueSet> { + every { items } returns listOf(vpItem, mpItem, maItem) + } + val valueSet = mockk<VaccinationValueSet> { - every { getDisplayText("ORG-100001699") } returns "Manufactorer-Name" - every { getDisplayText("EU/1/21/1529") } returns "MedicalProduct-Name" - every { getDisplayText("1119305005") } returns "Vaccine-Name" + every { vp } returns vpMockk + every { mp } returns vpMockk + every { ma } returns vpMockk } + testData.personAVac1Container.toVaccinationCertificate(valueSet).apply { firstName shouldBe "Andreas" lastName shouldBe "Astrá Eins"