diff --git a/.reuse/dep5 b/.reuse/dep5 index 25db19164e49c4b5d1f4f7853eb9c16a4310438b..ea34b9ea9c4ad56d31af452b3ae7f86e713850c4 100644 --- a/.reuse/dep5 +++ b/.reuse/dep5 @@ -58,4 +58,8 @@ License: Apache-2.0 Files: Corona-Warn-App/src/test/java/testhelpers/extensions/LiveDataTestUtil.kt Copyright: 2019 The Android Open Source Project +License: Apache-2.0 + +Files: Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base45Decoder.kt +Copyright: Copyright 2021 A-SIT Plus GmbH License: Apache-2.0 \ No newline at end of file diff --git a/Corona-Warn-App/build.gradle b/Corona-Warn-App/build.gradle index 863839fac762553415f2879edd75aaface42eeaa..ea075f218b9c71caa3f7efb719a3bb8ea9c8d574 100644 --- a/Corona-Warn-App/build.gradle +++ b/Corona-Warn-App/build.gradle @@ -436,4 +436,7 @@ dependencies { // ANIMATIONS implementation "com.airbnb.android:lottie:3.5.0" + + // HCert + implementation("com.upokecenter:cbor:4.4.1") } diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt index 43c90b8caf570ddcb58fa976c916431039d38209..915dd7d5ca342daa7132d3fa205e0774050e5c46 100644 --- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt +++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt @@ -32,6 +32,7 @@ import de.rki.coronawarnapp.util.bluetooth.BluetoothSupport import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool import de.rki.coronawarnapp.util.shortcuts.AppShortcutsHelper import de.rki.coronawarnapp.util.ui.SingleLiveEvent +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository import io.mockk.MockKAnnotations import io.mockk.Runs import io.mockk.every @@ -71,6 +72,7 @@ class HomeFragmentTest : BaseUITest() { @MockK lateinit var traceLocationOrganizerSettings: TraceLocationOrganizerSettings @MockK lateinit var timeStamper: TimeStamper @MockK lateinit var bluetoothSupport: BluetoothSupport + @MockK lateinit var vaccinationRepository: VaccinationRepository private lateinit var homeFragmentViewModel: HomeFragmentViewModel @@ -300,7 +302,8 @@ class HomeFragmentTest : BaseUITest() { tracingSettings = tracingSettings, traceLocationOrganizerSettings = traceLocationOrganizerSettings, timeStamper = timeStamper, - bluetoothSupport = bluetoothSupport + bluetoothSupport = bluetoothSupport, + vaccinationRepository = vaccinationRepository, ) ) diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragmentTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..4546bcb9f75957af312ac0744d3554b5d504ae27 --- /dev/null +++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragmentTest.kt @@ -0,0 +1,103 @@ +package de.rki.coronawarnapp.vaccination.ui.details + +import androidx.lifecycle.MutableLiveData +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.Module +import dagger.android.ContributesAndroidInjector +import de.rki.coronawarnapp.ui.Country +import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import org.joda.time.LocalDate +import org.joda.time.format.DateTimeFormat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import testhelpers.BaseUITest +import testhelpers.Screenshot +import testhelpers.SystemUIDemoModeRule +import testhelpers.launchFragment2 +import testhelpers.launchFragmentInContainer2 +import testhelpers.takeScreenshot +import tools.fastlane.screengrab.locale.LocaleTestRule + +@RunWith(AndroidJUnit4::class) +class VaccinationDetailsFragmentTest : BaseUITest() { + + @MockK lateinit var vaccinationDetailsViewModel: VaccinationDetailsViewModel + + @Rule + @JvmField + val localeTestRule = LocaleTestRule() + + @get:Rule + val systemUIDemoModeRule = SystemUIDemoModeRule() + + private val args = VaccinationDetailsFragmentArgs("vaccinationCertificateId").toBundle() + + @Before + fun setUp() { + MockKAnnotations.init(this, relaxed = true) + + setupMockViewModel( + object : VaccinationDetailsViewModel.Factory { + override fun create(certificateId: String): VaccinationDetailsViewModel = vaccinationDetailsViewModel + } + ) + } + + @Test + fun launch_fragment() { + launchFragment2<VaccinationDetailsFragment>(fragmentArgs = args) + } + + @Screenshot + @Test + fun capture_screenshot_complete() { + every { vaccinationDetailsViewModel.vaccinationCertificate } returns vaccinationDetailsData(true) + launchFragmentInContainer2<VaccinationDetailsFragment>(fragmentArgs = args) + takeScreenshot<VaccinationDetailsFragment>("complete") + } + + @Screenshot + @Test + fun capture_screenshot_incomplete() { + every { vaccinationDetailsViewModel.vaccinationCertificate } returns vaccinationDetailsData(false) + launchFragmentInContainer2<VaccinationDetailsFragment>(fragmentArgs = args) + takeScreenshot<VaccinationDetailsFragment>("incomplete") + } + + private fun vaccinationDetailsData(complete: Boolean): MutableLiveData<VaccinationDetails> { + val formatter = DateTimeFormat.forPattern("dd.MM.yyyy") + val mockCertificate = mockk<VaccinationCertificate>().apply { + every { firstName } returns "Max" + every { lastName } returns "Mustermann" + every { dateOfBirth } returns LocalDate.parse("01.02.1976", formatter) + every { vaccinatedAt } returns LocalDate.parse("01.05.2021", formatter) + every { vaccineName } returns "Comirnaty (mRNA)" + every { vaccineManufacturer } returns "BioNTech" + every { certificateIssuer } returns "Landratsamt Musterstadt" + every { certificateCountry } returns Country.DE + every { certificateId } returns "05930482748454836478695764787841" + } + + return MutableLiveData( + VaccinationDetails(mockCertificate, complete) + ) + } + + @After + fun tearDown() { + clearAllViewModels() + } +} + +@Module +abstract class VaccinationDetailsFragmentTestModule { + @ContributesAndroidInjector + abstract fun vaccinationDetailsFragment(): VaccinationDetailsFragment +} diff --git a/Corona-Warn-App/src/androidTest/java/testhelpers/FragmentTestModuleRegistrar.kt b/Corona-Warn-App/src/androidTest/java/testhelpers/FragmentTestModuleRegistrar.kt index 5f3cabbfcacfc6f5c44078480bc4dfcff0651fde..038042a5810542131a3ebe6ccb8f898fb435a572 100644 --- a/Corona-Warn-App/src/androidTest/java/testhelpers/FragmentTestModuleRegistrar.kt +++ b/Corona-Warn-App/src/androidTest/java/testhelpers/FragmentTestModuleRegistrar.kt @@ -36,6 +36,7 @@ import de.rki.coronawarnapp.ui.submission.SubmissionTestResultTestModule import de.rki.coronawarnapp.ui.submission.SubmissionTestResultTestNegativeModule import de.rki.coronawarnapp.ui.submission.SubmissionYourConsentFragmentTestModule import de.rki.coronawarnapp.ui.tracing.TracingDetailsFragmentTestTestModule +import de.rki.coronawarnapp.vaccination.ui.details.VaccinationDetailsFragmentTestModule @Module( includes = [ @@ -78,10 +79,12 @@ import de.rki.coronawarnapp.ui.tracing.TracingDetailsFragmentTestTestModule // Bugreporting DebugLogUploadTestModule::class, DebugLogTestModule::class, - // Event Registration + // Presence tracing CreateEventTestModule::class, TraceLocationsFragmentTestModule::class, - QrCodeDetailFragmentTestModule::class + QrCodeDetailFragmentTestModule::class, + // Vaccination Passport + VaccinationDetailsFragmentTestModule::class, ] ) class FragmentTestModuleRegistrar diff --git a/Corona-Warn-App/src/androidTest/java/testhelpers/ScreenShotter.kt b/Corona-Warn-App/src/androidTest/java/testhelpers/ScreenShotter.kt index b189086d8e18ecd94f81b614b2bbe28d9f7ea922..c5002121e6e91c55d63de8968a335686f51e531c 100644 --- a/Corona-Warn-App/src/androidTest/java/testhelpers/ScreenShotter.kt +++ b/Corona-Warn-App/src/androidTest/java/testhelpers/ScreenShotter.kt @@ -3,14 +3,11 @@ package testhelpers import android.app.Activity import android.graphics.Bitmap import android.os.Bundle -import android.provider.Settings import android.util.Log import androidx.annotation.StyleRes import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import androidx.test.espresso.ViewAction -import androidx.test.platform.app.InstrumentationRegistry -import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation import de.rki.coronawarnapp.R import tools.fastlane.screengrab.Screengrab import tools.fastlane.screengrab.ScreenshotCallback @@ -29,19 +26,11 @@ inline fun <reified T> takeScreenshot(suffix: String = "", delay: Long = SCREENS Thread.sleep(delay) val simpleName = T::class.simpleName val name = if (suffix.isEmpty()) simpleName else simpleName.plus("_$suffix") - - val contentResolver = getInstrumentation().targetContext.contentResolver - val testLabSetting = Settings.System.getString(contentResolver, "firebase.test.lab") - val androidStudioMode = InstrumentationRegistry.getArguments().getString("androidStudioMode") - if ("true" in listOf(testLabSetting, androidStudioMode)) { - Screengrab.screenshot( - name, - UiAutomatorScreenshotStrategy(), - SDCardCallback - ) - } else { - Screengrab.screenshot(name) - } + Screengrab.screenshot( + name, + UiAutomatorScreenshotStrategy(), + SDCardCallback + ) } /** diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt index b0e02bfce947e3a0776b4ed9eca95af2ca08d524..aef9b96ac5c4557b3cbc4075d09bec3c3967edb6 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/DebugOptionsFragment.kt @@ -71,6 +71,8 @@ class DebugOptionsFragment : Fragment(R.layout.fragment_test_debugoptions), Auto environmentCdnurlVerification.text = "Verification CDN:\n${state.urlVerification}" environmentUrlDatadonation.text = "DataDonation:\n${state.urlDataDonation}" environmentUrlLogUpload.text = "LogUpload:\n${state.urlLogUpload}" + environmentUrlVaccinationProof.text = "Vaccination Proof: \n${state.urlVaccinationProof}" + environmentCdnUrlVaccination.text = "Vaccination CDN: \n${state.urlVaccination}" environmentPubkeyCrowdnotifier.text = "CrowdNotifierPubKey:\n${state.pubKeyCrowdNotifier}" environmentPubkeyAppconfig.text = "AppConfigPubKey:\n${state.pubKeyAppConfig}" } diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/EnvironmentState.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/EnvironmentState.kt index c9b907793ab6812a252f519c71a9bee66af2387a..5705116888c4301f48f89d35e649f63a500fcf51 100644 --- a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/EnvironmentState.kt +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/test/debugoptions/ui/EnvironmentState.kt @@ -10,6 +10,8 @@ data class EnvironmentState( val urlVerification: String, val urlDataDonation: String, val urlLogUpload: String, + val urlVaccinationProof: String, + val urlVaccination: String, val pubKeyCrowdNotifier: String, val pubKeyAppConfig: String, ) { @@ -22,6 +24,8 @@ data class EnvironmentState( urlVerification = verificationCdnUrl, urlDataDonation = dataDonationCdnUrl, urlLogUpload = logUploadServerUrl, + urlVaccinationProof = vaccinationProofServerUrl, + urlVaccination = vaccinationCdnUrl, pubKeyCrowdNotifier = crowdNotifierPublicKey, pubKeyAppConfig = appConfigPublicKey, ) 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 5481b8257082656430d2bc3b06d3bab62401cc68..e4ef69df2799613253252a71600bed3fa7e91374 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 @@ -21,6 +21,7 @@ import de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment import de.rki.coronawarnapp.util.ui.SingleLiveEvent import de.rki.coronawarnapp.util.viewmodel.CWAViewModel import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory +import de.rki.coronawarnapp.vaccination.ui.VaccinationTestFragment class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() { @@ -41,6 +42,7 @@ class TestMenuFragmentViewModel @AssistedInject constructor() : CWAViewModel() { PresenceTracingTestFragment.MENU_ITEM, HomeTestCardsFragment.MENU_ITEM, CoronaTestTestFragment.MENU_ITEM, + VaccinationTestFragment.MENU_ITEM, ).let { MutableLiveData(it) } } val showTestScreenEvent = SingleLiveEvent<TestMenuItem>() 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 65aa2e6524a9f0a56a2ae9c9dff8d3e590c2e4fe..ebd69f5e08437398716bbcdb79c981aa71851d58 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 @@ -34,6 +34,8 @@ import de.rki.coronawarnapp.test.submission.ui.SubmissionTestFragment import de.rki.coronawarnapp.test.submission.ui.SubmissionTestFragmentModule import de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragment import de.rki.coronawarnapp.test.tasks.ui.TestTaskControllerFragmentModule +import de.rki.coronawarnapp.vaccination.ui.VaccinationTestFragment +import de.rki.coronawarnapp.vaccination.ui.VaccinationTestFragmentModule @Module abstract class MainActivityTestModule { @@ -85,4 +87,7 @@ abstract class MainActivityTestModule { @ContributesAndroidInjector(modules = [CoronaTestTestFragmentModule::class]) abstract fun coronaTest(): CoronaTestTestFragment + + @ContributesAndroidInjector(modules = [VaccinationTestFragmentModule::class]) + abstract fun vaccinationTest(): VaccinationTestFragment } diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragment.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..5bea439e2bebf790608a5251de80b9ea8440462c --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragment.kt @@ -0,0 +1,62 @@ +package de.rki.coronawarnapp.vaccination.ui + +import android.annotation.SuppressLint +import android.os.Bundle +import android.view.View +import androidx.fragment.app.Fragment +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.FragmentTestVaccinationBinding +import de.rki.coronawarnapp.test.menu.ui.TestMenuItem +import de.rki.coronawarnapp.util.di.AutoInject +import de.rki.coronawarnapp.util.ui.doNavigate +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 VaccinationTestFragment : Fragment(R.layout.fragment_test_vaccination), AutoInject { + + @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory + private val vm: VaccinationTestFragmentViewModel by cwaViewModels { viewModelFactory } + + private val binding: FragmentTestVaccinationBinding by viewBindingLazy() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + binding.openVaccinationList.setOnClickListener { + doNavigate( + VaccinationTestFragmentDirections.actionVaccinationTestFragmentToVaccinationListFragment( + "vaccinated-person-identifier" + ) + ) + } + + binding.openVaccinationDetailsIncomplete.setOnClickListener { + doNavigate( + VaccinationTestFragmentDirections + .actionVaccinationTestFragmentToVaccinationDetailsFragment( + "05930482748454836478695764787840" + ) + ) + } + + binding.openVaccinationDetailsComplete.setOnClickListener { + doNavigate( + VaccinationTestFragmentDirections + .actionVaccinationTestFragmentToVaccinationDetailsFragment( + "05930482748454836478695764787841" + ) + ) + } + } + + companion object { + val MENU_ITEM = TestMenuItem( + title = "Vaccination", + description = "View & Control vaccination related features.", + targetId = R.id.vaccinationTestFragment + ) + } +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragmentModule.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragmentModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..fb24039c60b2b4cfbea5e2a91e7d9672922eb806 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragmentModule.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.vaccination.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 VaccinationTestFragmentModule { + @Binds + @IntoMap + @CWAViewModelKey(VaccinationTestFragmentViewModel::class) + abstract fun testVaccinationFragment( + factory: VaccinationTestFragmentViewModel.Factory + ): CWAViewModelFactory<out CWAViewModel> +} diff --git a/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragmentViewModel.kt b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragmentViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..b9e4d4498f23b25c08f5e004052c04cab18e71de --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/java/de/rki/coronawarnapp/vaccination/ui/VaccinationTestFragmentViewModel.kt @@ -0,0 +1,15 @@ +package de.rki.coronawarnapp.vaccination.ui + +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.viewmodel.CWAViewModel +import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory + +class VaccinationTestFragmentViewModel @AssistedInject constructor( + dispatcherProvider: DispatcherProvider, +) : CWAViewModel(dispatcherProvider = dispatcherProvider) { + + @AssistedFactory + interface Factory : SimpleCWAViewModelFactory<VaccinationTestFragmentViewModel> +} diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml index 165e76a5873ac28d6015f1d63ec99802ff2c4a4f..25c7dff4c96bbbbbefa701de75382a3a262045fb 100644 --- a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_debugoptions.xml @@ -109,6 +109,22 @@ android:layout_marginTop="4dp" tools:text="LogUpload: ?" /> + <TextView + android:id="@+id/environment_url_vaccination_proof" + style="@style/TextAppearance.MaterialComponents.Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="VaccinationProof: ?" /> + + <TextView + android:id="@+id/environment_cdn_url_vaccination" + style="@style/TextAppearance.MaterialComponents.Caption" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="Vaccination: ?" /> + <TextView android:id="@+id/environment_pubkey_appconfig" style="@style/TextAppearance.MaterialComponents.Caption" diff --git a/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_vaccination.xml b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_vaccination.xml new file mode 100644 index 0000000000000000000000000000000000000000..ca0a9a4d55787d6726e829e5b67495be202c7985 --- /dev/null +++ b/Corona-Warn-App/src/deviceForTesters/res/layout/fragment_test_vaccination.xml @@ -0,0 +1,68 @@ +<?xml version="1.0" encoding="utf-8"?> + +<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:ignore="HardcodedText"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_margin="8dp" + android:orientation="vertical" + android:paddingBottom="32dp"> + + <androidx.constraintlayout.widget.ConstraintLayout + style="@style/Card" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_margin="@dimen/spacing_tiny" + android:layout_marginStart="8dp" + android:layout_marginEnd="8dp" + android:orientation="vertical"> + + <TextView + android:id="@+id/textView2" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:text="Vaccinations" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/open_vaccination_list" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="Vaccination List" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/textView2" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/open_vaccination_details_complete" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="Vaccination details - complete" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/open_vaccination_list" /> + + <com.google.android.material.button.MaterialButton + android:id="@+id/open_vaccination_details_incomplete" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="10dp" + android:text="Vaccination details - incomplete" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/open_vaccination_details_complete" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + + + </LinearLayout> +</androidx.core.widget.NestedScrollView> 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 fcb6ad2366b5adc63ef6954a1d6f37083b4163c6..bbb72a602388b89f6cb91afe7185c051017b0b28 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 @@ -55,6 +55,9 @@ <action android:id="@+id/action_test_menu_fragment_to_coronaTestTestFragment" app:destination="@id/coronaTestTestFragment" /> + <action + android:id="@+id/action_test_menu_fragment_to_vaccinationTestFragment" + app:destination="@id/vaccinationTestFragment" /> </fragment> <fragment @@ -160,6 +163,42 @@ <fragment android:id="@+id/coronaTestTestFragment" android:name="de.rki.coronawarnapp.test.coronatest.ui.CoronaTestTestFragment" - tools:layout="@layout/fragment_test_coronatest" - android:label="CoronaTestTestFragment" /> + android:label="CoronaTestTestFragment" + tools:layout="@layout/fragment_test_coronatest" /> + <fragment + android:id="@+id/vaccinationTestFragment" + android:name="de.rki.coronawarnapp.vaccination.ui.VaccinationTestFragment" + android:label="VaccinationTestFragment" + tools:layout="@layout/fragment_test_vaccination" > + + <action + android:id="@+id/action_vaccinationTestFragment_to_vaccinationDetailsFragment" + app:destination="@id/vaccinationDetailsFragment" /> + <action + android:id="@+id/action_vaccinationTestFragment_to_vaccinationListFragment" + app:destination="@id/vaccinationListFragment" /> + </fragment> + + <fragment + android:id="@+id/vaccinationListFragment" + android:name="de.rki.coronawarnapp.vaccination.ui.list.VaccinationListFragment" + android:label="fragment_vaccination_list" + tools:layout="@layout/fragment_vaccination_list"> + <argument + android:name="vaccinatedPersonId" + app:argType="string" /> + <action + android:id="@+id/action_vaccinationListFragment_to_vaccinationDetailsFragment" + app:destination="@id/vaccinationDetailsFragment" /> + </fragment> + + <fragment + android:id="@+id/vaccinationDetailsFragment" + android:name="de.rki.coronawarnapp.vaccination.ui.details.VaccinationDetailsFragment" + android:label="fragment_vaccination_details" + tools:layout="@layout/fragment_vaccination_details"> + <argument + android:name="vaccinationCertificateId" + app:argType="string" /> + </fragment> </navigation> diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt index 7adcb05ae735cdca27b6cbf15f7c90be8a371b80..0145584b0d9c32f272b62e77a1159ff96c643d17 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/CoronaWarnApplication.kt @@ -40,6 +40,7 @@ import de.rki.coronawarnapp.util.device.ForegroundState import de.rki.coronawarnapp.util.di.AppInjector import de.rki.coronawarnapp.util.di.ApplicationComponent import de.rki.coronawarnapp.util.hasAPILevel +import de.rki.coronawarnapp.vaccination.core.execution.VaccinationUpdateScheduler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @@ -78,6 +79,7 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { @Inject lateinit var raTestResultScheduler: RAResultScheduler @Inject lateinit var pcrTestResultAvailableNotificationService: PCRTestResultAvailableNotificationService @Inject lateinit var raTestResultAvailableNotificationService: RATTestResultAvailableNotificationService + @Inject lateinit var vaccinationUpdateScheduler: VaccinationUpdateScheduler @LogHistoryTree @Inject lateinit var rollingLogHistory: Timber.Tree @@ -131,6 +133,9 @@ class CoronaWarnApplication : Application(), HasAndroidInjector { pcrTestResultAvailableNotificationService.setup() raTestResultAvailableNotificationService.setup() + Timber.v("Setting up vaccination data update scheduler.") + vaccinationUpdateScheduler.setup() + deviceTimeHandler.launch() configChangeDetector.launch() riskLevelChangeDetector.launch() diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/ui/ErrorDialog.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/ui/ErrorDialog.kt index e17a6f67242c845a2f5c6bef145ef5e04e334215..dbd4e65bd6e012ebd5efadd2e154f5a5ede65d1a 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/ui/ErrorDialog.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/bugreporting/ui/ErrorDialog.kt @@ -41,21 +41,22 @@ private fun MaterialAlertDialogBuilder.setMessageView( setView(textView) } -fun Throwable.toErrorDialogBuilder(context: Context) = MaterialAlertDialogBuilder(context).apply { - val error = this@toErrorDialogBuilder - val humanReadable = error.tryHumanReadableError(context) - - setTitle(humanReadable.title ?: context.getString(R.string.errors_generic_headline_short)) - setMessageView(humanReadable.description, textHasLinks = true) - - setPositiveButton(R.string.errors_generic_button_positive) { _, _ -> } - - setNeutralButton(R.string.errors_generic_button_negative) { _, _ -> - MaterialAlertDialogBuilder(context).apply { - setMessageView( - error.toString() + "\n\n" + error.stackTraceToString(), - textHasLinks = false - ) - }.show() +fun Throwable.toErrorDialogBuilder(context: Context) = + MaterialAlertDialogBuilder(context).apply { + val error = this@toErrorDialogBuilder + val humanReadable = error.tryHumanReadableError(context) + + setTitle(humanReadable.title ?: context.getString(R.string.errors_generic_headline_short)) + setMessageView(humanReadable.description, textHasLinks = true) + + setPositiveButton(R.string.errors_generic_button_positive) { _, _ -> } + + setNeutralButton(R.string.errors_generic_button_negative) { _, _ -> + MaterialAlertDialogBuilder(context).apply { + setMessageView( + error.toString() + "\n\n" + error.stackTraceToString(), + textHasLinks = false + ) + }.show() + } } -} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt index b99710e6d4a6bd5a07a5bb7d08931aecf4594746..3a81cb926a78ecec4b9f6e8333b31fb15e075770 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentModule.kt @@ -5,6 +5,7 @@ import de.rki.coronawarnapp.environment.bugreporting.BugReportingServerModule import de.rki.coronawarnapp.environment.datadonation.DataDonationCDNModule import de.rki.coronawarnapp.environment.download.DownloadCDNModule import de.rki.coronawarnapp.environment.submission.SubmissionCDNModule +import de.rki.coronawarnapp.environment.vaccination.VaccinationCertificateUrlModule import de.rki.coronawarnapp.environment.verification.VerificationCDNModule @Module( @@ -13,7 +14,8 @@ import de.rki.coronawarnapp.environment.verification.VerificationCDNModule SubmissionCDNModule::class, VerificationCDNModule::class, DataDonationCDNModule::class, - BugReportingServerModule::class + BugReportingServerModule::class, + VaccinationCertificateUrlModule::class ] ) class EnvironmentModule diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt index d44e8e88ef378eaae6e78f7c4e73591be7c08e93..8f11f07e0bd9d8e2564181adad909ed9ce6ad294 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/EnvironmentSetup.kt @@ -12,6 +12,8 @@ import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.LOG_UPLOAD import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.SAFETYNET_API_KEY import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.SUBMISSION import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.USE_EUR_KEY_PKGS +import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.VACCINATION_PROOF +import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.VACCINATION_VALUE import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.VERIFICATION import de.rki.coronawarnapp.environment.EnvironmentSetup.EnvKey.VERIFICATION_KEYS import de.rki.coronawarnapp.environment.EnvironmentSetup.Type.Companion.toEnvironmentType @@ -36,6 +38,8 @@ class EnvironmentSetup @Inject constructor( VERIFICATION_KEYS("PUB_KEYS_SIGNATURE_VERIFICATION"), DATA_DONATION("DATA_DONATION_CDN_URL"), LOG_UPLOAD("LOG_UPLOAD_SERVER_URL"), + VACCINATION_PROOF("VACCINATION_PROOF_SERVER_URL"), + VACCINATION_VALUE("VACCINATION_CDN_URL"), SAFETYNET_API_KEY("SAFETYNET_API_KEY"), CROWD_NOTIFIER_PUBLIC_KEY("CROWD_NOTIFIER_PUBLIC_KEY") } @@ -135,6 +139,12 @@ class EnvironmentSetup @Inject constructor( val logUploadServerUrl: String get() = getEnvironmentValue(LOG_UPLOAD).asString + val vaccinationProofServerUrl: String + get() = getEnvironmentValue(VACCINATION_PROOF).asString + + val vaccinationCdnUrl: String + get() = getEnvironmentValue(VACCINATION_VALUE).asString + companion object { private const val PKEY_CURRENT_ENVINROMENT = "environment.current" } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateCDNUrl.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateCDNUrl.kt new file mode 100644 index 0000000000000000000000000000000000000000..5740f4bf0ca8e587642020e693a8754b43e91b09 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateCDNUrl.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.environment.vaccination + +import javax.inject.Qualifier + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class VaccinationCertificateCDNUrl diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateProofServerUrl.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateProofServerUrl.kt new file mode 100644 index 0000000000000000000000000000000000000000..c8ede341fd2906c7188da2885814378698917fff --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateProofServerUrl.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.environment.vaccination + +import javax.inject.Qualifier + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class VaccinationCertificateProofServerUrl diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateUrlModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateUrlModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..bb368376758cf7e747e2a1f4008fe8250def894f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/environment/vaccination/VaccinationCertificateUrlModule.kt @@ -0,0 +1,23 @@ +package de.rki.coronawarnapp.environment.vaccination + +import dagger.Module +import dagger.Provides +import dagger.Reusable +import de.rki.coronawarnapp.environment.BaseEnvironmentModule +import de.rki.coronawarnapp.environment.EnvironmentSetup + +@Module +class VaccinationCertificateUrlModule : BaseEnvironmentModule() { + + @Reusable + @VaccinationCertificateProofServerUrl + @Provides + fun provideVaccinationProofUrl(environmentSetup: EnvironmentSetup): String = + requireValidUrl(environmentSetup.vaccinationProofServerUrl) + + @Reusable + @VaccinationCertificateCDNUrl + @Provides + fun provideVaccinationValueSetUrl(environmentSetup: EnvironmentSetup): String = + requireValidUrl(environmentSetup.vaccinationCdnUrl) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/checkins/qrcode/QRCodeUriParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/checkins/qrcode/QRCodeUriParser.kt index 75e77f4281cd5fd60c40c62d5d3a848c2e8fa2d2..1a0d7c6192f98874c2619c2d6c40370743d68369 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/checkins/qrcode/QRCodeUriParser.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/presencetracing/checkins/qrcode/QRCodeUriParser.kt @@ -4,9 +4,9 @@ import com.google.common.io.BaseEncoding import dagger.Reusable import de.rki.coronawarnapp.appconfig.AppConfigProvider import de.rki.coronawarnapp.server.protocols.internal.pt.TraceLocationOuterClass.QRCodePayload -import de.rki.coronawarnapp.server.protocols.internal.v2.PresenceTracingParametersOuterClass.PresenceTracingQRCodeDescriptorOrBuilder import de.rki.coronawarnapp.server.protocols.internal.v2.PresenceTracingParametersOuterClass.PresenceTracingQRCodeDescriptor.PayloadEncoding -import de.rki.coronawarnapp.util.decodeBase32 +import de.rki.coronawarnapp.server.protocols.internal.v2.PresenceTracingParametersOuterClass.PresenceTracingQRCodeDescriptorOrBuilder +import de.rki.coronawarnapp.util.encoding.decodeBase32 import okio.ByteString.Companion.toByteString import timber.log.Timber import java.net.URI diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityModule.kt index 30e1176b6710fb4b85a68de0738a3a18fd41eb14..b81d1a5fc8e55cf43f5b8db0c0788af8fcc918d4 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityModule.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/MainActivityModule.kt @@ -9,13 +9,13 @@ import de.rki.coronawarnapp.release.NewReleaseInfoFragment import de.rki.coronawarnapp.release.NewReleaseInfoFragmentModule import de.rki.coronawarnapp.tracing.ui.details.TracingDetailsFragmentModule import de.rki.coronawarnapp.ui.coronatest.rat.profile.RATProfileUIModule -import de.rki.coronawarnapp.ui.presencetracing.PresenceTracingUIModule import de.rki.coronawarnapp.ui.information.InformationFragmentModule import de.rki.coronawarnapp.ui.interoperability.InteroperabilityConfigurationFragment import de.rki.coronawarnapp.ui.interoperability.InteroperabilityConfigurationFragmentModule import de.rki.coronawarnapp.ui.main.home.HomeFragmentModule import de.rki.coronawarnapp.ui.onboarding.OnboardingDeltaAnalyticsModule import de.rki.coronawarnapp.ui.onboarding.OnboardingDeltaInteroperabilityModule +import de.rki.coronawarnapp.ui.presencetracing.PresenceTracingUIModule import de.rki.coronawarnapp.ui.settings.SettingFragmentsModule import de.rki.coronawarnapp.ui.settings.SettingsResetFragment import de.rki.coronawarnapp.ui.settings.SettingsResetModule @@ -23,6 +23,7 @@ import de.rki.coronawarnapp.ui.submission.viewmodel.SubmissionFragmentModule import de.rki.coronawarnapp.util.viewmodel.CWAViewModel import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactory import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey +import de.rki.coronawarnapp.vaccination.ui.VaccinationUIModule @Module( includes = [ @@ -37,6 +38,7 @@ import de.rki.coronawarnapp.util.viewmodel.CWAViewModelKey AnalyticsUIModule::class, PresenceTracingUIModule::class, RATProfileUIModule::class, + VaccinationUIModule::class, ] ) abstract class MainActivityModule { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeAdapter.kt index 66c65f4a5dfdebc30433357ceb8d9d63408fc737..abd463895d6662bb2066c423c8e3ecdf9350ac3c 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeAdapter.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeAdapter.kt @@ -38,6 +38,9 @@ import de.rki.coronawarnapp.util.lists.modular.mods.DataBinderMod import de.rki.coronawarnapp.util.lists.modular.mods.SavedStateMod import de.rki.coronawarnapp.util.lists.modular.mods.StableIdMod import de.rki.coronawarnapp.util.lists.modular.mods.TypedVHCreatorMod +import de.rki.coronawarnapp.vaccination.ui.homecard.CompleteVaccinationHomeCard +import de.rki.coronawarnapp.vaccination.ui.homecard.CreateVaccinationHomeCard +import de.rki.coronawarnapp.vaccination.ui.homecard.IncompleteVaccinationHomeCard class HomeAdapter : ModularAdapter<HomeAdapter.HomeItemVH<HomeItem, ViewBinding>>(), @@ -76,6 +79,13 @@ class HomeAdapter : TypedVHCreatorMod({ data[it] is RapidTestOutdatedCard.Item }) { RapidTestOutdatedCard(it) }, TypedVHCreatorMod({ data[it] is TestUnregisteredCard.Item }) { TestUnregisteredCard(it) }, TypedVHCreatorMod({ data[it] is StatisticsHomeCard.Item }) { StatisticsHomeCard(it) }, + TypedVHCreatorMod({ data[it] is CreateVaccinationHomeCard.Item }) { CreateVaccinationHomeCard(it) }, + TypedVHCreatorMod({ data[it] is CompleteVaccinationHomeCard.Item }) { + CompleteVaccinationHomeCard(it) + }, + TypedVHCreatorMod({ data[it] is IncompleteVaccinationHomeCard.Item }) { + IncompleteVaccinationHomeCard(it) + }, SavedStateMod<HomeItemVH<HomeItem, ViewBinding>>() // For statistics card scroll position ) ) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt index 4b3d0497af8d2959d1aa4d50c07b3633c161d767..5ac327670709444fa261f2e6c6f05ef30ba7a0a3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt @@ -29,6 +29,7 @@ import de.rki.coronawarnapp.util.ui.observe2 import de.rki.coronawarnapp.util.ui.viewBindingLazy import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider import de.rki.coronawarnapp.util.viewmodel.cwaViewModels +import de.rki.coronawarnapp.vaccination.ui.list.VaccinationListFragment import javax.inject.Inject /** @@ -116,6 +117,9 @@ class HomeFragment : Fragment(R.layout.home_fragment_layout), AutoInject { viewModel.tracingExplanationWasShown() } } + is HomeFragmentEvents.GoToVaccinationList -> findNavController().navigate( + VaccinationListFragment.navigationUri(event.vaccinatedPersonIdentifier) + ) } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentEvents.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentEvents.kt index 4573361022e31b8bc36f27279c2aff8ad07573f9..03805dbf9383c07ae883ff53f57da5f027137462 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentEvents.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentEvents.kt @@ -8,7 +8,9 @@ sealed class HomeFragmentEvents { object ShowErrorResetDialog : HomeFragmentEvents() + object GoToStatisticsExplanation : HomeFragmentEvents() + data class ShowDeleteTestDialog(val type: CoronaTest.Type) : HomeFragmentEvents() - object GoToStatisticsExplanation : HomeFragmentEvents() + data class GoToVaccinationList(val vaccinatedPersonIdentifier: String) : HomeFragmentEvents() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt index 2ab3ce80e760ff68cba51e7909ff93506910d316..cb5e2c0458ce034d24aa54891b60b592bd331bd6 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt @@ -67,11 +67,16 @@ import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.bluetooth.BluetoothSupport import de.rki.coronawarnapp.util.coroutine.DispatcherProvider import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool +import de.rki.coronawarnapp.util.flow.combine import de.rki.coronawarnapp.util.shortcuts.AppShortcutsHelper import de.rki.coronawarnapp.util.ui.SingleLiveEvent import de.rki.coronawarnapp.util.viewmodel.CWAViewModel import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory -import kotlinx.coroutines.flow.combine +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import de.rki.coronawarnapp.vaccination.ui.homecard.CompleteVaccinationHomeCard +import de.rki.coronawarnapp.vaccination.ui.homecard.CreateVaccinationHomeCard +import de.rki.coronawarnapp.vaccination.ui.homecard.IncompleteVaccinationHomeCard import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -94,6 +99,7 @@ class HomeFragmentViewModel @AssistedInject constructor( private val traceLocationOrganizerSettings: TraceLocationOrganizerSettings, private val timeStamper: TimeStamper, private val bluetoothSupport: BluetoothSupport, + private val vaccinationRepository: VaccinationRepository, ) : CWAViewModel(dispatcherProvider = dispatcherProvider) { private val tracingStateProvider by lazy { tracingStateProviderFactory.create(isDetailsMode = false) } @@ -285,8 +291,9 @@ class HomeFragmentViewModel @AssistedInject constructor( coronaTestRepository.latestPCRT, coronaTestRepository.latestRAT, statisticsProvider.current.distinctUntilChanged(), - appConfigProvider.currentConfig.map { it.coronaTestParameters }.distinctUntilChanged() - ) { tracingItem, testPCR, testRAT, statsData, coronaTestParameters -> + appConfigProvider.currentConfig.map { it.coronaTestParameters }.distinctUntilChanged(), + vaccinationRepository.vaccinationInfos + ) { tracingItem, testPCR, testRAT, statsData, coronaTestParameters, vaccinatedPersons -> val statePCR = testPCR.toSubmissionState() val stateRAT = testRAT.toSubmissionState(timeStamper.nowUTC, coronaTestParameters) mutableListOf<HomeItem>().apply { @@ -300,6 +307,28 @@ class HomeFragmentViewModel @AssistedInject constructor( else -> add(tracingItem) } + vaccinatedPersons.forEach { vaccinatedPerson -> + val card = when (vaccinatedPerson.vaccinationStatus) { + VaccinatedPerson.Status.COMPLETE -> CompleteVaccinationHomeCard.Item( + vaccinatedPerson = vaccinatedPerson, + onClickAction = { + popupEvents.postValue( + HomeFragmentEvents.GoToVaccinationList(vaccinatedPerson.identifier.code) + ) + } + ) + VaccinatedPerson.Status.INCOMPLETE -> IncompleteVaccinationHomeCard.Item( + vaccinatedPerson = vaccinatedPerson, + onClickAction = { + popupEvents.postValue( + HomeFragmentEvents.GoToVaccinationList(vaccinatedPerson.identifier.code) + ) + } + ) + } + add(card) + } + if (bluetoothSupport.isAdvertisingSupported == false) { add( IncompatibleCard.Item( @@ -334,6 +363,14 @@ class HomeFragmentViewModel @AssistedInject constructor( } } + add( + CreateVaccinationHomeCard.Item( + onClickAction = { + routeToScreen.postValue(HomeFragmentDirections.actionMainFragmentToVaccinationNavGraph()) + } + ) + ) + if (statsData.isDataAvailable) { add( StatisticsHomeCard.Item( diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/presencetracing/attendee/scan/ScanCheckInQrCodeFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/presencetracing/attendee/scan/ScanCheckInQrCodeFragment.kt index 767df359cbdf638d3a242a3778d569878863c953..47ddf2f674d5fc0ba24113a654e00d04e7677198 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/presencetracing/attendee/scan/ScanCheckInQrCodeFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/presencetracing/attendee/scan/ScanCheckInQrCodeFragment.kt @@ -12,7 +12,7 @@ import com.google.android.material.transition.MaterialContainerTransform import com.google.zxing.BarcodeFormat import com.journeyapps.barcodescanner.DefaultDecoderFactory import de.rki.coronawarnapp.R -import de.rki.coronawarnapp.databinding.FragmentScanCheckInQrCodeBinding +import de.rki.coronawarnapp.databinding.FragmentScanQrCodeBinding import de.rki.coronawarnapp.ui.presencetracing.attendee.checkins.CheckInsFragment import de.rki.coronawarnapp.util.DialogHelper import de.rki.coronawarnapp.util.di.AutoInject @@ -26,13 +26,13 @@ import timber.log.Timber import javax.inject.Inject class ScanCheckInQrCodeFragment : - Fragment(R.layout.fragment_scan_check_in_qr_code), + Fragment(R.layout.fragment_scan_qr_code), AutoInject { @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory private val viewModel: ScanCheckInQrCodeViewModel by cwaViewModels { viewModelFactory } - private val binding: FragmentScanCheckInQrCodeBinding by viewBindingLazy() + private val binding: FragmentScanQrCodeBinding by viewBindingLazy() private var showsPermissionDialog = false override fun onCreate(savedInstanceState: Bundle?) { @@ -47,13 +47,13 @@ class ScanCheckInQrCodeFragment : savedInstanceState: Bundle? ) { with(binding) { - checkInQrCodeScanTorch.setOnCheckedChangeListener { _, isChecked -> - binding.checkInQrCodeScanPreview.setTorch(isChecked) + qrCodeScanTorch.setOnCheckedChangeListener { _, isChecked -> + binding.qrCodeScanPreview.setTorch(isChecked) } - checkInQrCodeScanToolbar.setNavigationOnClickListener { viewModel.onNavigateUp() } - checkInQrCodeScanPreview.decoderFactory = DefaultDecoderFactory(listOf(BarcodeFormat.QR_CODE)) - checkInQrCodeScanViewfinderView.setCameraPreview(binding.checkInQrCodeScanPreview) + qrCodeScanToolbar.setNavigationOnClickListener { viewModel.onNavigateUp() } + qrCodeScanPreview.decoderFactory = DefaultDecoderFactory(listOf(BarcodeFormat.QR_CODE)) + qrCodeScanViewfinderView.setCameraPreview(binding.qrCodeScanPreview) } viewModel.events.observe2(this) { navEvent -> @@ -76,7 +76,7 @@ class ScanCheckInQrCodeFragment : super.onResume() binding.checkInQrCodeScanContainer.sendAccessibilityEvent(TYPE_ANNOUNCEMENT) if (CameraPermissionHelper.hasCameraPermission(requireActivity())) { - binding.checkInQrCodeScanPreview.resume() + binding.qrCodeScanPreview.resume() startDecode() return } @@ -104,7 +104,7 @@ class ScanCheckInQrCodeFragment : } } - private fun startDecode() = binding.checkInQrCodeScanPreview + private fun startDecode() = binding.qrCodeScanPreview .decodeSingle { barcodeResult -> viewModel.onScanResult(barcodeResult) } @@ -156,7 +156,7 @@ class ScanCheckInQrCodeFragment : override fun onPause() { super.onPause() - binding.checkInQrCodeScanPreview.pause() + binding.qrCodeScanPreview.pause() } companion object { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/presencetracing/organizer/details/QrCodeDetailFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/presencetracing/organizer/details/QrCodeDetailFragment.kt index 3dc3137e1e4bb77feb965150d3a7d7b7a4bd4883..dc93c0c93431a4863d07c6507e4804a61394fbb7 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/presencetracing/organizer/details/QrCodeDetailFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/presencetracing/organizer/details/QrCodeDetailFragment.kt @@ -10,11 +10,11 @@ import androidx.core.view.isGone import androidx.fragment.app.Fragment import androidx.navigation.fragment.navArgs import com.google.android.material.appbar.AppBarLayout -import com.google.android.material.appbar.AppBarLayout.OnOffsetChangedListener import com.google.android.material.transition.MaterialContainerTransform import com.google.android.material.transition.MaterialSharedAxis import de.rki.coronawarnapp.R import de.rki.coronawarnapp.databinding.TraceLocationOrganizerQrCodeDetailFragmentBinding +import de.rki.coronawarnapp.ui.view.onOffsetChange import de.rki.coronawarnapp.util.ContextExtensions.getDrawableCompat import de.rki.coronawarnapp.util.di.AutoInject import de.rki.coronawarnapp.util.ui.doNavigate @@ -24,7 +24,6 @@ import de.rki.coronawarnapp.util.ui.viewBindingLazy import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider import de.rki.coronawarnapp.util.viewmodel.cwaViewModelsAssisted import javax.inject.Inject -import kotlin.math.abs class QrCodeDetailFragment : Fragment(R.layout.trace_location_organizer_qr_code_detail_fragment), AutoInject { @@ -55,16 +54,10 @@ class QrCodeDetailFragment : Fragment(R.layout.trace_location_organizer_qr_code_ setToolbarOverlay() binding.apply { - appBarLayout.addOnOffsetChangedListener( - OnOffsetChangedListener { appBarLayout, verticalOffset -> - title.alpha = ( - 1.0f - abs(verticalOffset / (appBarLayout.totalScrollRange.toFloat() * 0.5f)) - ) - subtitle.alpha = ( - 1.0f - abs(verticalOffset / (appBarLayout.totalScrollRange.toFloat() * 0.7f)) - ) - } - ) + appBarLayout.onOffsetChange { titleAlpha, subtitleAlpha -> + title.alpha = titleAlpha + subtitle.alpha = subtitleAlpha + } toolbar.apply { navigationIcon = context.getDrawableCompat(R.drawable.ic_close_white) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/view/AppBarLayout.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/view/AppBarLayout.kt new file mode 100644 index 0000000000000000000000000000000000000000..bc858fad4dd3d4eacb7bf85191b6560c5f38f7f4 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/view/AppBarLayout.kt @@ -0,0 +1,19 @@ +package de.rki.coronawarnapp.ui.view + +import com.google.android.material.appbar.AppBarLayout +import kotlin.math.abs + +/** + * Returns title and subtitle alpha calculations on the current offset change + */ +fun AppBarLayout.onOffsetChange(onChange: (Float, Float) -> Unit) { + addOnOffsetChangedListener( + AppBarLayout.OnOffsetChangedListener { appBarLayout, verticalOffset -> + val titleAlpha = + 1.0f - abs(verticalOffset / (appBarLayout.totalScrollRange.toFloat() * 0.5f)) + val subtitleAlpha = + 1.0f - abs(verticalOffset / (appBarLayout.totalScrollRange.toFloat() * 0.7f)) + onChange(titleAlpha, subtitleAlpha) + } + ) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt index 60b57e29d0d30c9bf49fd6b4bd21946b4f7411c9..7392f5fe704fc4f757beaf782be685aef59b4293 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/TimeAndDateExtensions.kt @@ -16,12 +16,15 @@ import java.util.Date import java.util.TimeZone import java.util.concurrent.TimeUnit +@Suppress("TooManyFunctions") object TimeAndDateExtensions { private const val MS_TO_DAYS = (1000 * 60 * 60 * 24) private const val MS_TO_HOURS = (1000 * 60 * 60) private const val MS_TO_SECONDS = 1000 + private val dayFormatter = DateTimeFormat.forPattern("dd.MM.yyyy") + fun getCurrentHourUTC(): Int = DateTime(Instant.now(), DateTimeZone.UTC).hourOfDay().get() fun Date.toServerFormat(): String = @@ -104,6 +107,16 @@ object TimeAndDateExtensions { fun Instant.toUserTimeZone() = this.toDateTime(DateTimeZone.forTimeZone(TimeZone.getDefault())) fun Instant.toLocalDateUserTz(): LocalDate = this.toUserTimeZone().toLocalDate() + + /** + * Returns a readable date String with the format "dd.MM.yyyy" like 23.05.1989 of an Instant + */ + fun Instant.toDayFormat() = toString(dayFormatter) + + /** + * Returns a readable date String with the format "dd.MM.yyyy" like 23.05.1989 of a LocalDate + */ + fun LocalDate.toDayFormat() = toString(dayFormatter) } typealias HourInterval = Long diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt index 1728ab9379762422fc2400609eeb2de1c65cb24f..471f0e5866c704de32490da16012ea6ad77b2081 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/di/ApplicationComponent.kt @@ -45,6 +45,7 @@ import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool import de.rki.coronawarnapp.util.security.SecurityModule import de.rki.coronawarnapp.util.serialization.SerializationModule import de.rki.coronawarnapp.util.worker.WorkerBinder +import de.rki.coronawarnapp.vaccination.core.VaccinationModule import javax.inject.Singleton @Singleton @@ -79,6 +80,7 @@ import javax.inject.Singleton SecurityModule::class, PresenceTracingModule::class, CoronaTestModule::class, + VaccinationModule::class, ] ) interface ApplicationComponent : AndroidInjector<CoronaWarnApplication> { diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/Base32.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base32Extensions.kt similarity index 95% rename from Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/Base32.kt rename to Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base32Extensions.kt index cee561c1e4e1713f25cf95fbee9bc3607f247fa0..2027ed7dcbb1ebd54a633f2451ef1a76d9e8cfa6 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/Base32.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base32Extensions.kt @@ -1,4 +1,4 @@ -package de.rki.coronawarnapp.util +package de.rki.coronawarnapp.util.encoding import com.google.common.io.BaseEncoding import okio.ByteString diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base45Decoder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base45Decoder.kt new file mode 100644 index 0000000000000000000000000000000000000000..39320eb27363a5bc8ecd9368316c8bde1d3042f7 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base45Decoder.kt @@ -0,0 +1,90 @@ +/* + Copyright 2021 A-SIT Plus GmbH + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + Modifications Copyright (c) 2021 SAP SE or an SAP affiliate company. +*/ +package de.rki.coronawarnapp.util.encoding + +import java.math.BigInteger + +/** + * Based on + * https://github.com/ehn-digital-green-development/hcert-kotlin/blob/23203fbb71f53524ee643a9df116264f87b5b32a/src/main/kotlin/ehn/techiop/hcert/kotlin/chain/common/Base45Encoder.kt + */ +@OptIn(ExperimentalUnsignedTypes::class) +object Base45Decoder { + private const val alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:" + private val int45 = BigInteger.valueOf(45) + private val int256 = BigInteger.valueOf(256) + + fun encode(input: ByteArray) = + input.asSequence() + .map { it.toUByte() } + .chunked(2) + .map(this::encodeTwoCharsPadded) + .flatten() + .joinToString(separator = "") + + private fun encodeTwoCharsPadded(input: List<UByte>): List<Char> { + val result = encodeTwoChars(input).toMutableList() + when (input.size) { + 1 -> if (result.size < 2) result += '0' + 2 -> while (result.size < 3) result += '0' + } + return result + } + + private fun encodeTwoChars(list: List<UByte>) = + generateSequenceByDivRem(toTwoCharValue(list), 45) + .map { alphabet[it] }.toList() + + private fun toTwoCharValue(list: List<UByte>) = + list.reversed().foldIndexed(0L) { index, acc, element -> + pow(int256, index) * element.toShort() + acc + } + + fun decode(input: String) = + input.chunked(3).map(this::decodeThreeCharsPadded) + .flatten().map { it.toByte() }.toByteArray() + + private fun decodeThreeCharsPadded(input: String): List<UByte> { + val result = decodeThreeChars(input).toMutableList() + when (input.length) { + 3 -> while (result.size < 2) result += 0U + } + return result.reversed() + } + + private fun decodeThreeChars(list: String) = + generateSequenceByDivRem(fromThreeCharValue(list), 256) + .map { it.toUByte() }.toList() + + private fun fromThreeCharValue(list: String): Long { + return list.foldIndexed( + 0L, + { index, acc: Long, element -> + if (!alphabet.contains(element)) + throw IllegalArgumentException(element.toString()) + pow(int45, index) * alphabet.indexOf(element) + acc + } + ) + } + + private fun generateSequenceByDivRem(seed: Long, divisor: Int) = + generateSequence(seed) { if (it >= divisor) it.div(divisor) else null } + .map { it.rem(divisor).toInt() } + + private fun pow(base: BigInteger, exp: Int) = base.pow(exp).toLong() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base45Extensions.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base45Extensions.kt new file mode 100644 index 0000000000000000000000000000000000000000..aeddd2010e19329da9e2e337a899a1a5dccd90ed --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/encoding/Base45Extensions.kt @@ -0,0 +1,16 @@ +package de.rki.coronawarnapp.util.encoding + +import okio.ByteString +import okio.ByteString.Companion.toByteString + +/** + * Decodes [String] into [ByteString] using Base45 decoder + * @return [ByteString] + */ +fun String.decodeBase45(): ByteString = Base45Decoder.decode(this).toByteString() + +/** + * Encodes [ByteString] into base45 [String] + * @return [String] + */ +fun ByteString.base45(): String = Base45Decoder.encode(this.toByteArray()) 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 e41065b3c8ee61e11a10d85ea24fdbf3c0ed4fff..1a35bb0361d6c87e14447419bebb714fb747f23b 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 @@ -6,9 +6,11 @@ import dagger.Module import dagger.Provides import dagger.Reusable import de.rki.coronawarnapp.util.serialization.adapter.ByteArrayAdapter +import de.rki.coronawarnapp.util.serialization.adapter.ByteStringBase64Adapter import de.rki.coronawarnapp.util.serialization.adapter.DurationAdapter import de.rki.coronawarnapp.util.serialization.adapter.InstantAdapter import de.rki.coronawarnapp.util.serialization.adapter.LocalDateAdapter +import okio.ByteString import org.joda.time.Duration import org.joda.time.Instant import org.joda.time.LocalDate @@ -24,5 +26,6 @@ class SerializationModule { .registerTypeAdapter(LocalDate::class.java, LocalDateAdapter()) .registerTypeAdapter(Duration::class.java, DurationAdapter()) .registerTypeAdapter(ByteArray::class.java, ByteArrayAdapter()) + .registerTypeAdapter(ByteString::class.java, ByteStringBase64Adapter()) .create() } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteStringBase64Adapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteStringBase64Adapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..a4abf80511d4ea75b9e08aa093ae50a9d8ad0b6f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/serialization/adapter/ByteStringBase64Adapter.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 +import okio.ByteString.Companion.decodeBase64 +import org.json.JSONObject.NULL + +class ByteStringBase64Adapter : TypeAdapter<ByteString>() { + override fun write(out: JsonWriter, value: ByteString?) { + if (value == null) out.nullValue() + else value.base64().let { out.value(it) } + } + + override fun read(reader: JsonReader): ByteString? = when (reader.peek()) { + NULL -> reader.nextNull().let { null } + else -> { + val raw = reader.nextString() + raw.decodeBase64() ?: throw JsonParseException("Can't decode base64 ByteString: $raw") + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt index 3b07716c52847508970e5b09bb781858ca55e9d9..b877317c4215654c733750ac38793022a4f3b563 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/worker/WorkerBinder.kt @@ -18,6 +18,7 @@ import de.rki.coronawarnapp.presencetracing.checkins.checkout.auto.AutoCheckOutW import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningWorker import de.rki.coronawarnapp.presencetracing.storage.retention.TraceLocationDbCleanUpPeriodicWorker import de.rki.coronawarnapp.submission.auto.SubmissionWorker +import de.rki.coronawarnapp.vaccination.core.execution.worker.VaccinationUpdateWorker @Module abstract class WorkerBinder { @@ -119,4 +120,11 @@ abstract class WorkerBinder { abstract fun traceWarningWorker( factory: PresenceTracingWarningWorker.Factory ): InjectedWorkerFactory<out ListenableWorker> + + @Binds + @IntoMap + @WorkerKey(VaccinationUpdateWorker::class) + abstract fun vaccinationUpdateWorker( + factory: VaccinationUpdateWorker.Factory + ): InjectedWorkerFactory<out ListenableWorker> } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/ProofCertificate.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/ProofCertificate.kt new file mode 100644 index 0000000000000000000000000000000000000000..006f67b7ecae3ac0a8e40f73addb29ce59deec54 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/ProofCertificate.kt @@ -0,0 +1,27 @@ +package de.rki.coronawarnapp.vaccination.core + +import org.joda.time.Instant +import org.joda.time.LocalDate + +interface ProofCertificate { + val personIdentifier: VaccinatedPersonIdentifier + + val expiresAt: Instant + + val firstName: String? + val lastName: String + + val dateOfBirth: LocalDate + + val vaccineName: String + val medicalProductName: String + val vaccineManufacturer: String + + val doseNumber: Int + val totalSeriesOfDoses: Int + + val vaccinatedAt: LocalDate + + val certificateIssuer: String + val certificateId: String +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPerson.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPerson.kt new file mode 100644 index 0000000000000000000000000000000000000000..5fa92403177c111d5cbec10053c37a12d9656367 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPerson.kt @@ -0,0 +1,61 @@ +package de.rki.coronawarnapp.vaccination.core + +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinatedPersonData +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet +import org.joda.time.Instant +import org.joda.time.LocalDate + +data class VaccinatedPerson( + internal val data: VaccinatedPersonData, + private val valueSet: VaccinationValueSet?, + val isUpdatingData: Boolean = false, + val lastError: Throwable? = null, +) { + val identifier: VaccinatedPersonIdentifier + get() = data.identifier + + val vaccinationCertificates: Set<VaccinationCertificate> + get() = data.vaccinations.map { + it.toVaccinationCertificate(valueSet) + }.toSet() + + val proofCertificates: Set<ProofCertificate> + get() = data.proofs.map { + it.toProofCertificate(valueSet) + }.toSet() + + val vaccinationStatus: Status + get() = if (proofCertificates.isNotEmpty()) Status.COMPLETE else Status.INCOMPLETE + + val vaccineName: String + get() = vaccinationCertificates.first().vaccineName + + val firstName: String? + get() = vaccinationCertificates.first().firstName + + val lastName: String + get() = vaccinationCertificates.first().lastName + + val fullName: String + get() = when { + firstName == null -> lastName + else -> "$firstName $lastName" + } + + val dateOfBirth: LocalDate + get() = vaccinationCertificates.first().dateOfBirth + + val isEligbleForProofCertificate: Boolean + get() = data.isEligbleForProofCertificate + + val isProofCertificateCheckPending: Boolean + get() = data.isPCRunPending + + val lastProofCheckAt: Instant + get() = data.lastSuccessfulPCRunAt + + enum class Status { + INCOMPLETE, + COMPLETE + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPersonIdentifier.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPersonIdentifier.kt new file mode 100644 index 0000000000000000000000000000000000000000..7629fcf8eec2503b8ce1bcebacbe0c4b06c5df6e --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPersonIdentifier.kt @@ -0,0 +1,56 @@ +package de.rki.coronawarnapp.vaccination.core + +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateV1 +import de.rki.coronawarnapp.vaccination.core.repository.errors.VaccinationDateOfBirthMissmatchException +import de.rki.coronawarnapp.vaccination.core.repository.errors.VaccinationNameMissmatchException +import de.rki.coronawarnapp.vaccination.core.server.ProofCertificateV1 +import org.joda.time.LocalDate + +data class VaccinatedPersonIdentifier( + val dateOfBirth: LocalDate, + val lastNameStandardized: String, + val firstNameStandardized: String? +) { + val code: String by lazy { + val dob = dateOfBirth.toString() + val lastName = lastNameStandardized + val firstName = firstNameStandardized + "$dob#$lastName#$firstName" + } + + fun requireMatch(other: VaccinatedPersonIdentifier) { + if (lastNameStandardized != other.lastNameStandardized) { + throw VaccinationNameMissmatchException( + "Family name does not match, got ${other.lastNameStandardized}, expected $lastNameStandardized" + ) + } + if (firstNameStandardized != other.firstNameStandardized) { + throw VaccinationNameMissmatchException( + "Given name does not match, got ${other.firstNameStandardized}, expected $firstNameStandardized" + ) + } + if (dateOfBirth != other.dateOfBirth) { + throw VaccinationDateOfBirthMissmatchException( + "Date of birth does not match, got ${other.dateOfBirth}, expected $dateOfBirth" + ) + } + } +} + +val VaccinationCertificateV1.personIdentifier: VaccinatedPersonIdentifier + get() = VaccinatedPersonIdentifier( + dateOfBirth = dateOfBirth, + lastNameStandardized = nameData.familyNameStandardized, + firstNameStandardized = nameData.givenNameStandardized + ) + +val ProofCertificateV1.personIdentifier: VaccinatedPersonIdentifier + get() = VaccinatedPersonIdentifier( + dateOfBirth = dateOfBirth, + lastNameStandardized = nameData.familyNameStandardized, + firstNameStandardized = nameData.givenNameStandardized + ) + +val VaccinationCertificateQRCode.personIdentifier: VaccinatedPersonIdentifier + get() = parsedData.vaccinationCertificate.personIdentifier diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationCertificate.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationCertificate.kt new file mode 100644 index 0000000000000000000000000000000000000000..b5d0301796a80813766b78b043edb63fe03d9139 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationCertificate.kt @@ -0,0 +1,25 @@ +package de.rki.coronawarnapp.vaccination.core + +import de.rki.coronawarnapp.ui.Country +import org.joda.time.LocalDate + +interface VaccinationCertificate { + val firstName: String? + val lastName: String + + val dateOfBirth: LocalDate + val vaccinatedAt: LocalDate + + val vaccineName: String + val vaccineManufacturer: String + val medicalProductName: String + + val doseNumber: Int + val totalSeriesOfDoses: Int + + val certificateIssuer: String + val certificateCountry: Country + val certificateId: String + + val personIdentifier: VaccinatedPersonIdentifier +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationException.kt new file mode 100644 index 0000000000000000000000000000000000000000..4963bf1bd500fddac6397d6da57a8f06a5d8eaf5 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationException.kt @@ -0,0 +1,6 @@ +package de.rki.coronawarnapp.vaccination.core + +open class VaccinationException( + cause: Throwable?, + message: String +) : Exception(message, cause) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..e8c20bd7126a10e2af24b8d262145783ddaf83a1 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/VaccinationModule.kt @@ -0,0 +1,24 @@ +package de.rki.coronawarnapp.vaccination.core + +import dagger.Binds +import dagger.Module +import dagger.multibindings.IntoMap +import de.rki.coronawarnapp.task.Task +import de.rki.coronawarnapp.task.TaskFactory +import de.rki.coronawarnapp.task.TaskTypeKey +import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask +import de.rki.coronawarnapp.vaccination.core.server.VaccinationServerModule + +@Module( + includes = [ + VaccinationServerModule::class + ] +) +abstract class VaccinationModule { + @Binds + @IntoMap + @TaskTypeKey(VaccinationUpdateTask::class) + abstract fun vaccinationUpdateTaskFactory( + factory: VaccinationUpdateTask.Factory + ): TaskFactory<out Task.Progress, out Task.Result> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/common/RawCOSEObject.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/common/RawCOSEObject.kt new file mode 100644 index 0000000000000000000000000000000000000000..170af7e10caadfd5dc1149ca9472cd5b85a4463d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/common/RawCOSEObject.kt @@ -0,0 +1,39 @@ +package de.rki.coronawarnapp.vaccination.core.common + +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 +import okio.ByteString.Companion.decodeBase64 +import okio.ByteString.Companion.toByteString +import org.json.JSONObject + +data class RawCOSEObject( + val data: ByteString +) { + constructor(data: ByteArray) : this(data.toByteString()) + + val asByteArray: ByteArray + get() = data.toByteArray() + + companion object { + val EMPTY = RawCOSEObject(data = ByteString.EMPTY) + } + + class JsonAdapter : TypeAdapter<RawCOSEObject>() { + override fun write(out: JsonWriter, value: RawCOSEObject?) { + if (value == null) out.nullValue() + else value.data.base64().let { out.value(it) } + } + + override fun read(reader: JsonReader): RawCOSEObject? = when (reader.peek()) { + JSONObject.NULL -> reader.nextNull().let { null } + else -> { + val raw = reader.nextString() + raw.decodeBase64()?.let { RawCOSEObject(data = it) } + ?: throw JsonParseException("Can't decode base64 ByteArray: $raw") + } + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/VaccinationUpdateScheduler.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/VaccinationUpdateScheduler.kt new file mode 100644 index 0000000000000000000000000000000000000000..e26551fb82e87959465513c8a784cb60de3d78a1 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/VaccinationUpdateScheduler.kt @@ -0,0 +1,116 @@ +package de.rki.coronawarnapp.vaccination.core.execution + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.WorkInfo +import androidx.work.WorkManager +import de.rki.coronawarnapp.task.TaskController +import de.rki.coronawarnapp.task.TaskFactory +import de.rki.coronawarnapp.task.common.DefaultTaskRequest +import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUtc +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.coroutine.AppScope +import de.rki.coronawarnapp.util.coroutine.await +import de.rki.coronawarnapp.util.device.ForegroundState +import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask +import de.rki.coronawarnapp.vaccination.core.execution.worker.VaccinationUpdateWorkerRequestBuilder +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VaccinationUpdateScheduler @Inject constructor( + @AppScope private val appScope: CoroutineScope, + private val taskController: TaskController, + private val vaccinationRepository: VaccinationRepository, + private val foregroundState: ForegroundState, + private val workManager: WorkManager, + private val workerRequestBuilder: VaccinationUpdateWorkerRequestBuilder, + private val timeStamper: TimeStamper, +) { + + fun setup() { + Timber.tag(TAG).d("setup()") + + // If there is a pending check, we can perform it in the background. + // We basically consume all "pending check flags" in the background until there are none. + vaccinationRepository.vaccinationInfos + .map { vaccinatedPersons -> + vaccinatedPersons.any { it.isProofCertificateCheckPending } + } + .distinctUntilChanged() + .onEach { hasProofCheckPending -> + val alreadyScheduled = isScheduled() + Timber.tag(TAG).d("Enable worker? hasPending=$hasProofCheckPending, scheduled=$alreadyScheduled") + setPeriodicUpdatesEnabled(hasProofCheckPending) + } + .catch { Timber.tag(TAG).e(it, "Failed to monitor for pending proof checks.") } + .launchIn(appScope) + + // If there is a pending check or we have stale data, we refresh immediately when opening the app + combine( + // Pending checks? + vaccinationRepository.vaccinationInfos.map { persons -> + persons.any { it.isProofCertificateCheckPending } + }.distinctUntilChanged(), + // Stale data? + vaccinationRepository.vaccinationInfos.map { persons -> + val nowUTC = timeStamper.nowUTC + persons.any { + it.lastProofCheckAt.toLocalDateUtc() != nowUTC.toLocalDateUtc() && it.isEligbleForProofCertificate + } + }.distinctUntilChanged(), + foregroundState.isInForeground + ) { hasPending, staleData, isForeground -> + Timber.tag(TAG).v("Run now? pending=$hasPending, staleData=$staleData, isForeground=$isForeground") + + if (isForeground && (hasPending || staleData)) { + Timber.tag(TAG).d("App moved to foreground, with pending checks, initiating refresh.") + DefaultTaskRequest( + type = VaccinationUpdateTask::class, + arguments = VaccinationUpdateTask.Arguments, + errorHandling = TaskFactory.Config.ErrorHandling.SILENT, + originTag = TAG, + ).run { taskController.submit(this) } + } + } + .catch { Timber.tag(TAG).e(it, "Failed to monitor foreground state changes.") } + .launchIn(appScope) + } + + private fun setPeriodicUpdatesEnabled(enabled: Boolean) { + Timber.tag(TAG).i("setPeriodicUpdatesEnabled(enabled=$enabled)") + if (enabled) { + val request = workerRequestBuilder.createPeriodicWorkRequest() + Timber.tag(TAG).d("queueWorker(request=%s)", request) + workManager.enqueueUniquePeriodicWork( + WORKER_ID_VACCINATION_UPDATE, + ExistingPeriodicWorkPolicy.KEEP, + request, + ) + } else { + Timber.tag(TAG).d("cancelWorker()") + workManager.cancelUniqueWork(WORKER_ID_VACCINATION_UPDATE) + } + } + + private suspend fun isScheduled(): Boolean = workManager + .getWorkInfosForUniqueWork(WORKER_ID_VACCINATION_UPDATE) + .await() + .any { it.isScheduled } + + internal val WorkInfo.isScheduled: Boolean + get() = state == WorkInfo.State.RUNNING || state == WorkInfo.State.ENQUEUED + + companion object { + private val TAG: String = VaccinationUpdateScheduler::class.java.simpleName + private const val WORKER_ID_VACCINATION_UPDATE = "VaccinationUpdateWorker" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/task/VaccinationUpdateTask.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/task/VaccinationUpdateTask.kt new file mode 100644 index 0000000000000000000000000000000000000000..3f02678f087144b55d3b1d2bc4de05d19223ee27 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/task/VaccinationUpdateTask.kt @@ -0,0 +1,80 @@ +package de.rki.coronawarnapp.vaccination.core.execution.task + +import de.rki.coronawarnapp.appconfig.AppConfigProvider +import de.rki.coronawarnapp.bugreporting.reportProblem +import de.rki.coronawarnapp.task.Task +import de.rki.coronawarnapp.task.TaskFactory +import de.rki.coronawarnapp.task.common.DefaultProgress +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import kotlinx.coroutines.channels.ConflatedBroadcastChannel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow +import org.joda.time.Duration +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Provider + +class VaccinationUpdateTask @Inject constructor( + private val timeStamper: TimeStamper, + private val vaccinationRepository: VaccinationRepository, +) : Task<DefaultProgress, VaccinationUpdateTask.Result> { + + private val internalProgress = ConflatedBroadcastChannel<DefaultProgress>() + override val progress: Flow<DefaultProgress> = internalProgress.asFlow() + + private var isCanceled = false + + override suspend fun run(arguments: Task.Arguments): Result = try { + Timber.d("Running with arguments=%s", arguments) + + doWork() + } catch (error: Exception) { + Timber.tag(TAG).e(error) + error.reportProblem(TAG, "Vaccination update task failed.") + throw error + } finally { + Timber.i("Finished (isCanceled=$isCanceled).") + internalProgress.close() + } + + private suspend fun doWork(): Result { + Timber.tag(TAG).d("Refreshing vaccination data.") + vaccinationRepository.refresh() + Timber.tag(TAG).d("Vaccination data refreshed.") + return Result + } + + override suspend fun cancel() { + Timber.w("cancel() called.") + isCanceled = true + } + + object Arguments : Task.Arguments + + object Result : Task.Result + + data class Config( + override val executionTimeout: Duration = Duration.standardMinutes(9), + override val collisionBehavior: TaskFactory.Config.CollisionBehavior = + TaskFactory.Config.CollisionBehavior.SKIP_IF_SIBLING_RUNNING + ) : TaskFactory.Config + + class Factory @Inject constructor( + private val taskByDagger: Provider<VaccinationUpdateTask>, + private val appConfigProvider: AppConfigProvider + ) : TaskFactory<DefaultProgress, Result> { + + override suspend fun createConfig(): TaskFactory.Config = Config( + executionTimeout = appConfigProvider.getAppConfig().overallDownloadTimeout + ) + + override val taskProvider: () -> Task<DefaultProgress, Result> = { + taskByDagger.get() + } + } + + companion object { + private const val TAG = "VaccinationUpdateTask" + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorker.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorker.kt new file mode 100644 index 0000000000000000000000000000000000000000..a22d1c1f885925005fb62c74e54bb99ccbe13302 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorker.kt @@ -0,0 +1,60 @@ +package de.rki.coronawarnapp.vaccination.core.execution.worker + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import de.rki.coronawarnapp.bugreporting.reportProblem +import de.rki.coronawarnapp.task.TaskController +import de.rki.coronawarnapp.task.TaskFactory +import de.rki.coronawarnapp.task.common.DefaultTaskRequest +import de.rki.coronawarnapp.task.submitBlocking +import de.rki.coronawarnapp.util.worker.InjectedWorkerFactory +import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask +import timber.log.Timber + +class VaccinationUpdateWorker @AssistedInject constructor( + @Assisted val context: Context, + @Assisted workerParams: WorkerParameters, + private val taskController: TaskController +) : CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result = try { + Timber.tag(TAG).v("$id: doWork() started. Run attempt: $runAttemptCount") + + Timber.tag(TAG).i("Running vaccination data update task.") + val taskState = taskController.submitBlocking( + DefaultTaskRequest( + VaccinationUpdateTask::class, + arguments = VaccinationUpdateTask.Arguments, + errorHandling = TaskFactory.Config.ErrorHandling.SILENT, + originTag = TAG, + ) + ) + + when { + taskState.isSuccessful -> { + Timber.tag(TAG).d("$id: VaccinationUpdateTask finished successfully.") + Result.success() + } + else -> { + taskState.error?.let { + Timber.tag(TAG).w(it, "$id: Error during VaccinationUpdateTask.") + } + Result.retry() + } + } + } catch (e: Exception) { + e.reportProblem(TAG, "VaccinationUpdateTask failed exceptionally, will retry.") + Result.retry() + } + + @AssistedFactory + interface Factory : InjectedWorkerFactory<VaccinationUpdateWorker> + + companion object { + private val TAG = VaccinationUpdateWorker::class.java.simpleName + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorkerRequestBuilder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorkerRequestBuilder.kt new file mode 100644 index 0000000000000000000000000000000000000000..62b491fe6415cd54eef28cf5891720c64a2e1b02 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorkerRequestBuilder.kt @@ -0,0 +1,38 @@ +package de.rki.coronawarnapp.vaccination.core.execution.worker + +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequest +import androidx.work.PeriodicWorkRequestBuilder +import dagger.Reusable +import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingWarningWorker +import de.rki.coronawarnapp.worker.BackgroundConstants +import java.util.concurrent.TimeUnit +import javax.inject.Inject + +@Reusable +class VaccinationUpdateWorkerRequestBuilder @Inject constructor() { + + fun createPeriodicWorkRequest(): PeriodicWorkRequest = + PeriodicWorkRequestBuilder<PresenceTracingWarningWorker>( + 24, + TimeUnit.HOURS + ) + .setInitialDelay( + BackgroundConstants.KIND_DELAY, + TimeUnit.MINUTES + ) + .setBackoffCriteria( + BackoffPolicy.EXPONENTIAL, + BackgroundConstants.BACKOFF_INITIAL_DELAY, + TimeUnit.MINUTES + ) + .setConstraints(buildConstraints()) + .build() + + private fun buildConstraints() = + Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .build() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/HealthCertificateCOSEDecoder.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/HealthCertificateCOSEDecoder.kt new file mode 100644 index 0000000000000000000000000000000000000000..b7740ca74021eedb07055d47d896cae5b16dbc19 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/HealthCertificateCOSEDecoder.kt @@ -0,0 +1,33 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.upokecenter.cbor.CBORObject +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_MESSAGE_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_TAG_INVALID +import timber.log.Timber +import javax.inject.Inject + +class HealthCertificateCOSEDecoder @Inject constructor() { + fun decode(input: RawCOSEObject): CBORObject { + return try { + val messageObject = CBORObject.DecodeFromBytes(input.asByteArray).validate() + val content = messageObject[2].GetByteString() + CBORObject.DecodeFromBytes(content) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Throwable) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID) + } + } + + private fun CBORObject.validate(): CBORObject { + if (size() != 4) { + throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID) + } + if (!HasTag(18)) { + throw InvalidHealthCertificateException(HC_COSE_TAG_INVALID) + } + return this + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/InvalidHealthCertificateException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/InvalidHealthCertificateException.kt new file mode 100644 index 0000000000000000000000000000000000000000..6cb56193106107c879c31397d94b69503b4ca6db --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/InvalidHealthCertificateException.kt @@ -0,0 +1,95 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import android.content.Context +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.coronatest.qrcode.InvalidQRCodeException +import de.rki.coronawarnapp.util.HasHumanReadableError +import de.rki.coronawarnapp.util.HumanReadableError +import de.rki.coronawarnapp.util.ui.CachedString +import de.rki.coronawarnapp.util.ui.LazyString +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_BASE45_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_CBOR_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_MESSAGE_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_TAG_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_ZLIB_DECOMPRESSION_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_ALREADY_REGISTERED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_DOB_MISMATCH +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_DGC +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_EXP +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_HCERT +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_ISS +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_JSON_SCHEMA_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_NAME_MISMATCH +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_NO_VACCINATION_ENTRY +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_PREFIX_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_STORING_FAILED + +class InvalidHealthCertificateException( + val errorCode: ErrorCode +) : HasHumanReadableError, InvalidQRCodeException(errorCode.message) { + enum class ErrorCode( + val message: String + ) { + HC_BASE45_DECODING_FAILED("Base45 decoding failed."), + HC_ZLIB_DECOMPRESSION_FAILED("Zlib decompression failed."), + HC_COSE_TAG_INVALID("COSE tag invalid."), + HC_COSE_MESSAGE_INVALID("COSE message invalid."), + HC_CBOR_DECODING_FAILED("CBOR decoding failed."), + VC_NO_VACCINATION_ENTRY("Vaccination certificate missing."), + VC_PREFIX_INVALID("Prefix invalid."), + VC_STORING_FAILED("Storing failed."), + VC_JSON_SCHEMA_INVALID("Json schema invalid."), + VC_NAME_MISMATCH("Name does not match."), + VC_ALREADY_REGISTERED("Certificate already registered."), + VC_DOB_MISMATCH("Date of birth does not match."), + VC_HC_CWT_NO_DGC("Dgc missing."), + VC_HC_CWT_NO_EXP("Expiration date missing."), + VC_HC_CWT_NO_HCERT("Health certificate missing."), + VC_HC_CWT_NO_ISS("Issuer missing."), + } + + val errorMessage: LazyString + get() = when (errorCode) { + HC_BASE45_DECODING_FAILED, + HC_CBOR_DECODING_FAILED, + HC_COSE_MESSAGE_INVALID, + HC_ZLIB_DECOMPRESSION_FAILED, + HC_COSE_TAG_INVALID, + VC_PREFIX_INVALID, + VC_HC_CWT_NO_DGC, + VC_HC_CWT_NO_EXP, + VC_HC_CWT_NO_HCERT, + VC_HC_CWT_NO_ISS, + VC_JSON_SCHEMA_INVALID, + -> CachedString { context -> + context.getString(ERROR_MESSAGE_VC_INVALID) + } + VC_NO_VACCINATION_ENTRY -> CachedString { context -> + context.getString(ERROR_MESSAGE_VC_NOT_YET_SUPPORTED) + } + VC_STORING_FAILED -> CachedString { context -> + context.getString(ERROR_MESSAGE_VC_SCAN_AGAIN) + } + VC_NAME_MISMATCH, VC_DOB_MISMATCH -> CachedString { context -> + context.getString(ERROR_MESSAGE_VC_DIFFERENT_PERSON) + } + VC_ALREADY_REGISTERED -> CachedString { context -> + context.getString(ERROR_MESSAGE_ALREADY_REGISTERED) + } + } + + override fun toHumanReadableError(context: Context): HumanReadableError { + var errorCodeString = errorCode.toString() + errorCodeString = if (errorCodeString.startsWith(PREFIX)) errorCodeString else PREFIX + errorCodeString + return HumanReadableError( + description = errorMessage.get(context) + "\n\n$errorCodeString" + ) + } +} + +private const val PREFIX = "VC_" +private const val ERROR_MESSAGE_VC_INVALID = R.string.error_vc_invalid +private const val ERROR_MESSAGE_VC_NOT_YET_SUPPORTED = R.string.error_vc_not_yet_supported +private const val ERROR_MESSAGE_VC_SCAN_AGAIN = R.string.error_vc_scan_again +private const val ERROR_MESSAGE_VC_DIFFERENT_PERSON = R.string.error_vc_different_person +private const val ERROR_MESSAGE_ALREADY_REGISTERED = R.string.error_vc_already_registered diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateCOSEParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateCOSEParser.kt new file mode 100644 index 0000000000000000000000000000000000000000..bbc53a7a4672d28b9413adb8c9d7843bda2f474c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateCOSEParser.kt @@ -0,0 +1,40 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.upokecenter.cbor.CBORObject +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_CBOR_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_MESSAGE_INVALID +import timber.log.Timber +import javax.inject.Inject + +class VaccinationCertificateCOSEParser @Inject constructor( + private val healthCertificateCOSEDecoder: HealthCertificateCOSEDecoder, + private val vaccinationCertificateV1Parser: VaccinationCertificateV1Parser, +) { + + fun parse(rawCOSEObject: RawCOSEObject): VaccinationCertificateData { + return rawCOSEObject + .decodeCOSEObject() + .decodeCBORObject() + } + + private fun RawCOSEObject.decodeCOSEObject(): CBORObject { + return try { + healthCertificateCOSEDecoder.decode(this) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Exception) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID) + } + } + + private fun CBORObject.decodeCBORObject(): VaccinationCertificateData { + return try { + vaccinationCertificateV1Parser.parse(this) + } catch (e: Exception) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED) + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateData.kt new file mode 100644 index 0000000000000000000000000000000000000000..a21dfacfbaebced59e5bf5f8a1ac62db26662fcd --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateData.kt @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +/** + * Represents the information gained from data in COSE representation + */ +data class VaccinationCertificateData( + val header: VaccinationCertificateHeader, + val vaccinationCertificate: VaccinationCertificateV1, +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateHeader.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateHeader.kt new file mode 100644 index 0000000000000000000000000000000000000000..8accdae8f34e2e1fcd95e4eff56a423ad5657fc7 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateHeader.kt @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import org.joda.time.Instant + +data class VaccinationCertificateHeader( + val issuer: String, + val issuedAt: Instant, + val expiresAt: Instant +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateQRCode.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateQRCode.kt new file mode 100644 index 0000000000000000000000000000000000000000..fc086b3e2efcb55fce532b8c93eb275571cd145d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateQRCode.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject + +data class VaccinationCertificateQRCode( + val parsedData: VaccinationCertificateData, + val certificateCOSE: RawCOSEObject, +) { + val uniqueCertificateIdentifier: String + get() = parsedData.vaccinationCertificate.vaccinationDatas.single().uniqueCertificateIdentifier +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1.kt new file mode 100644 index 0000000000000000000000000000000000000000..f4cad67e33ea25964fbaa8509470567ddaa1d9c5 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1.kt @@ -0,0 +1,47 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.google.gson.annotations.SerializedName +import org.joda.time.LocalDate + +data class VaccinationCertificateV1( + @SerializedName("ver") val version: String, + @SerializedName("nam") val nameData: NameData, + @SerializedName("dob") val dob: String, + @SerializedName("v") val vaccinationDatas: List<VaccinationData>, +) { + data class NameData( + @SerializedName("fn") val familyName: String?, + @SerializedName("fnt") val familyNameStandardized: String, + @SerializedName("gn") val givenName: String?, + @SerializedName("gnt") val givenNameStandardized: String?, + ) + + data class VaccinationData( + // Disease or agent targeted, e.g. "tg": "840539006" + @SerializedName("tg") val targetId: String, + // Vaccine or prophylaxis, e.g. "vp": "1119349007" + @SerializedName("vp") val vaccineId: String, + // Vaccine medicinal product,e.g. "mp": "EU/1/20/1528", + @SerializedName("mp") val medicalProductId: String, + // Marketing Authorization Holder, e.g. "ma": "ORG-100030215", + @SerializedName("ma") val marketAuthorizationHolderId: String, + // Dose Number, e.g. "dn": 2 + @SerializedName("dn") val doseNumber: Int, + // Total Series of Doses, e.g. "sd": 2, + @SerializedName("sd") val totalSeriesOfDoses: Int, + // Date of Vaccination, e.g. "dt" : "2021-04-21" + @SerializedName("dt") val dt: String, + // Country of Vaccination, e.g. "co": "NL" + @SerializedName("co") val countryOfVaccination: String, + // Certificate Issuer, e.g. "is": "Ministry of Public Health, Welfare and Sport", + @SerializedName("is") val certificateIssuer: String, + // Unique Certificate Identifier, e.g. "ci": "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ" + @SerializedName("ci") val uniqueCertificateIdentifier: String + ) { + val vaccinatedAt: LocalDate + get() = LocalDate.parse(dt) + } + + val dateOfBirth: LocalDate + get() = LocalDate.parse(dob) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1Parser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1Parser.kt new file mode 100644 index 0000000000000000000000000000000000000000..0ffceb930dc079c8694c2f1537cd971f70167fec --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationCertificateV1Parser.kt @@ -0,0 +1,78 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.google.gson.Gson +import com.upokecenter.cbor.CBORObject +import de.rki.coronawarnapp.util.serialization.BaseGson +import de.rki.coronawarnapp.util.serialization.fromJson +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_CBOR_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_DGC +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_EXP +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_HCERT +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_ISS +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_JSON_SCHEMA_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_NO_VACCINATION_ENTRY +import org.joda.time.Instant +import javax.inject.Inject + +class VaccinationCertificateV1Parser @Inject constructor( + @BaseGson private val gson: Gson +) { + + companion object { + private val keyEuDgcV1 = CBORObject.FromObject(1) + private val keyHCert = CBORObject.FromObject(-260) + private val keyIssuer = CBORObject.FromObject(1) + private val keyExpiresAt = CBORObject.FromObject(4) + private val keyIssuedAt = CBORObject.FromObject(6) + } + + fun parse(map: CBORObject): VaccinationCertificateData = try { + val issuer: String = map[keyIssuer]?.AsString() ?: throw InvalidHealthCertificateException(VC_HC_CWT_NO_ISS) + + val issuedAt: Instant = map[keyIssuedAt]?.run { + Instant.ofEpochSecond(AsNumber().ToInt64Checked()) + } ?: throw InvalidHealthCertificateException(VC_HC_CWT_NO_ISS) + + val expiresAt: Instant = map[keyExpiresAt]?.run { + Instant.ofEpochSecond(AsNumber().ToInt64Checked()) + } ?: throw InvalidHealthCertificateException(VC_HC_CWT_NO_EXP) + + val certificate: VaccinationCertificateV1 = map[keyHCert]?.run { + this[keyEuDgcV1]?.run { + toCertificate() + } ?: throw InvalidHealthCertificateException(VC_HC_CWT_NO_DGC) + } ?: throw InvalidHealthCertificateException(VC_HC_CWT_NO_HCERT) + + val header = VaccinationCertificateHeader( + issuer = issuer, + issuedAt = issuedAt, + expiresAt = expiresAt + ) + VaccinationCertificateData( + header, + certificate.validate() + ) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Throwable) { + throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED) + } + + private fun CBORObject.toCertificate() = try { + val json = ToJSONString() + gson.fromJson<VaccinationCertificateV1>(json) + } catch (e: Throwable) { + throw InvalidHealthCertificateException(VC_JSON_SCHEMA_INVALID) + } + + private fun VaccinationCertificateV1.validate(): VaccinationCertificateV1 { + if (vaccinationDatas.isEmpty()) { + throw InvalidHealthCertificateException(VC_NO_VACCINATION_ENTRY) + } + dateOfBirth + vaccinationDatas.forEach { + it.vaccinatedAt + } + return this + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt new file mode 100644 index 0000000000000000000000000000000000000000..a5a3ec80d66513a906f6155bed1eec0e0a7b4cbb --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractor.kt @@ -0,0 +1,73 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.upokecenter.cbor.CBORObject +import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor +import de.rki.coronawarnapp.util.encoding.decodeBase45 +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_BASE45_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_CBOR_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_COSE_MESSAGE_INVALID +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_ZLIB_DECOMPRESSION_FAILED +import de.rki.coronawarnapp.vaccination.decoder.ZLIBDecompressor +import okio.ByteString +import timber.log.Timber +import javax.inject.Inject + +class VaccinationQRCodeExtractor @Inject constructor( + private val zLIBDecompressor: ZLIBDecompressor, + private val healthCertificateCOSEDecoder: HealthCertificateCOSEDecoder, + private val vaccinationCertificateV1Parser: VaccinationCertificateV1Parser, +) : QrCodeExtractor<VaccinationCertificateQRCode> { + + private val prefix = "HC1:" + + override fun canHandle(rawString: String): Boolean = rawString.startsWith(prefix) + + override fun extract(rawString: String): VaccinationCertificateQRCode { + val rawCOSEObject = rawString + .removePrefix(prefix) + .tryDecodeBase45() + .decompress() + + val certificate = rawCOSEObject + .decodeCOSEObject() + .parseCBORObject() + + return VaccinationCertificateQRCode( + parsedData = certificate, + certificateCOSE = rawCOSEObject, + ) + } + + private fun String.tryDecodeBase45(): ByteString = try { + this.decodeBase45() + } catch (e: Throwable) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_BASE45_DECODING_FAILED) + } + + private fun ByteString.decompress(): RawCOSEObject = try { + RawCOSEObject(zLIBDecompressor.decompress(this.toByteArray())) + } catch (e: Throwable) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_ZLIB_DECOMPRESSION_FAILED) + } + + private fun RawCOSEObject.decodeCOSEObject(): CBORObject = try { + healthCertificateCOSEDecoder.decode(this) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Throwable) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID) + } + + private fun CBORObject.parseCBORObject(): VaccinationCertificateData = try { + vaccinationCertificateV1Parser.parse(this) + } catch (e: InvalidHealthCertificateException) { + throw e + } catch (e: Throwable) { + Timber.e(e) + throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt new file mode 100644 index 0000000000000000000000000000000000000000..07fb8293cf6413aad498f312d487ee82781245c9 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeValidator.kt @@ -0,0 +1,25 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import dagger.Reusable +import de.rki.coronawarnapp.coronatest.qrcode.QrCodeExtractor +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_PREFIX_INVALID +import timber.log.Timber +import javax.inject.Inject + +@Reusable +class VaccinationQRCodeValidator @Inject constructor( + vaccinationQRCodeExtractor: VaccinationQRCodeExtractor +) { + private val extractors = setOf(vaccinationQRCodeExtractor) + + fun validate(rawString: String): VaccinationCertificateQRCode { + return findExtractor(rawString) + ?.extract(rawString) + ?.also { Timber.i("Extracted data from QR code is $it") } + ?: throw InvalidHealthCertificateException(VC_PREFIX_INVALID) + } + + private fun findExtractor(rawString: String): QrCodeExtractor<VaccinationCertificateQRCode>? { + return extractors.find { it.canHandle(rawString) } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/VaccinationRepository.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/VaccinationRepository.kt new file mode 100644 index 0000000000000000000000000000000000000000..180e98e59de9298dbd198455a5651377a0b5719f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/VaccinationRepository.kt @@ -0,0 +1,223 @@ +package de.rki.coronawarnapp.vaccination.core.repository + +import de.rki.coronawarnapp.bugreporting.reportProblem +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.coroutine.AppScope +import de.rki.coronawarnapp.util.coroutine.DispatcherProvider +import de.rki.coronawarnapp.util.flow.HotDataFlow +import de.rki.coronawarnapp.util.flow.combine +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson +import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier +import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate +import de.rki.coronawarnapp.vaccination.core.personIdentifier +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode +import de.rki.coronawarnapp.vaccination.core.repository.errors.VaccinatedPersonNotFoundException +import de.rki.coronawarnapp.vaccination.core.repository.errors.VaccinationCertificateNotFoundException +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinatedPersonData +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationContainer +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationStorage +import de.rki.coronawarnapp.vaccination.core.repository.storage.toProofContainer +import de.rki.coronawarnapp.vaccination.core.repository.storage.toVaccinationContainer +import de.rki.coronawarnapp.vaccination.core.server.proof.VaccinationProofServer + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VaccinationRepository @Inject constructor( + @AppScope private val appScope: CoroutineScope, + dispatcherProvider: DispatcherProvider, + private val timeStamper: TimeStamper, + private val storage: VaccinationStorage, + private val valueSetsRepository: ValueSetsRepository, + private val vaccinationProofServer: VaccinationProofServer, +) { + + private val internalData: HotDataFlow<Set<VaccinatedPerson>> = HotDataFlow( + loggingTag = TAG, + scope = appScope + dispatcherProvider.IO, + sharingBehavior = SharingStarted.Lazily, + ) { + storage.personContainers + .map { personContainer -> + VaccinatedPerson( + data = personContainer, + valueSet = null, + isUpdatingData = false, + lastError = null + ) + } + .toSet() + .also { Timber.tag(TAG).v("Restored vaccination data: %s", it) } + } + + init { + internalData.data + .onStart { Timber.tag(TAG).d("Observing test data.") } + .onEach { vaccinatedPersons -> + Timber.tag(TAG).v("Vaccination data changed: %s", vaccinatedPersons) + storage.personContainers = vaccinatedPersons.map { it.data }.toSet() + } + .catch { + it.reportProblem(TAG, "Failed to snapshot vaccination data to storage.") + throw it + } + .launchIn(appScope + dispatcherProvider.IO) + } + + val vaccinationInfos: Flow<Set<VaccinatedPerson>> = combine( + internalData.data, + valueSetsRepository.latestValueSet + ) { personDatas, currentValueSet -> + personDatas.map { it.copy(valueSet = currentValueSet) }.toSet() + } + + suspend fun registerVaccination( + qrCode: VaccinationCertificateQRCode + ): VaccinationCertificate { + Timber.tag(TAG).v("registerVaccination(qrCode=%s)", qrCode) + + val updatedData = internalData.updateBlocking { + val originalPerson = if (this.isNotEmpty()) { + Timber.tag(TAG).d("There is an existing person we must match.") + this.single().also { + it.identifier.requireMatch(qrCode.personIdentifier) + Timber.tag(TAG).i("New certificate matches existing person!") + } + } else { + VaccinatedPerson( + data = VaccinatedPersonData( + vaccinations = emptySet(), + proofs = emptySet() + ), + valueSet = null, + ) + } + + val newCertificate = qrCode.toVaccinationContainer(scannedAt = timeStamper.nowUTC) + + val modifiedPerson = originalPerson.copy( + data = originalPerson.data.copy( + vaccinations = originalPerson.data.vaccinations.plus(newCertificate) + ) + ) + + this.toMutableSet().apply { + remove(originalPerson) + add(modifiedPerson) + } + } + + val updatedPerson = updatedData.single { it.identifier == qrCode.personIdentifier } + + if (updatedPerson.isEligbleForProofCertificate) { + Timber.tag(TAG).i("%s is eligble for proof certificate, launching async check.", updatedPerson.identifier) + appScope.launch { refresh(updatedPerson.identifier) } + } + + return updatedPerson.vaccinationCertificates.single { + it.certificateId == qrCode.uniqueCertificateIdentifier + } + } + + private suspend fun checkForProof(personIdentifier: VaccinatedPersonIdentifier?) { + Timber.tag(TAG).i("checkForProof(personIdentifier=%s)", personIdentifier) + withContext(appScope.coroutineContext) { + internalData.updateBlocking { + val originalPerson = this.singleOrNull { + it.identifier == personIdentifier + } ?: throw VaccinatedPersonNotFoundException("Identifier=$personIdentifier") + + val eligbleCert = originalPerson.data.vaccinations.first { it.isEligbleForProofCertificate } + + val proof = try { + vaccinationProofServer.getProofCertificate(eligbleCert.vaccinationCertificateCOSE) + } catch (e: Exception) { + Timber.tag(TAG).e(e, "Failed to check for proof.") + null + } + + val modifiedPerson = proof?.let { + originalPerson.copy( + data = originalPerson.data.copy( + proofs = setOf(it.toProofContainer(timeStamper.nowUTC)) + ) + ) + } ?: originalPerson + + this.toMutableSet().apply { + remove(originalPerson) + add(modifiedPerson) + } + } + } + throw NotImplementedError() + } + + /** + * Passing null as identifier will refresh all available data, if within constraints. + */ + suspend fun refresh(personIdentifier: VaccinatedPersonIdentifier? = null) { + Timber.tag(TAG).d("refresh(personIdentifier=%s)", personIdentifier) + // TODO + } + + suspend fun clear() { + Timber.tag(TAG).w("Clearing vaccination data.") + internalData.updateBlocking { + Timber.tag(TAG).v("Deleting: %s", this) + emptySet() + } + } + + suspend fun deleteVaccinationCertificate(vaccinationCertificateId: String) { + Timber.tag(TAG).w("deleteVaccinationCertificate(certificateId=%s)", vaccinationCertificateId) + var deletedVaccination: VaccinationContainer? = null + + internalData.updateBlocking { + val target = this.find { person -> + person.vaccinationCertificates.any { it.certificateId == vaccinationCertificateId } + } ?: throw VaccinationCertificateNotFoundException( + "No vaccination certificate found for $vaccinationCertificateId" + ) + + deletedVaccination = target.data.vaccinations.single { + it.certificateId != vaccinationCertificateId + } + + val newTarget = target.copy( + data = target.data.copy( + vaccinations = target.data.vaccinations.filter { + it.certificateId != vaccinationCertificateId + }.toSet() + ) + ) + + this.map { + if (it != target) newTarget else it + }.toSet() + } + + deletedVaccination?.let { + if (!it.isEligbleForProofCertificate) return + + Timber.tag(TAG).i("Deleted vaccination was eligble for proof, refreshing: %s", deletedVaccination) + appScope.launch { refresh(it.personIdentifier) } + } + } + + companion object { + private const val TAG = "VaccinationRepository" + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..e3497c090778dcdf8079163b72f2bd4082338107 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/ValueSetsRepository.kt @@ -0,0 +1,17 @@ +package de.rki.coronawarnapp.vaccination.core.repository + +import de.rki.coronawarnapp.submission.server.SubmissionServer +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ValueSetsRepository @Inject constructor( + private val submissionServer: SubmissionServer +) { + + val latestValueSet: Flow<VaccinationValueSet?> = flowOf(null) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinatedPersonNotFoundException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinatedPersonNotFoundException.kt new file mode 100644 index 0000000000000000000000000000000000000000..34e54c6f088361b3679092e07e728ca63d970a28 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinatedPersonNotFoundException.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.vaccination.core.repository.errors + +import de.rki.coronawarnapp.vaccination.core.VaccinationException + +class VaccinatedPersonNotFoundException( + message: String +) : VaccinationException( + message = message, + cause = null +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationCertificateNotFoundException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationCertificateNotFoundException.kt new file mode 100644 index 0000000000000000000000000000000000000000..2b3562f9093a0d15fe7e7c812d0de28f299ac056 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationCertificateNotFoundException.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.vaccination.core.repository.errors + +import de.rki.coronawarnapp.vaccination.core.VaccinationException + +class VaccinationCertificateNotFoundException( + message: String +) : VaccinationException( + message = message, + cause = null +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationDateOfBirthMissmatchException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationDateOfBirthMissmatchException.kt new file mode 100644 index 0000000000000000000000000000000000000000..bedc695d22726250f93bcb4273b84dc17fc21430 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationDateOfBirthMissmatchException.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.vaccination.core.repository.errors + +import de.rki.coronawarnapp.vaccination.core.VaccinationException + +class VaccinationDateOfBirthMissmatchException( + message: String +) : VaccinationException( + message = message, + cause = null +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationNameMissmatchException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationNameMissmatchException.kt new file mode 100644 index 0000000000000000000000000000000000000000..3e27e652d6519f83fa9473c2a0e064c4b77e1411 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/errors/VaccinationNameMissmatchException.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.vaccination.core.repository.errors + +import de.rki.coronawarnapp.vaccination.core.VaccinationException + +class VaccinationNameMissmatchException( + message: String +) : VaccinationException( + message = message, + cause = null +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ProofContainer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ProofContainer.kt new file mode 100644 index 0000000000000000000000000000000000000000..cc16b06a8e66e52abdc3c4bdb0f9d9fa20722b3a --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ProofContainer.kt @@ -0,0 +1,84 @@ +package de.rki.coronawarnapp.vaccination.core.repository.storage + +import androidx.annotation.Keep +import com.google.gson.annotations.SerializedName +import de.rki.coronawarnapp.vaccination.core.ProofCertificate +import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import de.rki.coronawarnapp.vaccination.core.personIdentifier +import de.rki.coronawarnapp.vaccination.core.server.ProofCertificateV1 +import de.rki.coronawarnapp.vaccination.core.server.proof.ProofCertificateCOSEParser +import de.rki.coronawarnapp.vaccination.core.server.proof.ProofCertificateData +import de.rki.coronawarnapp.vaccination.core.server.proof.ProofCertificateResponse +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet +import org.joda.time.Instant +import org.joda.time.LocalDate + +@Keep +data class ProofContainer( + @SerializedName("proofCOSE") val proofCOSE: RawCOSEObject, + @SerializedName("receivedAt") val receivedAt: Instant, +) { + @Transient internal var preParsedData: ProofCertificateData? = null + + // Otherwise GSON unsafes reflection to create this class, and sets the LAZY to null + @Suppress("unused") + constructor() : this(RawCOSEObject.EMPTY, Instant.EPOCH) + + @delegate:Transient + private val proofData: ProofCertificateData by lazy { + preParsedData ?: ProofCertificateCOSEParser().parse(proofCOSE) + } + + val proof: ProofCertificateV1 + get() = proofData.proofCertificate + + val vaccination: ProofCertificateV1.VaccinationData + get() = proof.vaccinationDatas.single() + + val personIdentifier: VaccinatedPersonIdentifier + get() = proof.personIdentifier + + fun toProofCertificate(valueSet: VaccinationValueSet?): ProofCertificate = object : ProofCertificate { + override val expiresAt: Instant + get() = proofData.expiresAt + + override val personIdentifier: VaccinatedPersonIdentifier + get() = proof.personIdentifier + + override val firstName: String? + get() = proof.nameData.givenName + override val lastName: String + get() = proof.nameData.familyName ?: proof.nameData.familyNameStandardized + override val dateOfBirth: LocalDate + get() = proof.dateOfBirth + + override val vaccinatedAt: LocalDate + get() = vaccination.vaccinatedAt + + override val doseNumber: Int + get() = vaccination.doseNumber + override val totalSeriesOfDoses: Int + get() = vaccination.totalSeriesOfDoses + + override val vaccineName: String + get() = valueSet?.getDisplayText(vaccination.vaccineId) ?: vaccination.vaccineId + override val vaccineManufacturer: String + get() = valueSet?.getDisplayText(vaccination.marketAuthorizationHolderId) + ?: vaccination.marketAuthorizationHolderId + override val medicalProductName: String + get() = valueSet?.getDisplayText(vaccination.medicalProductId) ?: vaccination.medicalProductId + + override val certificateIssuer: String + get() = vaccination.certificateIssuer + override val certificateId: String + get() = vaccination.uniqueCertificateIdentifier + } +} + +fun ProofCertificateResponse.toProofContainer(receivedAt: Instant) = ProofContainer( + proofCOSE = proofCertificateCOSE, + receivedAt = receivedAt, +).apply { + preParsedData = proofCertificateData +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinatedPersonData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinatedPersonData.kt new file mode 100644 index 0000000000000000000000000000000000000000..d7216981a06ad3e09e0299d9762908430dd7265c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinatedPersonData.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.vaccination.core.repository.storage + +import com.google.gson.annotations.SerializedName +import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier +import org.joda.time.Instant + +data class VaccinatedPersonData( + @SerializedName("vaccinationData") val vaccinations: Set<VaccinationContainer>, + @SerializedName("proofData") val proofs: Set<ProofContainer>, + @SerializedName("lastSuccessfulProofCertificateRun") val lastSuccessfulPCRunAt: Instant = Instant.EPOCH, + @SerializedName("proofCertificateRunPending") val isPCRunPending: Boolean = false, +) { + val identifier: VaccinatedPersonIdentifier + get() = vaccinations.first().personIdentifier + + val isEligbleForProofCertificate: Boolean + get() = vaccinations.any { it.isEligbleForProofCertificate } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..7fbacf663f40f075ed180afac3fd7e27e80d004a --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainer.kt @@ -0,0 +1,99 @@ +package de.rki.coronawarnapp.vaccination.core.repository.storage + +import androidx.annotation.Keep +import com.google.gson.Gson +import com.google.gson.annotations.SerializedName +import de.rki.coronawarnapp.ui.Country +import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier +import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import de.rki.coronawarnapp.vaccination.core.personIdentifier +import de.rki.coronawarnapp.vaccination.core.qrcode.HealthCertificateCOSEDecoder +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateCOSEParser +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateData +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateV1 +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateV1Parser +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet +import org.joda.time.Instant +import org.joda.time.LocalDate + +@Keep +data class VaccinationContainer( + @SerializedName("vaccinationCertificateCOSE") val vaccinationCertificateCOSE: RawCOSEObject, + @SerializedName("scannedAt") val scannedAt: Instant, +) { + + @Transient internal var preParsedData: VaccinationCertificateData? = null + + // Otherwise GSON unsafes reflection to create this class, and sets the LAZY to null + @Suppress("unused") + constructor() : this(RawCOSEObject.EMPTY, Instant.EPOCH) + + // TODO DI/ error handling + @delegate:Transient + private val certificateData: VaccinationCertificateData by lazy { + preParsedData ?: VaccinationCertificateCOSEParser( + HealthCertificateCOSEDecoder(), + VaccinationCertificateV1Parser(Gson()), + ).parse(vaccinationCertificateCOSE) + } + + val certificate: VaccinationCertificateV1 + get() = certificateData.vaccinationCertificate + + val vaccination: VaccinationCertificateV1.VaccinationData + get() = certificate.vaccinationDatas.single() + + val certificateId: String + get() = vaccination.uniqueCertificateIdentifier + + val personIdentifier: VaccinatedPersonIdentifier + get() = certificate.personIdentifier + + val isEligbleForProofCertificate: Boolean + get() = vaccination.doseNumber == vaccination.totalSeriesOfDoses + + fun toVaccinationCertificate(valueSet: VaccinationValueSet?) = object : VaccinationCertificate { + override val personIdentifier: VaccinatedPersonIdentifier + get() = certificate.personIdentifier + + override val firstName: String? + get() = certificate.nameData.givenName + override val lastName: String + get() = certificate.nameData.familyName ?: certificate.nameData.familyNameStandardized + + override val dateOfBirth: LocalDate + get() = certificate.dateOfBirth + + override val vaccinatedAt: LocalDate + get() = vaccination.vaccinatedAt + + override val doseNumber: Int + get() = vaccination.doseNumber + override val totalSeriesOfDoses: Int + get() = vaccination.totalSeriesOfDoses + + override val vaccineName: String + get() = valueSet?.getDisplayText(vaccination.vaccineId) ?: vaccination.vaccineId + override val vaccineManufacturer: String + get() = valueSet?.getDisplayText(vaccination.marketAuthorizationHolderId) + ?: vaccination.marketAuthorizationHolderId + override val medicalProductName: String + get() = valueSet?.getDisplayText(vaccination.medicalProductId) ?: vaccination.medicalProductId + + override val certificateIssuer: String + get() = vaccination.certificateIssuer + override val certificateCountry: Country + get() = Country.values().singleOrNull { it.code == vaccination.countryOfVaccination } ?: Country.DE + override val certificateId: String + get() = vaccination.uniqueCertificateIdentifier + } +} + +fun VaccinationCertificateQRCode.toVaccinationContainer(scannedAt: Instant) = VaccinationContainer( + vaccinationCertificateCOSE = certificateCOSE, + scannedAt = scannedAt, +).apply { + preParsedData = parsedData +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorage.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorage.kt new file mode 100644 index 0000000000000000000000000000000000000000..bdbaaadc265b61e28d922cfd8b4ee32073429bab --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorage.kt @@ -0,0 +1,67 @@ +package de.rki.coronawarnapp.vaccination.core.repository.storage + +import android.content.Context +import androidx.core.content.edit +import com.google.gson.Gson +import de.rki.coronawarnapp.util.di.AppContext +import de.rki.coronawarnapp.util.serialization.BaseGson +import de.rki.coronawarnapp.util.serialization.fromJson +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VaccinationStorage @Inject constructor( + @AppContext val context: Context, + @BaseGson val baseGson: Gson +) { + + private val prefs by lazy { + context.getSharedPreferences("vaccination_localdata", Context.MODE_PRIVATE) + } + + private val gson by lazy { + // Allow for custom type adapter. + baseGson.newBuilder().apply { + registerTypeAdapter(RawCOSEObject::class.java, RawCOSEObject.JsonAdapter()) + }.create() + } + + var personContainers: Set<VaccinatedPersonData> + get() { + Timber.tag(TAG).d("vaccinatedPersons - load()") + val persons = prefs.all.mapNotNull { (key, value) -> + if (!key.startsWith(PKEY_PERSON_PREFIX)) { + return@mapNotNull null + } + value as String + gson.fromJson<VaccinatedPersonData>(value).also { + Timber.tag(TAG).v("Person loaded: %s", it) + requireNotNull(it.identifier) + } + } + return persons.toSet() + } + set(persons) { + Timber.tag(TAG).d("vaccinatedPersons - save(%s)", persons) + + prefs.edit { + prefs.all.keys.filter { it.startsWith(PKEY_PERSON_PREFIX) }.forEach { + Timber.tag(TAG).v("Removing data for %s", it) + remove(it) + } + persons.forEach { + val raw = gson.toJson(it) + val identifier = it.identifier + Timber.tag(TAG).v("Storing vaccinatedPerson %s -> %s", identifier, raw) + putString("$PKEY_PERSON_PREFIX${identifier.code}", raw) + } + } + } + + companion object { + private const val TAG = "VaccinationStorage" + private const val PKEY_PERSON_PREFIX = "vaccination.person." + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/ProofCertificateV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/ProofCertificateV1.kt new file mode 100644 index 0000000000000000000000000000000000000000..0808cdbd70c310ad988fa034857b603fad4aa790 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/ProofCertificateV1.kt @@ -0,0 +1,43 @@ +package de.rki.coronawarnapp.vaccination.core.server + +import com.google.gson.annotations.SerializedName +import org.joda.time.LocalDate + +// TODO check correctness, copy paste from vaccination cert +data class ProofCertificateV1( + @SerializedName("ver") val version: String, + @SerializedName("nam") val nameData: NameData, + @SerializedName("dob") val dateOfBirth: LocalDate, + @SerializedName("v") val vaccinationDatas: List<VaccinationData>, +) { + + data class NameData( + @SerializedName("fn") val familyName: String?, + @SerializedName("fnt") val familyNameStandardized: String, + @SerializedName("gn") val givenName: String?, + @SerializedName("gnt") val givenNameStandardized: String?, + ) + + data class VaccinationData( + // Disease or agent targeted, e.g. "tg": "840539006" + @SerializedName("tg") val targetId: String, + // Vaccine or prophylaxis, e.g. "vp": "1119349007" + @SerializedName("vp") val vaccineId: String, + // Vaccine medicinal product,e.g. "mp": "EU/1/20/1528", + @SerializedName("mp") val medicalProductId: String, + // Marketing Authorization Holder, e.g. "ma": "ORG-100030215", + @SerializedName("ma") val marketAuthorizationHolderId: String, + // Dose Number, e.g. "dn": 2 + @SerializedName("dn") val doseNumber: Int, + // Total Series of Doses, e.g. "sd": 2, + @SerializedName("sd") val totalSeriesOfDoses: Int, + // Date of Vaccination, e.g. "dt" : "2021-04-21" + @SerializedName("dt") val vaccinatedAt: LocalDate, + // Country of Vaccination, e.g. "co": "NL" + @SerializedName("co") val countryOfVaccination: String, + // Certificate Issuer, e.g. "is": "Ministry of Public Health, Welfare and Sport", + @SerializedName("is") val certificateIssuer: String, + // Unique Certificate Identifier, e.g. "ci": "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ" + @SerializedName("ci") val uniqueCertificateIdentifier: String + ) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/VaccinationServerModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/VaccinationServerModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..b609d87e49bcdb47968d08f6e58f961384381534 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/VaccinationServerModule.kt @@ -0,0 +1,13 @@ +package de.rki.coronawarnapp.vaccination.core.server + +import dagger.Module +import de.rki.coronawarnapp.vaccination.core.server.proof.VaccinationProofModule +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSetModule + +@Module( + includes = [ + VaccinationValueSetModule::class, + VaccinationProofModule::class + ] +) +abstract class VaccinationServerModule diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateCOSEParser.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateCOSEParser.kt new file mode 100644 index 0000000000000000000000000000000000000000..d226fa3d7066f437f846329d126a83090ffc3481 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateCOSEParser.kt @@ -0,0 +1,43 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import de.rki.coronawarnapp.vaccination.core.server.ProofCertificateV1 +import org.joda.time.Instant +import org.joda.time.LocalDate + +class ProofCertificateCOSEParser { + + fun parse(proofCOSE: RawCOSEObject): ProofCertificateData { + // TODO + val cert = ProofCertificateV1( + version = "1.0.0", + nameData = ProofCertificateV1.NameData( + givenName = "François-Joan", + givenNameStandardized = "FRANCOIS<JOAN", + familyName = "d'Arsøns - van Halen", + familyNameStandardized = "DARSONS<VAN<HALEN", + ), + dateOfBirth = LocalDate.parse("2009-02-28"), + vaccinationDatas = listOf( + ProofCertificateV1.VaccinationData( + targetId = "840539006", + vaccineId = "1119349007", + medicalProductId = "EU/1/20/1528", + marketAuthorizationHolderId = "ORG-100030215", + doseNumber = 2, + totalSeriesOfDoses = 2, + vaccinatedAt = LocalDate.parse("2021-04-22"), + countryOfVaccination = "DE", + certificateIssuer = "Ministry of Public Health, Welfare and Sport", + uniqueCertificateIdentifier = "urn:uvci:01:NL:THECAKEISALIE", + ) + ) + ) + return ProofCertificateData( + proofCertificate = cert, + issuedAt = Instant.EPOCH, + issuerCountryCode = "DE", + expiresAt = Instant.EPOCH + ) + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateData.kt new file mode 100644 index 0000000000000000000000000000000000000000..deef7b47e4ea145106a8de9653a3e31383e56e75 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateData.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +import de.rki.coronawarnapp.vaccination.core.server.ProofCertificateV1 +import org.joda.time.Instant + +/** + * Represents the information gained from data in COSE representation + */ +data class ProofCertificateData constructor( + // Parsed json + val proofCertificate: ProofCertificateV1, + // Issuer (2-letter country code) + val issuerCountryCode: String, + // Issued at (server data returns UNIX timestamp in seconds) + val issuedAt: Instant, + // Expiration time (server data returns UNIX timestamp in seconds) + val expiresAt: Instant, +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateException.kt new file mode 100644 index 0000000000000000000000000000000000000000..a7b10b17330ee9bbfcd829e481abc5115525aff5 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateException.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +import de.rki.coronawarnapp.vaccination.core.VaccinationException + +open class ProofCertificateException( + cause: Throwable?, + message: String +) : VaccinationException( + message = message, + cause = cause +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateResponse.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateResponse.kt new file mode 100644 index 0000000000000000000000000000000000000000..0e61941f3a3bf7c1c9a63be39ba102a904797acc --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateResponse.kt @@ -0,0 +1,10 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject + +interface ProofCertificateResponse { + val proofCertificateData: ProofCertificateData + + // COSE representation of the Proof Certificate (as byte sequence) + val proofCertificateCOSE: RawCOSEObject +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateServerData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateServerData.kt new file mode 100644 index 0000000000000000000000000000000000000000..644c05b091e7796d30aecafb43dd52523097ca6f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/ProofCertificateServerData.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +interface ProofCertificateServerData { + // Satisfy CI ¯\_(ツ)_/¯ +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofApiV1.kt new file mode 100644 index 0000000000000000000000000000000000000000..bb4c059786efd23bd5f877100103f35f68170aec --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofApiV1.kt @@ -0,0 +1,6 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +@Deprecated("Poor fella never was used once. Delete when everything is merged.") +interface VaccinationProofApiV1 { + // Satisfy CI ¯\_(ツ)_/¯ +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofApiV2.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofApiV2.kt new file mode 100644 index 0000000000000000000000000000000000000000..e90a9ece33ff0cc17c71421bc13370ed796e5783 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofApiV2.kt @@ -0,0 +1,23 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +import okio.ByteString +import retrofit2.http.Body +import retrofit2.http.Headers + +import retrofit2.http.POST + +interface VaccinationProofApiV2 { + + // Returns COSE representation (as byte sequence) of a Proof Certificate + @Headers("Content-Type: application/cbor") + @POST("/api/certify/v2/reissue/cbor") + suspend fun obtainProofCertificate(@Body cose: ByteString): ByteString + + // Returns string as for the QR Code of a Proof Certificate (starting with HC1: ) + @Headers( + "Content-Type: application/cbor", + "Accept: application/cbor+base45" + ) + @POST("/api/certify/v2/reissue/cbor") + suspend fun obtainProofCertificateBase45(@Body cose: ByteString): String +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofHttpClient.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofHttpClient.kt new file mode 100644 index 0000000000000000000000000000000000000000..a94a2c643210300f0b862786430ad3cb491eded8 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofHttpClient.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +import javax.inject.Qualifier + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class VaccinationProofHttpClient diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..0d5d820efd78a1cbad4b0d750989021112951fa5 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofModule.kt @@ -0,0 +1,31 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +import dagger.Module +import dagger.Provides +import dagger.Reusable +import de.rki.coronawarnapp.environment.vaccination.VaccinationCertificateProofServerUrl +import de.rki.coronawarnapp.http.HttpClientDefault +import okhttp3.OkHttpClient +import retrofit2.Retrofit + +@Module +class VaccinationProofModule { + + @Reusable + @VaccinationProofHttpClient + @Provides + fun httpClient( + @HttpClientDefault default: OkHttpClient + ): OkHttpClient = default.newBuilder().build() + + @Reusable + @Provides + fun api( + @VaccinationProofHttpClient httpClient: OkHttpClient, + @VaccinationCertificateProofServerUrl url: String + ): VaccinationProofApiV2 = Retrofit.Builder() + .client(httpClient) + .baseUrl(url) + .build() + .create(VaccinationProofApiV2::class.java) +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofServer.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofServer.kt new file mode 100644 index 0000000000000000000000000000000000000000..b9499cf0e91a46dbf7ea479c7ffdbd6b3ae4f375 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/proof/VaccinationProofServer.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.vaccination.core.server.proof + +import dagger.Reusable +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import javax.inject.Inject + +/** + * Talks with IBM servers? + */ +@Reusable +class VaccinationProofServer @Inject constructor() { + + suspend fun getProofCertificate( + vaccinationCertificate: RawCOSEObject + ): ProofCertificateResponse { + throw NotImplementedError() + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..cb64715a67d263d703c095a9e64ad11c925e006c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationServer.kt @@ -0,0 +1,26 @@ +package de.rki.coronawarnapp.vaccination.core.server.valueset + +import dagger.Reusable +import okhttp3.Cache +import timber.log.Timber +import java.util.Locale +import javax.inject.Inject + +/** + * Talks with CWA servers + */ +@Reusable +class VaccinationServer @Inject constructor( + @VaccinationValueSetHttpClient private val cache: Cache +) { + + suspend fun getVaccinationValueSets(languageCode: Locale): VaccinationValueSet { + throw NotImplementedError() + } + + fun clear() { + // Clear cache + Timber.d("Clearing cache") + cache.evictAll() + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..6bace45c474e8760501732e684653ab8c7360ea8 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSet.kt @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.vaccination.core.server.valueset + +import java.util.Locale + +interface VaccinationValueSet { + val languageCode: Locale + + fun getDisplayText(key: String): String? +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetApiV1.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetApiV1.kt new file mode 100644 index 0000000000000000000000000000000000000000..d30f63ada3e13187b20aa2952192c35b9ea9df20 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetApiV1.kt @@ -0,0 +1,12 @@ +package de.rki.coronawarnapp.vaccination.core.server.valueset + +import okhttp3.ResponseBody +import retrofit2.Response +import retrofit2.http.GET +import retrofit2.http.Path + +interface VaccinationValueSetApiV1 { + + @GET("/version/v1/ehn-dgc/{lang}/value-sets") + suspend fun getValueSets(@Path("lang") languageCode: String): Response<ResponseBody> +} 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/VaccinationValueSetHttpClient.kt new file mode 100644 index 0000000000000000000000000000000000000000..1650bcb5f61039656a1cc3e51485dacf03e5317b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetHttpClient.kt @@ -0,0 +1,8 @@ +package de.rki.coronawarnapp.vaccination.core.server.valueset + +import javax.inject.Qualifier + +@Qualifier +@MustBeDocumented +@Retention(AnnotationRetention.RUNTIME) +annotation class VaccinationValueSetHttpClient 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 new file mode 100644 index 0000000000000000000000000000000000000000..1a4d73746910ef349635ffe3e5e95f0c1a0f0807 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/core/server/valueset/VaccinationValueSetModule.kt @@ -0,0 +1,53 @@ +package de.rki.coronawarnapp.vaccination.core.server.valueset + +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.util.di.AppContext +import okhttp3.Cache +import okhttp3.OkHttpClient +import retrofit2.Retrofit +import java.io.File + +@Module +class VaccinationValueSetModule { + + @Reusable + @VaccinationValueSetHttpClient + @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) + } + + @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) + + 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/decoder/InvalidInputException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/InvalidInputException.kt new file mode 100644 index 0000000000000000000000000000000000000000..6e2604b4123120b91b1e0cde9d9ab0b4ace2649b --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/InvalidInputException.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.vaccination.decoder + +class InvalidInputException( + message: String = "An error occurred while decoding input." +) : Exception(message) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/ZLIBDecompressor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/ZLIBDecompressor.kt new file mode 100644 index 0000000000000000000000000000000000000000..b79a188440708d8f8f1f298e5096c91705e10a10 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/decoder/ZLIBDecompressor.kt @@ -0,0 +1,22 @@ +package de.rki.coronawarnapp.vaccination.decoder + +import timber.log.Timber +import java.util.zip.InflaterInputStream +import javax.inject.Inject + +class ZLIBDecompressor @Inject constructor() { + fun decompress(input: ByteArray): ByteArray = if ( + input.size >= 2 && + input[0] == 0x78.toByte() && + input[1] in listOf(0x01.toByte(), 0x5E.toByte(), 0x9C.toByte(), 0xDA.toByte()) + ) { + try { + input.inputStream().use { InflaterInputStream(it).readBytes() } + } catch (e: Throwable) { + Timber.e(e) + throw InvalidInputException("Zlib decompression failed.") + } + } else { + input + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/VaccinationUIModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/VaccinationUIModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..9c5e2af207f1b60145cd75413de8522179a03ad7 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/VaccinationUIModule.kt @@ -0,0 +1,23 @@ +package de.rki.coronawarnapp.vaccination.ui + +import dagger.Module +import dagger.android.ContributesAndroidInjector +import de.rki.coronawarnapp.vaccination.ui.details.VaccinationDetailsFragment +import de.rki.coronawarnapp.vaccination.ui.details.VaccinationDetailsFragmentModule +import de.rki.coronawarnapp.vaccination.ui.list.VaccinationListFragment +import de.rki.coronawarnapp.vaccination.ui.list.VaccinationListFragmentModule +import de.rki.coronawarnapp.vaccination.ui.scan.VaccinationQrCodeScanFragment +import de.rki.coronawarnapp.vaccination.ui.scan.VaccinationQrCodeScanModule + +@Module +abstract class VaccinationUIModule { + + @ContributesAndroidInjector(modules = [VaccinationListFragmentModule::class]) + abstract fun vaccinationListFragment(): VaccinationListFragment + + @ContributesAndroidInjector(modules = [VaccinationDetailsFragmentModule::class]) + abstract fun vaccinationDetailsFragment(): VaccinationDetailsFragment + + @ContributesAndroidInjector(modules = [VaccinationQrCodeScanModule::class]) + abstract fun vaccinationQrCodeScanFragment(): VaccinationQrCodeScanFragment +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..f5e711f748449adb4e3cdaf466be2c0f64ebc254 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragment.kt @@ -0,0 +1,122 @@ +package de.rki.coronawarnapp.vaccination.ui.details + +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.navArgs +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.bugreporting.ui.toErrorDialogBuilder +import de.rki.coronawarnapp.databinding.FragmentVaccinationDetailsBinding +import de.rki.coronawarnapp.ui.view.onOffsetChange +import de.rki.coronawarnapp.util.di.AutoInject +import de.rki.coronawarnapp.util.ui.popBackStack +import de.rki.coronawarnapp.util.ui.viewBindingLazy +import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider +import de.rki.coronawarnapp.util.viewmodel.cwaViewModelsAssisted +import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate +import org.joda.time.format.DateTimeFormat +import javax.inject.Inject + +class VaccinationDetailsFragment : Fragment(R.layout.fragment_vaccination_details), AutoInject { + + @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory + + private val args by navArgs<VaccinationDetailsFragmentArgs>() + private val binding: FragmentVaccinationDetailsBinding by viewBindingLazy() + private val viewModel: VaccinationDetailsViewModel by cwaViewModelsAssisted( + factoryProducer = { viewModelFactory }, + constructorCall = { factory, _ -> + factory as VaccinationDetailsViewModel.Factory + factory.create( + certificateId = args.vaccinationCertificateId, + ) + } + ) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) = + with(binding) { + toolbar.setNavigationOnClickListener { viewModel.onClose() } + deleteButton.setOnClickListener { showDeletionDialog() } + + viewModel.vaccinationCertificate.observe(viewLifecycleOwner) { + it.certificate?.let { certificate -> bindCertificateViews(certificate) } + val background = when { + it.isComplete -> R.drawable.vaccination_compelete_gradient + else -> R.drawable.vaccination_incomplete + } + expandedImage.setImageResource(background) + } + + appBarLayout.onOffsetChange { titleAlpha, subtitleAlpha -> + title.alpha = titleAlpha + subtitle.alpha = subtitleAlpha + } + setToolbarOverlay() + + viewModel.errors.observe(viewLifecycleOwner) { + it.toErrorDialogBuilder(requireContext()).show() + } + + viewModel.events.observe(viewLifecycleOwner) { + when (it) { + VaccinationDetailsNavigation.Back -> popBackStack() + } + } + } + + private fun showDeletionDialog() { + MaterialAlertDialogBuilder(requireContext()) + .setTitle(R.string.vaccination_details_deletion_dialog_title) + .setMessage(R.string.vaccination_details_deletion_dialog_message) + .setPositiveButton(R.string.vaccination_details_deletion_dialog_positive_button) { _, _ -> + viewModel.deleteVaccination() + } + .setNegativeButton(R.string.vaccination_details_deletion_dialog_negative_button) { _, _ -> + // No-Op + } + .show() + } + + private fun FragmentVaccinationDetailsBinding.bindCertificateViews( + certificate: VaccinationCertificate + ) { + name.text = certificate.run { "$firstName $lastName" } + birthDate.text = getString( + R.string.vaccination_details_birth_date, + certificate.dateOfBirth.toString(format) + ) + vaccinatedAt.text = certificate.vaccinatedAt.toString(format) + vaccineName.text = certificate.vaccineName + vaccineManufacturer.text = certificate.vaccineManufacturer + certificateIssuer.text = certificate.certificateIssuer + certificateCountry.text = certificate.certificateCountry.getLabel(requireContext()) + certificateId.text = certificate.certificateId + title.text = getString( + R.string.vaccination_details_title, + certificate.doseNumber, + certificate.totalSeriesOfDoses + ) + } + + private fun setToolbarOverlay() { + val width = requireContext().resources.displayMetrics.widthPixels + + val params: CoordinatorLayout.LayoutParams = binding.scrollView.layoutParams + as (CoordinatorLayout.LayoutParams) + + val textParams = binding.subtitle.layoutParams as (LinearLayout.LayoutParams) + textParams.bottomMargin = (width / 3) - 24 /* 24 is space between screen border and QrCode */ + binding.subtitle.requestLayout() /* 24 is space between screen border and QrCode */ + + val behavior: AppBarLayout.ScrollingViewBehavior = params.behavior as (AppBarLayout.ScrollingViewBehavior) + behavior.overlayTop = (width / 3) - 24 + } + + companion object { + private val format = DateTimeFormat.forPattern("dd.MM.yyyy") + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragmentModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragmentModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..62e9906d2577086ae40ee382f6e66fede9fa27f4 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsFragmentModule.kt @@ -0,0 +1,19 @@ +package de.rki.coronawarnapp.vaccination.ui.details + +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 VaccinationDetailsFragmentModule { + + @Binds + @IntoMap + @CWAViewModelKey(VaccinationDetailsViewModel::class) + abstract fun vaccinationDetailsFragment( + factory: VaccinationDetailsViewModel.Factory + ): CWAViewModelFactory<out CWAViewModel> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsNavigation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsNavigation.kt new file mode 100644 index 0000000000000000000000000000000000000000..6dbe067b2e9857d073ce0d722e2669e0a3ffa9eb --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsNavigation.kt @@ -0,0 +1,5 @@ +package de.rki.coronawarnapp.vaccination.ui.details + +sealed class VaccinationDetailsNavigation { + object Back : VaccinationDetailsNavigation() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..773ff8209f6896a3fc68a7c9bb34378b55297a17 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/details/VaccinationDetailsViewModel.kt @@ -0,0 +1,71 @@ +package de.rki.coronawarnapp.vaccination.ui.details + +import androidx.lifecycle.asLiveData +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +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.CWAViewModelFactory +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson +import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import kotlinx.coroutines.flow.map +import timber.log.Timber + +class VaccinationDetailsViewModel @AssistedInject constructor( + private val vaccinationRepository: VaccinationRepository, + @Assisted private val vaccinationCertificateId: String, + dispatcherProvider: DispatcherProvider, +) : CWAViewModel() { + + val vaccinationCertificate = vaccinationRepository.vaccinationInfos.map { + findVaccinationDetails(it) + }.asLiveData(context = dispatcherProvider.Default) + + val errors = SingleLiveEvent<Throwable>() + val events = SingleLiveEvent<VaccinationDetailsNavigation>() + + fun deleteVaccination() = launch { + try { + Timber.d("deleteVaccination") + vaccinationRepository.deleteVaccinationCertificate(vaccinationCertificateId) + events.postValue(VaccinationDetailsNavigation.Back) + } catch (e: Exception) { + Timber.d(e, "deleteVaccinationCertificate failed") + errors.postValue(e) + } + } + + fun onClose() { + events.postValue(VaccinationDetailsNavigation.Back) + } + + private fun findVaccinationDetails(vaccinatedPersons: Set<VaccinatedPerson>): VaccinationDetails { + val vaccinatedPerson = vaccinatedPersons.find { vaccinatedPerson -> + vaccinatedPerson + .vaccinationCertificates + .any { it.certificateId == vaccinationCertificateId } + } + + return VaccinationDetails( + certificate = vaccinatedPerson?.vaccinationCertificates?.find { + it.certificateId == vaccinationCertificateId + }, + isComplete = vaccinatedPerson?.vaccinationStatus == VaccinatedPerson.Status.COMPLETE + ) + } + + @AssistedFactory + interface Factory : CWAViewModelFactory<VaccinationDetailsViewModel> { + fun create( + certificateId: String, + ): VaccinationDetailsViewModel + } +} + +data class VaccinationDetails( + val certificate: VaccinationCertificate?, + val isComplete: Boolean = false +) diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/CompleteVaccinationHomeCard.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/CompleteVaccinationHomeCard.kt new file mode 100644 index 0000000000000000000000000000000000000000..f64b6f3fdaac4f755b4df64958c486619b5b135f --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/CompleteVaccinationHomeCard.kt @@ -0,0 +1,38 @@ +package de.rki.coronawarnapp.vaccination.ui.homecard + +import android.view.ViewGroup +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.VaccinationHomeCompleteCardBinding +import de.rki.coronawarnapp.ui.main.home.HomeAdapter +import de.rki.coronawarnapp.util.lists.diffutil.HasPayloadDiffer +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson + +class CompleteVaccinationHomeCard(parent: ViewGroup) : + HomeAdapter.HomeItemVH<CompleteVaccinationHomeCard.Item, VaccinationHomeCompleteCardBinding>( + R.layout.home_card_container_layout, + parent + ) { + + override val viewBinding = lazy { + VaccinationHomeCompleteCardBinding.inflate(layoutInflater, itemView.findViewById(R.id.card_container), true) + } + + override val onBindData: VaccinationHomeCompleteCardBinding.( + item: Item, + payloads: List<Any> + ) -> Unit = { item, payloads -> + val curItem = payloads.filterIsInstance<Item>().singleOrNull() ?: item + + personName.text = curItem.vaccinatedPerson.fullName + + itemView.setOnClickListener { curItem.onClickAction(item) } + } + + data class Item( + override val vaccinatedPerson: VaccinatedPerson, + val onClickAction: (Item) -> Unit, + ) : VaccinationStatusItem, HasPayloadDiffer { + + override fun diffPayload(old: Any, new: Any): Any? = if (old::class == new::class) new else null + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/CreateVaccinationHomeCard.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/CreateVaccinationHomeCard.kt new file mode 100644 index 0000000000000000000000000000000000000000..4f807e7ae20844dc457f4b45242ba32301eb9eab --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/CreateVaccinationHomeCard.kt @@ -0,0 +1,43 @@ +package de.rki.coronawarnapp.vaccination.ui.homecard + +import android.view.ViewGroup +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.VaccinationHomeRegistrationCardBinding +import de.rki.coronawarnapp.ui.main.home.HomeAdapter +import de.rki.coronawarnapp.ui.main.home.items.HomeItem +import de.rki.coronawarnapp.util.lists.diffutil.HasPayloadDiffer + +class CreateVaccinationHomeCard(parent: ViewGroup) : + HomeAdapter.HomeItemVH<CreateVaccinationHomeCard.Item, VaccinationHomeRegistrationCardBinding>( + R.layout.home_card_container_layout, + parent + ) { + + override val viewBinding = lazy { + VaccinationHomeRegistrationCardBinding.inflate(layoutInflater, itemView.findViewById(R.id.card_container), true) + } + + override val onBindData: VaccinationHomeRegistrationCardBinding.( + item: Item, + payloads: List<Any> + ) -> Unit = { item, payloads -> + + fun onClick() { + val curItem = payloads.filterIsInstance<Item>().singleOrNull() ?: item + curItem.onClickAction(item) + } + + itemView.setOnClickListener { + onClick() + } + nextStepsAction.setOnClickListener { + onClick() + } + } + + data class Item(val onClickAction: (Item) -> Unit) : HomeItem, HasPayloadDiffer { + override val stableId: Long = Item::class.java.name.hashCode().toLong() + + override fun diffPayload(old: Any, new: Any): Any? = if (old::class == new::class) new else null + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/IncompleteVaccinationHomeCard.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/IncompleteVaccinationHomeCard.kt new file mode 100644 index 0000000000000000000000000000000000000000..915eb6d6802170e12083d67b394a2b7cf623e55d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/IncompleteVaccinationHomeCard.kt @@ -0,0 +1,38 @@ +package de.rki.coronawarnapp.vaccination.ui.homecard + +import android.view.ViewGroup +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.VaccinationHomeIncompleteCardBinding +import de.rki.coronawarnapp.ui.main.home.HomeAdapter +import de.rki.coronawarnapp.util.lists.diffutil.HasPayloadDiffer +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson + +class IncompleteVaccinationHomeCard(parent: ViewGroup) : + HomeAdapter.HomeItemVH<IncompleteVaccinationHomeCard.Item, VaccinationHomeIncompleteCardBinding>( + R.layout.home_card_container_layout, + parent + ) { + + override val viewBinding = lazy { + VaccinationHomeIncompleteCardBinding.inflate(layoutInflater, itemView.findViewById(R.id.card_container), true) + } + + override val onBindData: VaccinationHomeIncompleteCardBinding.( + item: Item, + payloads: List<Any> + ) -> Unit = { item, payloads -> + val curItem = payloads.filterIsInstance<Item>().singleOrNull() ?: item + + personName.text = curItem.vaccinatedPerson.fullName + + itemView.setOnClickListener { curItem.onClickAction(item) } + } + + data class Item( + override val vaccinatedPerson: VaccinatedPerson, + val onClickAction: (Item) -> Unit, + ) : VaccinationStatusItem, HasPayloadDiffer { + + override fun diffPayload(old: Any, new: Any): Any? = if (old::class == new::class) new else null + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/VaccinationStatusItem.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/VaccinationStatusItem.kt new file mode 100644 index 0000000000000000000000000000000000000000..92bc3708f9d994c3fca82ab6ec648a092bae6dfb --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/homecard/VaccinationStatusItem.kt @@ -0,0 +1,11 @@ +package de.rki.coronawarnapp.vaccination.ui.homecard + +import de.rki.coronawarnapp.ui.main.home.items.HomeItem +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson + +interface VaccinationStatusItem : HomeItem { + val vaccinatedPerson: VaccinatedPerson + + override val stableId: Long + get() = vaccinatedPerson.identifier.hashCode().toLong() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..9cbb482bb5cd0c03cc9afa630d15a304909ba7fd --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListFragment.kt @@ -0,0 +1,126 @@ +package de.rki.coronawarnapp.vaccination.ui.list + +import android.os.Bundle +import android.view.View +import android.widget.LinearLayout +import android.widget.Toast +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.net.toUri +import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.navArgs +import com.google.android.material.appbar.AppBarLayout +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.FragmentVaccinationListBinding +import de.rki.coronawarnapp.ui.view.onOffsetChange +import de.rki.coronawarnapp.util.di.AutoInject +import de.rki.coronawarnapp.util.lists.diffutil.update +import de.rki.coronawarnapp.util.ui.doNavigate +import de.rki.coronawarnapp.util.ui.popBackStack +import de.rki.coronawarnapp.util.ui.viewBindingLazy +import de.rki.coronawarnapp.util.viewmodel.CWAViewModelFactoryProvider +import de.rki.coronawarnapp.util.viewmodel.cwaViewModelsAssisted +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson +import de.rki.coronawarnapp.vaccination.ui.list.VaccinationListViewModel.Event.NavigateToVaccinationCertificateDetails +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListAdapter +import javax.inject.Inject + +class VaccinationListFragment : Fragment(R.layout.fragment_vaccination_list), AutoInject { + + @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory + + private val args by navArgs<VaccinationListFragmentArgs>() + private val binding: FragmentVaccinationListBinding by viewBindingLazy() + private val viewModel: VaccinationListViewModel by cwaViewModelsAssisted( + factoryProducer = { viewModelFactory }, + constructorCall = { factory, _ -> + factory as VaccinationListViewModel.Factory + factory.create( + vaccinatedPersonIdentifier = args.vaccinatedPersonId + ) + } + ) + + private val adapter = VaccinationListAdapter() + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + with(binding) { + toolbar.setNavigationOnClickListener { + popBackStack() + } + + recyclerViewVaccinationList.adapter = adapter + + viewModel.uiState.observe(viewLifecycleOwner) { uiState -> + bindViews(uiState) + } + + viewModel.events.observe(viewLifecycleOwner) { event -> + when (event) { + is NavigateToVaccinationCertificateDetails -> doNavigate( + VaccinationListFragmentDirections + .actionVaccinationListFragmentToVaccinationDetailsFragment(event.vaccinationCertificateId) + ) + } + } + + registerNewVaccinationButton.setOnClickListener { + Toast.makeText(requireContext(), "TODO \uD83D\uDEA7", Toast.LENGTH_LONG).show() + } + + refreshButton.setOnClickListener { + Toast.makeText(requireContext(), "TODO \uD83D\uDEA7", Toast.LENGTH_LONG).show() + } + } + } + + private fun FragmentVaccinationListBinding.bindViews(uiState: VaccinationListViewModel.UiState) = with(uiState) { + + adapter.update(listItems) + + val isVaccinationComplete = vaccinationStatus == VaccinatedPerson.Status.COMPLETE + + val background = if (isVaccinationComplete) { + R.drawable.vaccination_compelete_gradient + } else { + R.drawable.vaccination_incomplete + } + + expandedImage.setImageResource(background) + + subtitle.isVisible = isVaccinationComplete + + appBarLayout.onOffsetChange { titleAlpha, subtitleAlpha -> + title.alpha = titleAlpha + subtitle.alpha = subtitleAlpha + } + + setToolbarOverlay(isVaccinationComplete) + } + + private fun setToolbarOverlay(isVaccinationComplete: Boolean) { + + // subtitle is only visible when vaccination is complete + val bottomTextView = if (isVaccinationComplete) binding.subtitle else binding.title + + val deviceWidth = requireContext().resources.displayMetrics.widthPixels + + val params: CoordinatorLayout.LayoutParams = binding.recyclerViewVaccinationList.layoutParams + as (CoordinatorLayout.LayoutParams) + + val textParams = bottomTextView.layoutParams as (LinearLayout.LayoutParams) + + val divider = if (isVaccinationComplete) 2 else 3 + textParams.bottomMargin = (deviceWidth / divider) - 24 /* 24 is space between screen border and Card */ + bottomTextView.requestLayout() + + val behavior: AppBarLayout.ScrollingViewBehavior = params.behavior as (AppBarLayout.ScrollingViewBehavior) + behavior.overlayTop = (deviceWidth / divider) - 24 + } + + companion object { + fun navigationUri(personIdentifier: String) = "coronawarnapp://vaccination-list/$personIdentifier".toUri() + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListFragmentModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListFragmentModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..86c2cb094926468e4163fcdc2c637f1d04ac2ca6 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListFragmentModule.kt @@ -0,0 +1,19 @@ +package de.rki.coronawarnapp.vaccination.ui.list + +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 VaccinationListFragmentModule { + + @Binds + @IntoMap + @CWAViewModelKey(VaccinationListViewModel::class) + abstract fun vaccinationListFragment( + factory: VaccinationListViewModel.Factory + ): CWAViewModelFactory<out CWAViewModel> +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..dada5d17400c77d54a2350006b3a6d8faf81130c --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationListViewModel.kt @@ -0,0 +1,168 @@ +package de.rki.coronawarnapp.vaccination.ui.list + +import android.graphics.Bitmap +import androidx.lifecycle.LiveData +import androidx.lifecycle.asLiveData +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import de.rki.coronawarnapp.presencetracing.checkins.qrcode.QrCodeGenerator +import de.rki.coronawarnapp.util.TimeAndDateExtensions.toDayFormat +import de.rki.coronawarnapp.util.TimeAndDateExtensions.toLocalDateUserTz +import de.rki.coronawarnapp.util.TimeStamper +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.ProofCertificate +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson.Status.COMPLETE +import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListCertificateCardItemVH.VaccinationListCertificateCardItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListIncompleteTopCardItemVH.VaccinationListIncompleteTopCardItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListNameCardItemVH.VaccinationListNameCardItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListVaccinationCardItemVH.VaccinationListVaccinationCardItem +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.transform +import org.joda.time.Days +import org.joda.time.LocalDate + +class VaccinationListViewModel @AssistedInject constructor( + private val vaccinationRepository: VaccinationRepository, + private val timeStamper: TimeStamper, + private val qrCodeGenerator: QrCodeGenerator, + @Assisted private val vaccinatedPersonIdentifier: String +) : CWAViewModel() { + + val events = SingleLiveEvent<Event>() + + val vaccinationInfoFlow = vaccinationRepository.vaccinationInfos.map { vaccinatedPersonSet -> + // TODO: use the line below once the repository returns actual values + // val vaccinatedPerson = vaccinatedPersonSet.single { it.identifier.code == vaccinatedPersonIdentifier } + } + + private val proofQrCode: Flow<Bitmap?> = vaccinationRepository.vaccinationInfos.transform { vaccinationInfos -> + + emit(null) + + // TODO: use actual values from repository instead of these mocked ones + val proofCertificates = setOf( + getMockProofCertificate() + ) + + if (proofCertificates.isNotEmpty()) { + emit(qrCodeGenerator.createQrCode("TODO create qrCode from actual value")) + } + } + + val uiState: LiveData<UiState> = combine(vaccinationInfoFlow, proofQrCode) { vaccinatedPerson, proofQrCode -> + + // For now, use mock data + val vaccinationStatus = COMPLETE + // val vaccinationStatus = COMPLETE + + val vaccinationCertificates = setOf( + getMockVaccinationCertificate(), + getMockVaccinationCertificate().copy( + doseNumber = 2 + ) + ) + + val proofCertificates = setOf( + getMockProofCertificate() + ) + + val listItems = assembleItemList( + vaccinationCertificates = vaccinationCertificates, + proofCertificates = proofCertificates, + firstName = "François-Joan", + lastName = "d'Arsøns - van Halen", + dateOfBirth = LocalDate.parse("2009-02-28"), + vaccinationStatus, + proofQrCode + ) + + UiState( + listItems, + vaccinationStatus = vaccinationStatus + ) + }.catch { + // TODO Error Handling in an upcoming subtask + }.asLiveData() + + // TODO: after using actual values from the repository, we only pass VaccinatedPerson here instead of all these + // arguments + @Suppress("LongParameterList") + private fun assembleItemList( + vaccinationCertificates: Set<VaccinationCertificate>, + proofCertificates: Set<ProofCertificate>, + firstName: String, + lastName: String, + dateOfBirth: LocalDate, + vaccinationStatus: VaccinatedPerson.Status, + proofQrCode: Bitmap? + ) = mutableListOf<VaccinationListItem>().apply { + if (vaccinationStatus == COMPLETE) { + if (proofCertificates.isNotEmpty()) { + + val proofCertificate = proofCertificates.first() + val expiresAt = proofCertificate.expiresAt.toLocalDateUserTz() + val today = timeStamper.nowUTC.toLocalDateUserTz() + val remainingValidityInDays = Days.daysBetween(today, expiresAt).days + + add( + VaccinationListCertificateCardItem( + qrCode = proofQrCode, + remainingValidityInDays = remainingValidityInDays + ) + ) + } + } else { + add(VaccinationListIncompleteTopCardItem) + } + add( + VaccinationListNameCardItem( + fullName = "$firstName $lastName", + dayOfBirth = dateOfBirth.toDayFormat() + ) + ) + vaccinationCertificates.forEach { vaccinationCertificate -> + with(vaccinationCertificate) { + add( + VaccinationListVaccinationCardItem( + vaccinationCertificateId = certificateId, + doseNumber = doseNumber.toString(), + totalSeriesOfDoses = totalSeriesOfDoses.toString(), + vaccinatedAt = vaccinatedAt.toDayFormat(), + vaccinationStatus = vaccinationStatus, + isFinalVaccination = + doseNumber == totalSeriesOfDoses, + onCardClick = { certificateId -> + events.postValue(Event.NavigateToVaccinationCertificateDetails(certificateId)) + } + ) + ) + } + } + }.toList() + + data class UiState( + val listItems: List<VaccinationListItem>, + val vaccinationStatus: VaccinatedPerson.Status + ) + + sealed class Event { + data class NavigateToVaccinationCertificateDetails(val vaccinationCertificateId: String) : Event() + } + + @AssistedFactory + interface Factory : CWAViewModelFactory<VaccinationListViewModel> { + fun create( + vaccinatedPersonIdentifier: String + ): VaccinationListViewModel + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationMockData.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationMockData.kt new file mode 100644 index 0000000000000000000000000000000000000000..b8d84a56cd84b5e2c5036f29e2658694d7517afe --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/VaccinationMockData.kt @@ -0,0 +1,83 @@ +package de.rki.coronawarnapp.vaccination.ui.list + +import de.rki.coronawarnapp.ui.Country +import de.rki.coronawarnapp.vaccination.core.ProofCertificate +import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier +import de.rki.coronawarnapp.vaccination.core.VaccinationCertificate +import org.joda.time.Duration +import org.joda.time.Instant +import org.joda.time.LocalDate + +/** + * Mock Data needed for UI development while backend connection is not yet available + * Can be removed later + */ +internal fun getMockVaccinationCertificate() = MockVaccinationCertificate( + firstName = "François-Joan", + lastName = "FRANCOIS<JOAN", + dateOfBirth = LocalDate.parse("2009-02-28"), + vaccinatedAt = LocalDate.parse("2021-04-22"), + vaccineName = "vaccineName", + vaccineManufacturer = "vaccineManufactorer", + medicalProductName = "medicalProductName", + doseNumber = 1, + totalSeriesOfDoses = 2, + certificateIssuer = "certificateIssuer", + certificateCountry = Country.AT, + certificateId = "certificate Id", + personIdentifier = getPersonIdentifier() +) + +internal fun getMockProofCertificate() = MockProofCertificate( + personIdentifier = getPersonIdentifier(), + expiresAt = Instant.now().plus(Duration.standardDays(3)), + firstName = "François-Joan", + lastName = "FRANCOIS<JOAN", + dateOfBirth = LocalDate.parse("2009-02-28"), + vaccinatedAt = LocalDate.parse("2021-04-22"), + vaccineName = "vaccineName", + vaccineManufacturer = "vaccineManufactorer", + medicalProductName = "medicalProductName", + doseNumber = 1, + totalSeriesOfDoses = 2, + certificateIssuer = "certificateIssuer", + certificateId = "certificate Id" +) + +fun getPersonIdentifier() = VaccinatedPersonIdentifier( + dateOfBirth = LocalDate.parse("2009-02-28"), + lastNameStandardized = "DARSONS<VAN<HALEN", + firstNameStandardized = "FRANCOIS<JOAN" +) + +internal data class MockProofCertificate( + override val personIdentifier: VaccinatedPersonIdentifier, + override val expiresAt: Instant, + override val firstName: String?, + override val lastName: String, + override val dateOfBirth: LocalDate, + override val vaccineName: String, + override val medicalProductName: String, + override val vaccineManufacturer: String, + override val doseNumber: Int, + override val totalSeriesOfDoses: Int, + override val vaccinatedAt: LocalDate, + override val certificateIssuer: String, + override val certificateId: String +) : ProofCertificate + +internal data class MockVaccinationCertificate( + override val firstName: String?, + override val lastName: String, + override val dateOfBirth: LocalDate, + override val vaccinatedAt: LocalDate, + override val vaccineName: String, + override val vaccineManufacturer: String, + override val medicalProductName: String, + override val doseNumber: Int, + override val totalSeriesOfDoses: Int, + override val certificateIssuer: String, + override val certificateCountry: Country, + override val certificateId: String, + override val personIdentifier: VaccinatedPersonIdentifier +) : VaccinationCertificate diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/VaccinationListAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/VaccinationListAdapter.kt new file mode 100644 index 0000000000000000000000000000000000000000..e152edbe93f9e2b9831784520bd909d3df61aa0d --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/VaccinationListAdapter.kt @@ -0,0 +1,60 @@ +package de.rki.coronawarnapp.vaccination.ui.list.adapter + +import android.view.ViewGroup +import androidx.annotation.LayoutRes +import androidx.viewbinding.ViewBinding +import de.rki.coronawarnapp.util.lists.BindableVH +import de.rki.coronawarnapp.util.lists.HasStableId +import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffUtilAdapter +import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffer +import de.rki.coronawarnapp.util.lists.modular.ModularAdapter +import de.rki.coronawarnapp.util.lists.modular.mods.DataBinderMod +import de.rki.coronawarnapp.util.lists.modular.mods.StableIdMod +import de.rki.coronawarnapp.util.lists.modular.mods.TypedVHCreatorMod +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListCertificateCardItemVH +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListCertificateCardItemVH.VaccinationListCertificateCardItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListIncompleteTopCardItemVH +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListIncompleteTopCardItemVH.VaccinationListIncompleteTopCardItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListNameCardItemVH +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListNameCardItemVH.VaccinationListNameCardItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListVaccinationCardItemVH +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListVaccinationCardItemVH.VaccinationListVaccinationCardItem + +class VaccinationListAdapter : + ModularAdapter<VaccinationListAdapter.ItemVH<VaccinationListItem, ViewBinding>>(), + AsyncDiffUtilAdapter<VaccinationListItem> { + + override val asyncDiffer: AsyncDiffer<VaccinationListItem> = AsyncDiffer(adapter = this) + + init { + modules.addAll( + listOf( + StableIdMod(data), + DataBinderMod<VaccinationListItem, ItemVH<VaccinationListItem, ViewBinding>>(data), + TypedVHCreatorMod({ data[it] is VaccinationListIncompleteTopCardItem }) { + VaccinationListIncompleteTopCardItemVH(it) + }, + TypedVHCreatorMod({ data[it] is VaccinationListNameCardItem }) { + VaccinationListNameCardItemVH(it) + }, + TypedVHCreatorMod({ data[it] is VaccinationListVaccinationCardItem }) { + VaccinationListVaccinationCardItemVH(it) + }, + TypedVHCreatorMod({ data[it] is VaccinationListCertificateCardItem }) { + VaccinationListCertificateCardItemVH(it) + } + ) + ) + } + + override fun getItemCount(): Int { + return data.size + } + + abstract class ItemVH<Item : VaccinationListItem, VB : ViewBinding>( + @LayoutRes layoutRes: Int, + parent: ViewGroup + ) : ModularAdapter.VH(layoutRes, parent), BindableVH<Item, VB> +} + +interface VaccinationListItem : HasStableId diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListCertificateCardItemVH.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListCertificateCardItemVH.kt new file mode 100644 index 0000000000000000000000000000000000000000..2a67a41e404edcc5b9eb0a2311299744873a81ef --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListCertificateCardItemVH.kt @@ -0,0 +1,43 @@ +package de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder + +import android.graphics.Bitmap +import android.view.ViewGroup +import androidx.core.view.isVisible +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.VaccinationListCertificateCardBinding +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListAdapter +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListCertificateCardItemVH.VaccinationListCertificateCardItem + +class VaccinationListCertificateCardItemVH(parent: ViewGroup) : + VaccinationListAdapter.ItemVH<VaccinationListCertificateCardItem, VaccinationListCertificateCardBinding>( + layoutRes = R.layout.vaccination_list_certificate_card, + parent = parent + ) { + + override val viewBinding: Lazy<VaccinationListCertificateCardBinding> = lazy { + VaccinationListCertificateCardBinding.bind(itemView) + } + + override val onBindData: VaccinationListCertificateCardBinding + .(item: VaccinationListCertificateCardItem, payloads: List<Any>) -> Unit = + { item, _ -> + when (item.qrCode) { + null -> progressBar.isVisible = true + else -> { + qrCodeImage.setImageBitmap(item.qrCode) + progressBar.isVisible = false + } + } + certificateCardSubtitle.text = + context.getString(R.string.vaccination_list_certificate_card_subtitle, item.remainingValidityInDays) + } + + data class VaccinationListCertificateCardItem( + val qrCode: Bitmap?, + val remainingValidityInDays: Int + ) : + VaccinationListItem { + override val stableId = this.hashCode().toLong() + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListIncompleteTopCardItemVH.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListIncompleteTopCardItemVH.kt new file mode 100644 index 0000000000000000000000000000000000000000..dd9ec7bbad1078c76c6c71fb7024451a82165389 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListIncompleteTopCardItemVH.kt @@ -0,0 +1,27 @@ +package de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder + +import android.view.ViewGroup +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.VaccinationListIncompleteTopCardBinding +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListAdapter +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListIncompleteTopCardItemVH.VaccinationListIncompleteTopCardItem + +class VaccinationListIncompleteTopCardItemVH(parent: ViewGroup) : + VaccinationListAdapter.ItemVH<VaccinationListIncompleteTopCardItem, VaccinationListIncompleteTopCardBinding>( + layoutRes = R.layout.vaccination_list_incomplete_top_card, + parent = parent + ) { + + override val viewBinding: Lazy<VaccinationListIncompleteTopCardBinding> = lazy { + VaccinationListIncompleteTopCardBinding.bind(itemView) + } + + override val onBindData: VaccinationListIncompleteTopCardBinding + .(item: VaccinationListIncompleteTopCardItem, payloads: List<Any>) -> Unit = { _, _ -> // NOOP + } + + object VaccinationListIncompleteTopCardItem : VaccinationListItem { + override val stableId = this.hashCode().toLong() + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListNameCardItemVH.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListNameCardItemVH.kt new file mode 100644 index 0000000000000000000000000000000000000000..5cd1501ca2a27854aeedeb23575a690a5938ec3e --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListNameCardItemVH.kt @@ -0,0 +1,33 @@ +package de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder + +import android.view.ViewGroup +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.VaccinationListNameCardBinding +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListAdapter +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListNameCardItemVH.VaccinationListNameCardItem + +class VaccinationListNameCardItemVH(parent: ViewGroup) : + VaccinationListAdapter.ItemVH<VaccinationListNameCardItem, VaccinationListNameCardBinding>( + layoutRes = R.layout.vaccination_list_name_card, + parent = parent + ) { + + override val viewBinding: Lazy<VaccinationListNameCardBinding> = lazy { + VaccinationListNameCardBinding.bind(itemView) + } + + override val onBindData: VaccinationListNameCardBinding + .(item: VaccinationListNameCardItem, payloads: List<Any>) -> Unit = + { item, _ -> + nameCardTitle.text = item.fullName + nameCardSubtitle.text = context.getString( + R.string.vaccination_list_name_card_subtitle, + item.dayOfBirth + ) + } + + data class VaccinationListNameCardItem(val fullName: String, val dayOfBirth: String) : VaccinationListItem { + override val stableId = this.hashCode().toLong() + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListVaccinationCardItemVH.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListVaccinationCardItemVH.kt new file mode 100644 index 0000000000000000000000000000000000000000..340a86e7aa457543dce724528792acd5c775e8d8 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/list/adapter/viewholder/VaccinationListVaccinationCardItemVH.kt @@ -0,0 +1,111 @@ +package de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder + +import android.view.ViewGroup +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.databinding.VaccinationListVaccinationCardBinding +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson.Status.COMPLETE +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson.Status.INCOMPLETE +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListAdapter +import de.rki.coronawarnapp.vaccination.ui.list.adapter.VaccinationListItem +import de.rki.coronawarnapp.vaccination.ui.list.adapter.viewholder.VaccinationListVaccinationCardItemVH.VaccinationListVaccinationCardItem +import java.util.Objects + +class VaccinationListVaccinationCardItemVH( + parent: ViewGroup, +) : + VaccinationListAdapter.ItemVH<VaccinationListVaccinationCardItem, VaccinationListVaccinationCardBinding>( + layoutRes = R.layout.vaccination_list_vaccination_card, + parent = parent + ) { + + override val viewBinding: Lazy<VaccinationListVaccinationCardBinding> = lazy { + VaccinationListVaccinationCardBinding.bind(itemView) + } + override val onBindData: + VaccinationListVaccinationCardBinding.(item: VaccinationListVaccinationCardItem, payloads: List<Any>) -> Unit = + { item, _ -> + with(item) { + root.setOnClickListener { + onCardClick.invoke(vaccinationCertificateId) + } + vaccinationCardTitle.text = context.getString( + R.string.vaccination_list_vaccination_card_title, + doseNumber, + totalSeriesOfDoses + ) + vaccinationCardSubtitle.text = context.getString( + R.string.vaccination_list_vaccination_card_subtitle, + vaccinatedAt + ) + + val iconRes = when (item.vaccinationStatus) { + INCOMPLETE -> { + if (isFinalVaccination) { + R.drawable.ic_vaccination_incomplete_final + } else { + R.drawable.ic_vaccination_incomplete + } + } + COMPLETE -> { + if (isFinalVaccination) { + R.drawable.ic_vaccination_complete_final + } else { + R.drawable.ic_vaccination_complete + } + } + } + vaccinationIcon.setImageResource(iconRes) + } + } + + data class VaccinationListVaccinationCardItem( + val vaccinationCertificateId: String, + val doseNumber: String, + val totalSeriesOfDoses: String, + val vaccinatedAt: String, + val vaccinationStatus: VaccinatedPerson.Status, + val isFinalVaccination: Boolean, + val onCardClick: (String) -> Unit + ) : VaccinationListItem { + + override val stableId: Long = Objects.hash( + vaccinationCertificateId, + doseNumber, + totalSeriesOfDoses, + vaccinatedAt, + vaccinationStatus, + isFinalVaccination + ).toLong() + + // Ignore onCardClick Listener in equals() to avoid re-drawing when only the click listener is updated + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as VaccinationListVaccinationCardItem + + if (vaccinationCertificateId != other.vaccinationCertificateId) return false + if (doseNumber != other.doseNumber) return false + if (totalSeriesOfDoses != other.totalSeriesOfDoses) return false + if (vaccinatedAt != other.vaccinatedAt) return false + if (vaccinationStatus != other.vaccinationStatus) return false + if (isFinalVaccination != other.isFinalVaccination) return false + if (stableId != other.stableId) return false + + return true + } + + // Ignore onCardClick Listener in equals() to avoid re-drawing when only the click listener is updated + override fun hashCode(): Int { + var result = vaccinationCertificateId.hashCode() + result = 31 * result + doseNumber.hashCode() + result = 31 * result + totalSeriesOfDoses.hashCode() + result = 31 * result + vaccinatedAt.hashCode() + result = 31 * result + vaccinationStatus.hashCode() + result = 31 * result + isFinalVaccination.hashCode() + result = 31 * result + stableId.hashCode() + return result + } + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanFragment.kt new file mode 100644 index 0000000000000000000000000000000000000000..bc4c36ec027b8316284d668df909b65be66eb821 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanFragment.kt @@ -0,0 +1,163 @@ +package de.rki.coronawarnapp.vaccination.ui.scan + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Bundle +import android.view.View +import android.view.accessibility.AccessibilityEvent.TYPE_ANNOUNCEMENT +import androidx.fragment.app.Fragment +import com.google.zxing.BarcodeFormat +import com.journeyapps.barcodescanner.DefaultDecoderFactory +import de.rki.coronawarnapp.R +import de.rki.coronawarnapp.bugreporting.ui.toErrorDialogBuilder +import de.rki.coronawarnapp.databinding.FragmentScanQrCodeBinding +import de.rki.coronawarnapp.util.DialogHelper +import de.rki.coronawarnapp.util.di.AutoInject +import de.rki.coronawarnapp.util.permission.CameraPermissionHelper +import de.rki.coronawarnapp.util.ui.doNavigate +import de.rki.coronawarnapp.util.ui.popBackStack +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 + +class VaccinationQrCodeScanFragment : + Fragment(R.layout.fragment_scan_qr_code), + AutoInject { + + @Inject lateinit var viewModelFactory: CWAViewModelFactoryProvider.Factory + private val viewModel: VaccinationQrCodeScanViewModel by cwaViewModels { viewModelFactory } + + private val binding: FragmentScanQrCodeBinding by viewBindingLazy() + private var showsPermissionDialog = false + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle? + ) { + with(binding) { + qrCodeScanTorch.setOnCheckedChangeListener { _, isChecked -> + binding.qrCodeScanPreview.setTorch(isChecked) + } + + qrCodeScanToolbar.setNavigationOnClickListener { popBackStack() } + qrCodeScanPreview.decoderFactory = DefaultDecoderFactory(listOf(BarcodeFormat.QR_CODE)) + qrCodeScanViewfinderView.setCameraPreview(binding.qrCodeScanPreview) + qrCodeScanSpinner.hide() + } + + viewModel.event.observe(viewLifecycleOwner) { event -> + when (event) { + is VaccinationQrCodeScanViewModel.Event.QrCodeScanSucceeded -> { + binding.qrCodeScanSpinner.hide() + doNavigate( + VaccinationQrCodeScanFragmentDirections + .actionVaccinationQrCodeScanFragmentToVaccinationDetailsFragment(event.certificateId) + ) + } + VaccinationQrCodeScanViewModel.Event.QrCodeScanInProgress -> { + binding.qrCodeScanSpinner.show() + } + } + } + + viewModel.errorEvent.observe(viewLifecycleOwner) { + binding.qrCodeScanSpinner.hide() + it.toErrorDialogBuilder(requireContext()).apply { + setOnDismissListener { popBackStack() } + }.show() + } + } + + override fun onResume() { + super.onResume() + binding.checkInQrCodeScanContainer.sendAccessibilityEvent(TYPE_ANNOUNCEMENT) + if (CameraPermissionHelper.hasCameraPermission(requireActivity())) { + binding.qrCodeScanPreview.resume() + startDecode() + return + } + if (showsPermissionDialog) return + + requestCameraPermission() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array<String>, + grantResults: IntArray + ) { + if (requestCode == REQUEST_CAMERA_PERMISSION_CODE && + grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_DENIED + ) { + if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) { + showCameraPermissionRationaleDialog() + viewModel.setCameraDeniedPermanently(false) + } else { + // User permanently denied access to the camera + showCameraPermissionDeniedDialog() + viewModel.setCameraDeniedPermanently(true) + } + } + } + + private fun startDecode() = binding.qrCodeScanPreview + .decodeSingle { barcodeResult -> + viewModel.onScanResult(barcodeResult) + } + + private fun showCameraPermissionDeniedDialog() { + val permissionDeniedDialog = DialogHelper.DialogInstance( + requireActivity(), + R.string.submission_qr_code_scan_permission_denied_dialog_headline, + R.string.submission_qr_code_scan_permission_denied_dialog_body, + R.string.submission_qr_code_scan_permission_denied_dialog_button, + cancelable = false, + positiveButtonFunction = { + leave() + } + ) + showsPermissionDialog = true + DialogHelper.showDialog(permissionDeniedDialog) + } + + private fun showCameraPermissionRationaleDialog() { + val cameraPermissionRationaleDialogInstance = DialogHelper.DialogInstance( + requireActivity(), + R.string.submission_qr_code_scan_permission_rationale_dialog_headline, + R.string.submission_qr_code_scan_permission_rationale_dialog_body, + R.string.submission_qr_code_scan_permission_rationale_dialog_button_positive, + R.string.submission_qr_code_scan_permission_rationale_dialog_button_negative, + false, + positiveButtonFunction = { + showsPermissionDialog = false + requestCameraPermission() + }, + negativeButtonFunction = { + leave() + } + ) + + showsPermissionDialog = true + DialogHelper.showDialog(cameraPermissionRationaleDialogInstance) + } + + private fun requestCameraPermission() = requestPermissions( + arrayOf(Manifest.permission.CAMERA), + REQUEST_CAMERA_PERMISSION_CODE + ) + + private fun leave() { + showsPermissionDialog = false + popBackStack() + } + + override fun onPause() { + super.onPause() + binding.qrCodeScanPreview.pause() + } + + companion object { + private const val REQUEST_CAMERA_PERMISSION_CODE = 4000 + } +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanModule.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanModule.kt new file mode 100644 index 0000000000000000000000000000000000000000..aa5cd02dfd000d4e78466d6a5e9d5fd85f072ea7 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanModule.kt @@ -0,0 +1,18 @@ +package de.rki.coronawarnapp.vaccination.ui.scan + +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 VaccinationQrCodeScanModule { + @Binds + @IntoMap + @CWAViewModelKey(VaccinationQrCodeScanViewModel::class) + abstract fun vaccinationQrCodeScanFragment( + factory: VaccinationQrCodeScanViewModel.Factory + ): CWAViewModelFactory<out CWAViewModel> +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanViewModel.kt new file mode 100644 index 0000000000000000000000000000000000000000..17b6d03349a9e7f953e69f55d66a0038fe6eb898 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/vaccination/ui/scan/VaccinationQrCodeScanViewModel.kt @@ -0,0 +1,47 @@ +package de.rki.coronawarnapp.vaccination.ui.scan + +import com.journeyapps.barcodescanner.BarcodeResult +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import de.rki.coronawarnapp.util.permission.CameraSettings +import de.rki.coronawarnapp.util.ui.SingleLiveEvent +import de.rki.coronawarnapp.util.viewmodel.CWAViewModel +import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationQRCodeValidator +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import timber.log.Timber + +class VaccinationQrCodeScanViewModel @AssistedInject constructor( + private val cameraSettings: CameraSettings, + private val vaccinationQRCodeValidator: VaccinationQRCodeValidator, + private val vaccinationRepository: VaccinationRepository +) : CWAViewModel() { + + val event = SingleLiveEvent<Event>() + + val errorEvent = SingleLiveEvent<Throwable>() + + fun onScanResult(barcodeResult: BarcodeResult) = launch { + try { + event.postValue(Event.QrCodeScanInProgress) + val qrCode = vaccinationQRCodeValidator.validate(barcodeResult.text) + val certificate = vaccinationRepository.registerVaccination(qrCode) + event.postValue(Event.QrCodeScanSucceeded(certificate.certificateId)) + } catch (e: Throwable) { + errorEvent.postValue(e) + } + } + + fun setCameraDeniedPermanently(denied: Boolean) { + Timber.d("setCameraDeniedPermanently(denied=$denied)") + cameraSettings.isCameraDeniedPermanently.update { denied } + } + + sealed class Event { + object QrCodeScanInProgress : Event() + data class QrCodeScanSucceeded(val certificateId: String) : Event() + } + + @AssistedFactory + interface Factory : SimpleCWAViewModelFactory<VaccinationQrCodeScanViewModel> +} diff --git a/Corona-Warn-App/src/main/res/drawable-night/ic_arrow_right_grey.xml b/Corona-Warn-App/src/main/res/drawable-night/ic_arrow_right_grey.xml new file mode 100644 index 0000000000000000000000000000000000000000..40001f61b41bcc20118ae570253dd3b25a46ed85 --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable-night/ic_arrow_right_grey.xml @@ -0,0 +1,10 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="19dp" + android:height="16dp" + android:viewportWidth="19" + android:viewportHeight="16"> + <path + android:pathData="M11.9186,0.7474L10.4286,2.3179L14.9685,7.103H0.9918V9.197H14.9685L10.4286,13.9821L11.9186,15.5525L18.9419,8.15L11.9186,0.7474Z" + android:fillColor="#A7A7A7" + android:fillType="evenOdd"/> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable-night/vaccination_card_icon_registration.xml b/Corona-Warn-App/src/main/res/drawable-night/vaccination_card_icon_registration.xml new file mode 100644 index 0000000000000000000000000000000000000000..50b94148be3b91e2f1da06b50ac8334f96c072aa --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable-night/vaccination_card_icon_registration.xml @@ -0,0 +1,17 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="78dp" + android:height="72dp" + android:viewportWidth="78" + android:viewportHeight="72"> + <path + android:pathData="M44.2502,0V4.3846H46.4425V6.7139C42.0321,7.1507 38.05,8.8463 34.7958,11.5096L33.1516,9.8654L34.6588,8.3582L31.5759,5.2752L25.41,11.4411L28.4929,14.524L30.0001,13.0168L31.6444,14.6611C28.9811,17.9153 27.2855,21.8974 26.8487,26.3077H24.5194V24.1154H20.1348V32.8846H24.5194V30.6923H26.8487C27.2855,35.1369 29.0153,39.1447 31.7129,42.4075L30.0001,43.9832L28.4929,42.476L25.41,45.5589L31.5759,51.7248L34.6588,48.6418L33.1516,47.1346L34.7958,45.4219C38.0586,48.1023 42.0236,49.8493 46.4425,50.2861V52.6154H44.2502V57H53.0194V52.6154H50.8271V50.2861C55.2459,49.8493 59.2109,48.1023 62.4737,45.4219H62.5422L64.1179,47.1346L62.6107,48.6418L65.6937,51.7248L71.8595,45.5589L68.7766,42.476L67.2694,43.9832L65.5566,42.4075C65.5609,42.3989 65.5588,42.386 65.5566,42.3732C65.5545,42.3604 65.5524,42.3475 65.5566,42.3389C68.2371,39.0762 69.9841,35.1112 70.4208,30.6923H72.7501V32.8846H77.1348V24.1154H72.7501V26.3077H70.4208C69.9841,21.8974 68.2885,17.9153 65.6251,14.6611L67.2694,13.0168L68.7766,14.524L71.8595,11.4411L65.6937,5.2752L62.6107,8.3582L64.1179,9.8654L62.4737,11.5096C59.2195,8.8463 55.2374,7.1507 50.8271,6.7139V4.3846H53.0194V0H44.2502ZM48.6352,10.9604C58.3465,10.9604 66.1737,18.7876 66.1737,28.4989C66.1737,38.2101 58.3465,46.0373 48.6352,46.0373C38.924,46.0373 31.0968,38.2101 31.0968,28.4989C31.0968,18.7876 38.924,10.9604 48.6352,10.9604ZM39.8653,18.9777C39.8653,17.1622 41.3382,15.6893 43.1537,15.6893C44.9692,15.6893 46.4422,17.1622 46.4422,18.9777C46.4422,20.7933 44.9692,22.2662 43.1537,22.2662C41.3382,22.2662 39.8653,20.7933 39.8653,18.9777ZM54.1157,15.6893C52.3002,15.6893 50.8273,17.1622 50.8273,18.9777C50.8273,20.7933 52.3002,22.2662 54.1157,22.2662C55.9312,22.2662 57.4042,20.7933 57.4042,18.9777C57.4042,17.1622 55.9312,15.6893 54.1157,15.6893ZM34.3848,28.499C34.3848,26.6834 35.8578,25.2105 37.6733,25.2105C39.4888,25.2105 40.9618,26.6834 40.9618,28.499C40.9618,30.3145 39.4888,31.7874 37.6733,31.7874C35.8578,31.7874 34.3848,30.3145 34.3848,28.499ZM48.6353,25.2105C46.8198,25.2105 45.3468,26.6834 45.3468,28.499C45.3468,30.3145 46.8198,31.7874 48.6353,31.7874C50.4508,31.7874 51.9237,30.3145 51.9237,28.499C51.9237,26.6834 50.4508,25.2105 48.6353,25.2105ZM56.3075,28.499C56.3075,26.6834 57.7804,25.2105 59.5959,25.2105C61.4114,25.2105 62.8844,26.6834 62.8844,28.499C62.8844,30.3145 61.4114,31.7874 59.5959,31.7874C57.7804,31.7874 56.3075,30.3145 56.3075,28.499ZM43.1537,34.7339C41.3382,34.7339 39.8653,36.2069 39.8653,38.0224C39.8653,39.8379 41.3382,41.3108 43.1537,41.3108C44.9692,41.3108 46.4422,39.8379 46.4422,38.0224C46.4422,36.2069 44.9692,34.7339 43.1537,34.7339ZM50.8273,38.0224C50.8273,36.2069 52.3002,34.7339 54.1157,34.7339C55.9312,34.7339 57.4042,36.2069 57.4042,38.0224C57.4042,39.8379 55.9312,41.3108 54.1157,41.3108C52.3002,41.3108 50.8273,39.8379 50.8273,38.0224Z" + android:fillColor="#ffffff" + android:fillAlpha="0.2" + android:fillType="evenOdd" /> + <path + android:pathData="M22,18L0,27.8182V42.5455C0,56.1682 9.3867,68.9073 22,72C34.6133,68.9073 44,56.1682 44,42.5455V27.8182L22,18Z" + android:fillColor="#007FAD" /> + <path + android:pathData="M11.29,43.7909L8,47.2335L17.3333,57L36,37.4671L32.71,34L17.3333,50.0902L11.29,43.7909Z" + android:fillColor="#ffffff" /> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/ic_arrow_right_grey.xml b/Corona-Warn-App/src/main/res/drawable/ic_arrow_right_grey.xml new file mode 100644 index 0000000000000000000000000000000000000000..fea2452990a68326b2ac7ee4a7a73a64725a963c --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/ic_arrow_right_grey.xml @@ -0,0 +1,11 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="19dp" + android:height="16dp" + android:viewportWidth="19" + android:viewportHeight="16"> + <path + android:pathData="M11.9186,0.7475L10.4286,2.318L14.9685,7.1031H0.9918V9.197H14.9685L10.4286,13.9821L11.9186,15.5526L18.9419,8.15L11.9186,0.7475Z" + android:fillColor="#17191A" + android:fillAlpha="0.6" + android:fillType="evenOdd"/> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/ic_vaccination_complete.xml b/Corona-Warn-App/src/main/res/drawable/ic_vaccination_complete.xml new file mode 100644 index 0000000000000000000000000000000000000000..38ca300a85b99713bc8a8ee9642892cf4515a7b6 --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/ic_vaccination_complete.xml @@ -0,0 +1,47 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="88dp" + android:height="95dp" + android:viewportWidth="88" + android:viewportHeight="95"> + <path + android:pathData="M0,4C0,1.7909 1.7909,0 4,0H84C86.2091,0 88,1.7909 88,4V91C88,93.2091 86.2091,95 84,95H4C1.7909,95 0,93.2091 0,91V4Z" + android:fillColor="#FDD207"/> + <path + android:pathData="M0,4C0,1.7909 1.7909,0 4,0H84C86.2091,0 88,1.7909 88,4V91C88,93.2091 86.2091,95 84,95H4C1.7909,95 0,93.2091 0,91V4Z"> + <aapt:attr name="android:fillColor"> + <gradient + android:startY="35.3456" + android:startX="-1.95773E-6" + android:endY="57.3292" + android:endX="82.1951" + android:type="linear"> + <item android:offset="0" android:color="#FF0093C8"/> + <item android:offset="1" android:color="#FF007FAD"/> + </gradient> + </aapt:attr> + </path> + <path + android:pathData="M0,4C0,1.7909 1.7909,0 4,0H84C86.2091,0 88,1.7909 88,4V91C88,93.2091 86.2091,95 84,95H4C1.7909,95 0,93.2091 0,91V4Z" + android:fillColor="#FDD207"/> + <path + android:pathData="M0,4C0,1.7909 1.7909,0 4,0H84C86.2091,0 88,1.7909 88,4V91C88,93.2091 86.2091,95 84,95H4C1.7909,95 0,93.2091 0,91V4Z"> + <aapt:attr name="android:fillColor"> + <gradient + android:startY="35.3456" + android:startX="-1.95773E-6" + android:endY="57.3292" + android:endX="82.1951" + android:type="linear"> + <item android:offset="0" android:color="#FF0093C8"/> + <item android:offset="1" android:color="#FF007FAD"/> + </gradient> + </aapt:attr> + </path> + <path + android:pathData="M44.6667,21L23,29.125V45.6188C23,59.2958 32.2354,72.0521 44.6667,75.1667C57.0979,72.0521 66.3333,59.2958 66.3333,45.6188V29.125L44.6667,21ZM60.9167,45.6188C60.9167,56.4521 54.0104,66.4729 44.6667,69.5333C35.3229,66.4729 28.4167,56.4792 28.4167,45.6188V32.8896L44.6667,26.7958L60.9167,32.8896V45.6188Z" + android:fillColor="#ffffff"/> + <path + android:pathData="M44.6665,71.0834V26.5834V24.0834L26.1665,32.0834V49.0834L31.1665,63.0834L44.6665,71.0834Z" + android:fillColor="#ffffff"/> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/ic_vaccination_complete_final.xml b/Corona-Warn-App/src/main/res/drawable/ic_vaccination_complete_final.xml new file mode 100644 index 0000000000000000000000000000000000000000..81e5a5d1c58a39162f5ed067000118504d0a2457 --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/ic_vaccination_complete_final.xml @@ -0,0 +1,30 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:aapt="http://schemas.android.com/aapt" + android:width="88dp" + android:height="95dp" + android:viewportWidth="88" + android:viewportHeight="95"> + <path + android:pathData="M0,4C0,1.7909 1.7909,0 4,0H84C86.2091,0 88,1.7909 88,4V91C88,93.2091 86.2091,95 84,95H4C1.7909,95 0,93.2091 0,91V4Z" + android:fillColor="#FDD207"/> + <path + android:pathData="M0,4C0,1.7909 1.7909,0 4,0H84C86.2091,0 88,1.7909 88,4V91C88,93.2091 86.2091,95 84,95H4C1.7909,95 0,93.2091 0,91V4Z"> + <aapt:attr name="android:fillColor"> + <gradient + android:startY="35.3456" + android:startX="-1.95773E-6" + android:endY="57.3292" + android:endX="82.1951" + android:type="linear"> + <item android:offset="0" android:color="#FF0093C8"/> + <item android:offset="1" android:color="#FF007FAD"/> + </gradient> + </aapt:attr> + </path> + <path + android:pathData="M44.5,21L23,30.8182V45.5455C23,59.1682 32.1733,71.9073 44.5,75C56.8267,71.9073 66,59.1682 66,45.5455V30.8182L44.5,21Z" + android:fillColor="#ffffff"/> + <path + android:pathData="M32.525,46.7909L29,50.2335L39,60L59,40.4671L55.475,37L39,53.0902L32.525,46.7909Z" + android:fillColor="#0186B6"/> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/ic_vaccination_incomplete.xml b/Corona-Warn-App/src/main/res/drawable/ic_vaccination_incomplete.xml new file mode 100644 index 0000000000000000000000000000000000000000..8a6cdf9e1bf90fb35486a0555b6e5c896dabdaf6 --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/ic_vaccination_incomplete.xml @@ -0,0 +1,15 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="88dp" + android:height="95dp" + android:viewportWidth="88" + android:viewportHeight="95"> + <path + android:pathData="M0,4C0,1.7909 1.7909,0 4,0H84C86.2091,0 88,1.7909 88,4V91C88,93.2091 86.2091,95 84,95H4C1.7909,95 0,93.2091 0,91V4Z" + android:fillColor="#616F7E"/> + <path + android:pathData="M44.6667,21L23,29.125V45.6188C23,59.2958 32.2354,72.0521 44.6667,75.1667C57.0979,72.0521 66.3333,59.2958 66.3333,45.6188V29.125L44.6667,21ZM60.9167,45.6188C60.9167,56.4521 54.0104,66.4729 44.6667,69.5333C35.3229,66.4729 28.4167,56.4792 28.4167,45.6188V32.8896L44.6667,26.7958L60.9167,32.8896V45.6188Z" + android:fillColor="#ffffff"/> + <path + android:pathData="M44.6665,71.0834V26.5834V24.0834L26.1665,32.0834V49.0834L31.1665,63.0834L44.6665,71.0834Z" + android:fillColor="#ffffff"/> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/ic_vaccination_incomplete_final.xml b/Corona-Warn-App/src/main/res/drawable/ic_vaccination_incomplete_final.xml new file mode 100644 index 0000000000000000000000000000000000000000..ea2e7488de12496a7cab4402ddfb856a5961380a --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/ic_vaccination_incomplete_final.xml @@ -0,0 +1,15 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="88dp" + android:height="95dp" + android:viewportWidth="88" + android:viewportHeight="95"> + <path + android:pathData="M0,4C0,1.7909 1.7909,0 4,0H84C86.2091,0 88,1.7909 88,4V91C88,93.2091 86.2091,95 84,95H4C1.7909,95 0,93.2091 0,91V4Z" + android:fillColor="#616F7E"/> + <path + android:pathData="M44.5,21L23,30.8182V45.5455C23,59.1682 32.1733,71.9073 44.5,75C56.8267,71.9073 66,59.1682 66,45.5455V30.8182L44.5,21Z" + android:fillColor="#ffffff"/> + <path + android:pathData="M32.525,46.7909L29,50.2335L39,60L59,40.4671L55.475,37L39,53.0902L32.525,46.7909Z" + android:fillColor="#616F7E"/> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_complete.xml b/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_complete.xml new file mode 100644 index 0000000000000000000000000000000000000000..47227abb719d617b587e8270164fbde8a3e9aaf8 --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_complete.xml @@ -0,0 +1,17 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="78dp" + android:height="72dp" + android:viewportWidth="78" + android:viewportHeight="72"> + <path + android:pathData="M44.2502,0V4.3846H46.4425V6.7139C42.0321,7.1507 38.05,8.8463 34.7958,11.5096L33.1516,9.8654L34.6588,8.3582L31.5759,5.2752L25.41,11.4411L28.4929,14.524L30.0001,13.0168L31.6444,14.6611C28.9811,17.9153 27.2855,21.8974 26.8487,26.3077H24.5194V24.1154H20.1348V32.8846H24.5194V30.6923H26.8487C27.2855,35.1369 29.0153,39.1447 31.7129,42.4075L30.0001,43.9832L28.4929,42.476L25.41,45.5589L31.5759,51.7248L34.6588,48.6418L33.1516,47.1346L34.7958,45.4219C38.0586,48.1023 42.0236,49.8493 46.4425,50.2861V52.6154H44.2502V57H53.0194V52.6154H50.8271V50.2861C55.2459,49.8493 59.2109,48.1023 62.4737,45.4219H62.5422L64.1179,47.1346L62.6107,48.6418L65.6937,51.7248L71.8595,45.5589L68.7766,42.476L67.2694,43.9832L65.5566,42.4075C65.5609,42.3989 65.5588,42.386 65.5566,42.3732C65.5545,42.3604 65.5524,42.3475 65.5566,42.3389C68.2371,39.0762 69.9841,35.1112 70.4208,30.6923H72.7501V32.8846H77.1348V24.1154H72.7501V26.3077H70.4208C69.9841,21.8974 68.2885,17.9153 65.6251,14.6611L67.2694,13.0168L68.7766,14.524L71.8595,11.4411L65.6937,5.2752L62.6107,8.3582L64.1179,9.8654L62.4737,11.5096C59.2195,8.8463 55.2374,7.1507 50.8271,6.7139V4.3846H53.0194V0H44.2502ZM48.6352,10.9604C58.3465,10.9604 66.1737,18.7876 66.1737,28.4989C66.1737,38.2101 58.3465,46.0373 48.6352,46.0373C38.924,46.0373 31.0968,38.2101 31.0968,28.4989C31.0968,18.7876 38.924,10.9604 48.6352,10.9604ZM39.8653,18.9777C39.8653,17.1622 41.3382,15.6893 43.1537,15.6893C44.9692,15.6893 46.4422,17.1622 46.4422,18.9777C46.4422,20.7933 44.9692,22.2662 43.1537,22.2662C41.3382,22.2662 39.8653,20.7933 39.8653,18.9777ZM54.1157,15.6893C52.3002,15.6893 50.8273,17.1622 50.8273,18.9777C50.8273,20.7933 52.3002,22.2662 54.1157,22.2662C55.9312,22.2662 57.4042,20.7933 57.4042,18.9777C57.4042,17.1622 55.9312,15.6893 54.1157,15.6893ZM34.3848,28.499C34.3848,26.6834 35.8578,25.2105 37.6733,25.2105C39.4888,25.2105 40.9618,26.6834 40.9618,28.499C40.9618,30.3145 39.4888,31.7874 37.6733,31.7874C35.8578,31.7874 34.3848,30.3145 34.3848,28.499ZM48.6353,25.2105C46.8198,25.2105 45.3468,26.6834 45.3468,28.499C45.3468,30.3145 46.8198,31.7874 48.6353,31.7874C50.4508,31.7874 51.9237,30.3145 51.9237,28.499C51.9237,26.6834 50.4508,25.2105 48.6353,25.2105ZM56.3075,28.499C56.3075,26.6834 57.7804,25.2105 59.5959,25.2105C61.4114,25.2105 62.8844,26.6834 62.8844,28.499C62.8844,30.3145 61.4114,31.7874 59.5959,31.7874C57.7804,31.7874 56.3075,30.3145 56.3075,28.499ZM43.1537,34.7339C41.3382,34.7339 39.8653,36.2069 39.8653,38.0224C39.8653,39.8379 41.3382,41.3108 43.1537,41.3108C44.9692,41.3108 46.4422,39.8379 46.4422,38.0224C46.4422,36.2069 44.9692,34.7339 43.1537,34.7339ZM50.8273,38.0224C50.8273,36.2069 52.3002,34.7339 54.1157,34.7339C55.9312,34.7339 57.4042,36.2069 57.4042,38.0224C57.4042,39.8379 55.9312,41.3108 54.1157,41.3108C52.3002,41.3108 50.8273,39.8379 50.8273,38.0224Z" + android:fillColor="#ffffff" + android:fillAlpha="0.2" + android:fillType="evenOdd" /> + <path + android:pathData="M22,18L0,27.8182V42.5455C0,56.1682 9.3867,68.9073 22,72C34.6133,68.9073 44,56.1682 44,42.5455V27.8182L22,18Z" + android:fillColor="#ffffff" /> + <path + android:pathData="M11.29,43.7909L8,47.2335L17.3333,57L36,37.4671L32.71,34L17.3333,50.0902L11.29,43.7909Z" + android:fillColor="#007FAD" /> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_incomplete.xml b/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_incomplete.xml new file mode 100644 index 0000000000000000000000000000000000000000..f51cc4182dc3e363ea51e1759488a01cf48f9831 --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_incomplete.xml @@ -0,0 +1,20 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="78dp" + android:height="73dp" + android:viewportWidth="78" + android:viewportHeight="73"> + <path + android:pathData="M44.4167,0V4.3846H46.609V6.7139C42.1987,7.1507 38.2165,8.8463 34.9623,11.5096L33.3181,9.8654L34.8253,8.3582L31.7424,5.2752L25.5765,11.4411L28.6594,14.524L30.1667,13.0168L31.8109,14.6611C29.1476,17.9153 27.452,21.8974 27.0152,26.3077H24.6859V24.1154H20.3013V32.8846H24.6859V30.6923H27.0152C27.452,35.1369 29.1818,39.1447 31.8794,42.4075L30.1667,43.9832L28.6594,42.476L25.5765,45.5589L31.7424,51.7248L34.8253,48.6418L33.3181,47.1346L34.9623,45.4219C38.2251,48.1023 42.1901,49.8493 46.609,50.2861V52.6154H44.4167V57H53.1859V52.6154H50.9936V50.2861C55.4124,49.8493 59.3774,48.1023 62.6402,45.4219H62.7087L64.2844,47.1346L62.7772,48.6418L65.8602,51.7248L72.026,45.5589L68.9431,42.476L67.4359,43.9832L65.7231,42.4075C65.7274,42.3989 65.7253,42.386 65.7231,42.3732C65.721,42.3604 65.7189,42.3475 65.7231,42.3389C68.4036,39.0762 70.1506,35.1112 70.5873,30.6923H72.9166V32.8846H77.3013V24.1154H72.9166V26.3077H70.5873C70.1506,21.8974 68.455,17.9153 65.7916,14.6611L67.4359,13.0168L68.9431,14.524L72.026,11.4411L65.8602,5.2752L62.7772,8.3582L64.2844,9.8654L62.6402,11.5096C59.386,8.8463 55.4039,7.1507 50.9936,6.7139V4.3846H53.1859V0H44.4167ZM48.8017,10.9604C58.513,10.9604 66.3402,18.7876 66.3402,28.4989C66.3402,38.2101 58.513,46.0373 48.8017,46.0373C39.0905,46.0373 31.2633,38.2101 31.2633,28.4989C31.2633,18.7876 39.0905,10.9604 48.8017,10.9604ZM40.0318,18.9777C40.0318,17.1622 41.5047,15.6893 43.3202,15.6893C45.1357,15.6893 46.6087,17.1622 46.6087,18.9777C46.6087,20.7933 45.1357,22.2662 43.3202,22.2662C41.5047,22.2662 40.0318,20.7933 40.0318,18.9777ZM54.2822,15.6893C52.4667,15.6893 50.9938,17.1622 50.9938,18.9777C50.9938,20.7933 52.4667,22.2662 54.2822,22.2662C56.0977,22.2662 57.5707,20.7933 57.5707,18.9777C57.5707,17.1622 56.0977,15.6893 54.2822,15.6893ZM34.5513,28.499C34.5513,26.6834 36.0243,25.2105 37.8398,25.2105C39.6553,25.2105 41.1283,26.6834 41.1283,28.499C41.1283,30.3145 39.6553,31.7874 37.8398,31.7874C36.0243,31.7874 34.5513,30.3145 34.5513,28.499ZM48.8018,25.2105C46.9863,25.2105 45.5133,26.6834 45.5133,28.499C45.5133,30.3145 46.9863,31.7874 48.8018,31.7874C50.6173,31.7874 52.0902,30.3145 52.0902,28.499C52.0902,26.6834 50.6173,25.2105 48.8018,25.2105ZM56.474,28.499C56.474,26.6834 57.9469,25.2105 59.7624,25.2105C61.5779,25.2105 63.0509,26.6834 63.0509,28.499C63.0509,30.3145 61.5779,31.7874 59.7624,31.7874C57.9469,31.7874 56.474,30.3145 56.474,28.499ZM43.3202,34.7339C41.5047,34.7339 40.0318,36.2069 40.0318,38.0224C40.0318,39.8379 41.5047,41.3108 43.3202,41.3108C45.1357,41.3108 46.6087,39.8379 46.6087,38.0224C46.6087,36.2069 45.1357,34.7339 43.3202,34.7339ZM50.9938,38.0224C50.9938,36.2069 52.4667,34.7339 54.2822,34.7339C56.0977,34.7339 57.5707,36.2069 57.5707,38.0224C57.5707,39.8379 56.0977,41.3108 54.2822,41.3108C52.4667,41.3108 50.9938,39.8379 50.9938,38.0224Z" + android:fillColor="#ffffff" + android:fillAlpha="0.2" + android:fillType="evenOdd" /> + <path + android:pathData="M17.167,70V24.5532V22L40.167,30.1702V47.5319L33.9508,61.8298L17.167,70Z" + android:fillColor="#616F7E" /> + <path + android:pathData="M21.6667,18.4167L0,26.5417V43.0355C0,56.7126 9.2354,69.4688 21.6667,72.5834C34.0979,69.4688 43.3333,56.7126 43.3333,43.0355V26.5417L21.6667,18.4167ZM37.9167,43.0355C37.9167,53.8688 31.0104,63.8897 21.6667,66.9501C12.3229,63.8897 5.4167,53.8959 5.4167,43.0355V30.3063L21.6667,24.2126L37.9167,30.3063V43.0355Z" + android:fillColor="#ffffff" /> + <path + android:pathData="M21.667,68.5V24V21.5L3.167,29.5V46.5L8.167,60.5L21.667,68.5Z" + android:fillColor="#ffffff" /> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_registration.xml b/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_registration.xml new file mode 100644 index 0000000000000000000000000000000000000000..492a32adae7c54cae2edae371eea25d4581b537c --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/vaccination_card_icon_registration.xml @@ -0,0 +1,17 @@ +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="78dp" + android:height="72dp" + android:viewportWidth="78" + android:viewportHeight="72"> + <path + android:pathData="M44.2502,0V4.3846H46.4425V6.7139C42.0321,7.1507 38.05,8.8463 34.7958,11.5096L33.1516,9.8654L34.6588,8.3582L31.5759,5.2752L25.41,11.4411L28.4929,14.524L30.0001,13.0168L31.6444,14.6611C28.9811,17.9153 27.2855,21.8974 26.8487,26.3077H24.5194V24.1154H20.1348V32.8846H24.5194V30.6923H26.8487C27.2855,35.1369 29.0153,39.1447 31.7129,42.4075L30.0001,43.9832L28.4929,42.476L25.41,45.5589L31.5759,51.7248L34.6588,48.6418L33.1516,47.1346L34.7958,45.4219C38.0586,48.1023 42.0236,49.8493 46.4425,50.2861V52.6154H44.2502V57H53.0194V52.6154H50.8271V50.2861C55.2459,49.8493 59.2109,48.1023 62.4737,45.4219H62.5422L64.1179,47.1346L62.6107,48.6418L65.6937,51.7248L71.8595,45.5589L68.7766,42.476L67.2694,43.9832L65.5566,42.4075C65.5609,42.3989 65.5588,42.386 65.5566,42.3732C65.5545,42.3604 65.5524,42.3475 65.5566,42.3389C68.2371,39.0762 69.9841,35.1112 70.4208,30.6923H72.7501V32.8846H77.1348V24.1154H72.7501V26.3077H70.4208C69.9841,21.8974 68.2885,17.9153 65.6251,14.6611L67.2694,13.0168L68.7766,14.524L71.8595,11.4411L65.6937,5.2752L62.6107,8.3582L64.1179,9.8654L62.4737,11.5096C59.2195,8.8463 55.2374,7.1507 50.8271,6.7139V4.3846H53.0194V0H44.2502ZM48.6352,10.9604C58.3465,10.9604 66.1737,18.7876 66.1737,28.4989C66.1737,38.2101 58.3465,46.0373 48.6352,46.0373C38.924,46.0373 31.0968,38.2101 31.0968,28.4989C31.0968,18.7876 38.924,10.9604 48.6352,10.9604ZM39.8653,18.9777C39.8653,17.1622 41.3382,15.6893 43.1537,15.6893C44.9692,15.6893 46.4422,17.1622 46.4422,18.9777C46.4422,20.7933 44.9692,22.2662 43.1537,22.2662C41.3382,22.2662 39.8653,20.7933 39.8653,18.9777ZM54.1157,15.6893C52.3002,15.6893 50.8273,17.1622 50.8273,18.9777C50.8273,20.7933 52.3002,22.2662 54.1157,22.2662C55.9312,22.2662 57.4042,20.7933 57.4042,18.9777C57.4042,17.1622 55.9312,15.6893 54.1157,15.6893ZM34.3848,28.499C34.3848,26.6834 35.8578,25.2105 37.6733,25.2105C39.4888,25.2105 40.9618,26.6834 40.9618,28.499C40.9618,30.3145 39.4888,31.7874 37.6733,31.7874C35.8578,31.7874 34.3848,30.3145 34.3848,28.499ZM48.6353,25.2105C46.8198,25.2105 45.3468,26.6834 45.3468,28.499C45.3468,30.3145 46.8198,31.7874 48.6353,31.7874C50.4508,31.7874 51.9237,30.3145 51.9237,28.499C51.9237,26.6834 50.4508,25.2105 48.6353,25.2105ZM56.3075,28.499C56.3075,26.6834 57.7804,25.2105 59.5959,25.2105C61.4114,25.2105 62.8844,26.6834 62.8844,28.499C62.8844,30.3145 61.4114,31.7874 59.5959,31.7874C57.7804,31.7874 56.3075,30.3145 56.3075,28.499ZM43.1537,34.7339C41.3382,34.7339 39.8653,36.2069 39.8653,38.0224C39.8653,39.8379 41.3382,41.3108 43.1537,41.3108C44.9692,41.3108 46.4422,39.8379 46.4422,38.0224C46.4422,36.2069 44.9692,34.7339 43.1537,34.7339ZM50.8273,38.0224C50.8273,36.2069 52.3002,34.7339 54.1157,34.7339C55.9312,34.7339 57.4042,36.2069 57.4042,38.0224C57.4042,39.8379 55.9312,41.3108 54.1157,41.3108C52.3002,41.3108 50.8273,39.8379 50.8273,38.0224Z" + android:fillColor="#007FAD" + android:fillAlpha="0.1" + android:fillType="evenOdd" /> + <path + android:pathData="M22,18L0,27.8182V42.5455C0,56.1682 9.3867,68.9073 22,72C34.6133,68.9073 44,56.1682 44,42.5455V27.8182L22,18Z" + android:fillColor="#007FAD" /> + <path + android:pathData="M11.29,43.7909L8,47.2335L17.3333,57L36,37.4671L32.71,34L17.3333,50.0902L11.29,43.7909Z" + android:fillColor="#ffffff" /> +</vector> diff --git a/Corona-Warn-App/src/main/res/drawable/vaccination_compelete_gradient.xml b/Corona-Warn-App/src/main/res/drawable/vaccination_compelete_gradient.xml new file mode 100644 index 0000000000000000000000000000000000000000..ca1f11742575b3859a8d7a12a879d14f0fb6cd59 --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/vaccination_compelete_gradient.xml @@ -0,0 +1,7 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <gradient + android:angle="315" + android:endColor="#007FAD" + android:startColor="#0093C8" /> +</shape> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/drawable/vaccination_incomplete.xml b/Corona-Warn-App/src/main/res/drawable/vaccination_incomplete.xml new file mode 100644 index 0000000000000000000000000000000000000000..9373ef74532dbdaac3735958d16c6ab103b29244 --- /dev/null +++ b/Corona-Warn-App/src/main/res/drawable/vaccination_incomplete.xml @@ -0,0 +1,4 @@ +<?xml version="1.0" encoding="utf-8"?> +<shape xmlns:android="http://schemas.android.com/apk/res/android"> + <solid android:color="#616F7E" /> +</shape> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/layout/fragment_scan_check_in_qr_code.xml b/Corona-Warn-App/src/main/res/layout/fragment_scan_qr_code.xml similarity index 71% rename from Corona-Warn-App/src/main/res/layout/fragment_scan_check_in_qr_code.xml rename to Corona-Warn-App/src/main/res/layout/fragment_scan_qr_code.xml index 80845bfcf2153083902983d8bd6d40c65797ab08..2b4f586ab41d81c60a490a7912cf8af3caabf93d 100644 --- a/Corona-Warn-App/src/main/res/layout/fragment_scan_check_in_qr_code.xml +++ b/Corona-Warn-App/src/main/res/layout/fragment_scan_qr_code.xml @@ -8,7 +8,7 @@ android:transitionName="shared_element_container"> <com.journeyapps.barcodescanner.BarcodeView - android:id="@+id/check_in_qr_code_scan_preview" + android:id="@+id/qr_code_scan_preview" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" @@ -21,7 +21,7 @@ </com.journeyapps.barcodescanner.BarcodeView> <com.journeyapps.barcodescanner.ViewfinderView - android:id="@+id/check_in_qr_code_scan_viewfinder_view" + android:id="@+id/qr_code_scan_viewfinder_view" android:layout_width="0dp" android:layout_height="0dp" app:layout_constraintBottom_toBottomOf="parent" @@ -32,19 +32,31 @@ app:zxing_viewfinder_laser_visibility="false" /> <TextView - android:id="@+id/check_in_qr_code_scan_body" + android:id="@+id/qr_code_scan_body" style="@style/qrCodeScanBody" android:layout_width="@dimen/scan_qr_code_viewfinder_size" android:layout_height="wrap_content" android:layout_marginTop="@dimen/scan_qr_code_viewfinder_center_offset" android:text="@string/qr_code_scan_body" - app:layout_constraintEnd_toEndOf="@id/check_in_qr_code_scan_preview" - app:layout_constraintStart_toStartOf="@id/check_in_qr_code_scan_preview" - app:layout_constraintTop_toBottomOf="@id/check_in_qr_code_scan_guideline_center" /> + app:layout_constraintEnd_toEndOf="@id/qr_code_scan_preview" + app:layout_constraintStart_toStartOf="@id/qr_code_scan_preview" + app:layout_constraintTop_toBottomOf="@id/qr_code_scan_guideline_center" /> + <com.google.android.material.progressindicator.CircularProgressIndicator + android:id="@+id/qr_code_scan_spinner" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="@dimen/spacing_normal" + android:indeterminate="true" + app:indicatorColor="@android:color/white" + app:indicatorSize="64dp" + app:trackColor="@android:color/transparent" + app:layout_constraintEnd_toEndOf="@id/qr_code_scan_body" + app:layout_constraintStart_toStartOf="@id/qr_code_scan_body" + app:layout_constraintTop_toBottomOf="@id/qr_code_scan_body" /> <com.google.android.material.appbar.MaterialToolbar - android:id="@+id/check_in_qr_code_scan_toolbar" + android:id="@+id/qr_code_scan_toolbar" style="@style/CWAToolbar.BackArrow.Transparent" android:layout_width="match_parent" android:layout_height="wrap_content" @@ -56,7 +68,7 @@ app:titleTextColor="@color/colorQrCodeScanToolbar"> <ToggleButton - android:id="@+id/check_in_qr_code_scan_torch" + android:id="@+id/qr_code_scan_torch" android:layout_width="@dimen/icon_size_button" android:layout_height="@dimen/icon_size_button" android:layout_gravity="end" @@ -69,10 +81,10 @@ </com.google.android.material.appbar.MaterialToolbar> <androidx.constraintlayout.widget.Guideline - android:id="@+id/check_in_qr_code_scan_guideline_center" + android:id="@+id/qr_code_scan_guideline_center" android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="horizontal" app:layout_constraintGuide_percent="0.5" /> -</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Corona-Warn-App/src/main/res/layout/fragment_vaccination_details.xml b/Corona-Warn-App/src/main/res/layout/fragment_vaccination_details.xml new file mode 100644 index 0000000000000000000000000000000000000000..436feb07748778fd821234c11d03cb6afc42d456 --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/fragment_vaccination_details.xml @@ -0,0 +1,242 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@drawable/trace_location_gradient_background"> + + <androidx.coordinatorlayout.widget.CoordinatorLayout + android:id="@+id/coordinator_layout" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginBottom="12dp" + android:nestedScrollingEnabled="true" + app:layout_constraintBottom_toTopOf="@id/delete_button" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + <com.google.android.material.appbar.AppBarLayout + android:id="@+id/appBarLayout" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <com.google.android.material.appbar.CollapsingToolbarLayout + android:id="@+id/collapsing_toolbar_layout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:nestedScrollingEnabled="true" + app:layout_scrollFlags="scroll|exitUntilCollapsed"> + + <ImageView + android:id="@+id/expandedImage" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:importantForAccessibility="no" + app:layout_collapseMode="parallax" + app:srcCompat="@drawable/vaccination_compelete_gradient" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_collapseMode="parallax"> + + <TextView + android:id="@+id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="24dp" + android:layout_marginTop="90dp" + android:layout_marginBottom="12dp" + android:gravity="center" + android:textColor="@android:color/white" + android:textSize="20sp" + android:textStyle="bold" + tools:text="Impfung 1 von 2" /> + + <TextView + android:id="@+id/subtitle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginHorizontal="24dp" + android:layout_marginBottom="8dp" + android:gravity="center" + android:text="@string/vaccination_details_subtitle" + android:textColor="@android:color/white" + android:textSize="18sp" /> + + </LinearLayout> + + <com.google.android.material.appbar.MaterialToolbar + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + app:layout_collapseMode="pin" + app:layout_scrollFlags="scroll|enterAlways" + app:navigationIcon="@drawable/ic_back" + app:navigationIconTint="@android:color/white" + app:titleTextColor="@color/colorAccentTintButton"> + + <LinearLayout + android:id="@+id/header_text_layout" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_vertical" + android:orientation="horizontal"> + <ImageView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginEnd="72dp" + android:importantForAccessibility="no" + app:srcCompat="@drawable/ic_cwa_logo_white" /> + </LinearLayout> + + </com.google.android.material.appbar.MaterialToolbar> + + </com.google.android.material.appbar.CollapsingToolbarLayout> + + </com.google.android.material.appbar.AppBarLayout> + + <androidx.core.widget.NestedScrollView + android:id="@+id/scroll_view" + android:layout_width="match_parent" + android:layout_height="match_parent" + app:layout_behavior="@string/appbar_scrolling_view_behavior"> + + <LinearLayout + style="@style/Card.Vaccination" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="24dp" + android:layout_marginTop="16dp" + android:orientation="vertical" + android:padding="16dp"> + + <TextView + android:id="@+id/name" + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:text="Andrea Schneider" /> + + <TextView + android:id="@+id/birth_date" + style="@style/body2Medium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="geboren 18.04.1943" /> + + <TextView + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="24dp" + android:text="@string/vaccination_details_certificate_date" /> + + <TextView + android:id="@+id/vaccinated_at" + style="@style/body2Medium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="12.04.2021" /> + + <TextView + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="24dp" + android:text="@string/vaccination_details_vaccine_name" /> + + <TextView + android:id="@+id/vaccine_name" + style="@style/body2Medium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="Comirnaty (mRNA)" /> + + <TextView + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="24dp" + android:text="@string/vaccination_details_vaccine_manufacturer" /> + + <TextView + android:id="@+id/vaccine_manufacturer" + style="@style/body2Medium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="BioNTech" /> + + <TextView + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="24dp" + android:text="@string/vaccination_details_certificate_issuer" /> + + <TextView + android:id="@+id/certificate_issuer" + style="@style/body2Medium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="Landratsamt Potsdam" /> + + <TextView + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="24dp" + android:text="@string/vaccination_details_certificate_country" /> + + <TextView + android:id="@+id/certificate_country" + style="@style/body2Medium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="Deutschland" /> + + <TextView + style="@style/body1" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="24dp" + android:text="@string/vaccination_details_certificate_id" /> + + <TextView + android:id="@+id/certificate_id" + style="@style/body2Medium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="05930482748454836478695764787840" /> + + </LinearLayout> + + </androidx.core.widget.NestedScrollView> + + </androidx.coordinatorlayout.widget.CoordinatorLayout> + + <Button + android:id="@+id/delete_button" + style="@style/buttonPrimary" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_normal" + android:layout_marginEnd="@dimen/spacing_normal" + android:layout_marginBottom="8dp" + android:text="@string/vaccination_details_delete_button" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Corona-Warn-App/src/main/res/layout/fragment_vaccination_list.xml b/Corona-Warn-App/src/main/res/layout/fragment_vaccination_list.xml new file mode 100644 index 0000000000000000000000000000000000000000..ec70295fcc340ef288ea5ccd16a32797e6bfd90c --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/fragment_vaccination_list.xml @@ -0,0 +1,140 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/content_container" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="@drawable/trace_location_gradient_background"> + + <androidx.coordinatorlayout.widget.CoordinatorLayout + android:id="@+id/coordinator_layout" + android:layout_width="0dp" + android:layout_height="0dp" + android:layout_marginBottom="12dp" + android:nestedScrollingEnabled="true" + app:layout_constraintBottom_toTopOf="@id/refresh_button" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + <com.google.android.material.appbar.AppBarLayout + android:id="@+id/appBarLayout" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <com.google.android.material.appbar.CollapsingToolbarLayout + android:id="@+id/collapsing_toolbar_layout" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:nestedScrollingEnabled="true" + app:layout_scrollFlags="scroll|exitUntilCollapsed"> + + <ImageView + android:id="@+id/expandedImage" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:importantForAccessibility="no" + app:layout_collapseMode="parallax" + app:srcCompat="@drawable/vaccination_incomplete" /> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + app:layout_collapseMode="parallax"> + + <TextView + android:id="@+id/title" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="24dp" + android:layout_marginTop="80dp" + android:layout_marginBottom="12dp" + android:gravity="center" + android:text="@string/vaccination_list_title" + android:textColor="@android:color/white" + android:textSize="20sp" + android:textStyle="bold"/> + + <TextView + android:id="@+id/subtitle" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_gravity="center" + android:layout_marginHorizontal="24dp" + android:gravity="center" + android:text="@string/vaccination_list_complete_vaccination_subtitle" + android:textColor="@android:color/white" + android:textSize="18sp" + android:visibility="visible"/> + + </LinearLayout> + + <com.google.android.material.appbar.MaterialToolbar + android:id="@+id/toolbar" + android:layout_width="match_parent" + android:layout_height="?attr/actionBarSize" + app:layout_collapseMode="pin" + app:layout_scrollFlags="scroll|enterAlways" + app:navigationIcon="@drawable/ic_close_white" + app:titleTextColor="@color/colorAccentTintButton"> + + <LinearLayout + android:id="@+id/header_text_layout" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:gravity="center_vertical" + android:orientation="horizontal"> + <ImageView + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginEnd="72dp" + android:importantForAccessibility="no" + app:srcCompat="@drawable/ic_cwa_logo_white" /> + </LinearLayout> + + </com.google.android.material.appbar.MaterialToolbar> + + </com.google.android.material.appbar.CollapsingToolbarLayout> + + </com.google.android.material.appbar.AppBarLayout> + + <androidx.recyclerview.widget.RecyclerView + android:id="@+id/recycler_view_vaccination_list" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_weight="1" + app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" + app:layout_behavior="@string/appbar_scrolling_view_behavior" + tools:listitem="@layout/vaccination_list_incomplete_top_card" /> + + </androidx.coordinatorlayout.widget.CoordinatorLayout> + + <Button + android:id="@+id/refresh_button" + style="@style/buttonPrimary" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_normal" + android:layout_marginEnd="@dimen/spacing_normal" + android:layout_marginBottom="8dp" + android:text="@string/vaccination_list_refresh_button" + app:layout_constraintBottom_toTopOf="@id/register_new_vaccination_button" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent"/> + + <Button + android:id="@+id/register_new_vaccination_button" + style="@style/buttonPrimary" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/spacing_normal" + android:layout_marginEnd="@dimen/spacing_normal" + android:layout_marginBottom="8dp" + android:text="@string/vaccination_list_register_new_vaccination_button" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout> diff --git a/Corona-Warn-App/src/main/res/layout/trace_location_organizer_qr_code_detail_fragment.xml b/Corona-Warn-App/src/main/res/layout/trace_location_organizer_qr_code_detail_fragment.xml index 8ffc939f6e8957cdb5d1c5923692cc43418eb0f0..faa8ae92f26281e086b79cd7eea299d387db2140 100644 --- a/Corona-Warn-App/src/main/res/layout/trace_location_organizer_qr_code_detail_fragment.xml +++ b/Corona-Warn-App/src/main/res/layout/trace_location_organizer_qr_code_detail_fragment.xml @@ -36,8 +36,8 @@ android:id="@+id/expandedImage" android:layout_width="match_parent" android:layout_height="match_parent" - app:srcCompat="@drawable/trace_location_view_cardhighlight_gradient" - app:layout_collapseMode="parallax" /> + app:layout_collapseMode="parallax" + app:srcCompat="@drawable/trace_location_view_cardhighlight_gradient" /> <LinearLayout android:layout_width="match_parent" diff --git a/Corona-Warn-App/src/main/res/layout/vaccination_home_complete_card.xml b/Corona-Warn-App/src/main/res/layout/vaccination_home_complete_card.xml new file mode 100644 index 0000000000000000000000000000000000000000..598ddd22dd51745335c2100a4b5b9c4e82c24891 --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/vaccination_home_complete_card.xml @@ -0,0 +1,74 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@drawable/vaccination_compelete_gradient" + android:padding="@dimen/card_padding"> + + + <ImageView + android:id="@+id/show_more_action" + android:layout_width="@dimen/icon_size_risk_card" + android:layout_height="@dimen/icon_size_risk_card" + android:importantForAccessibility="no" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_forward" + app:tint="@color/colorStableLight" /> + + <ImageView + android:id="@+id/icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@+id/show_more_action" + app:srcCompat="@drawable/vaccination_card_icon_complete" /> + + <TextView + android:id="@+id/card_title" + style="@style/body2" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginEnd="8dp" + android:accessibilityHeading="true" + android:focusable="false" + android:text="@string/vaccination_card_status_title" + android:textColor="@color/colorTextPrimary1InvertedStable" + app:layout_constraintEnd_toStartOf="@id/show_more_action" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <TextView + android:id="@+id/vaccination_label" + style="@style/headline5Bold" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginEnd="16dp" + android:accessibilityHeading="true" + android:focusable="false" + android:textColor="@color/colorTextPrimary1InvertedStable" + app:layout_constraintEnd_toStartOf="@+id/icon" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/card_title" + android:text="@string/vaccination_card_status_vaccination_name" /> + + <TextView + android:id="@+id/person_name" + style="@style/body1" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginEnd="16dp" + android:accessibilityHeading="true" + android:focusable="false" + android:textColor="@color/colorTextPrimary1InvertedStable" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/icon" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/vaccination_label" + tools:text="François-Joan d'Arsøns - van Halen" /> + +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/layout/vaccination_home_incomplete_card.xml b/Corona-Warn-App/src/main/res/layout/vaccination_home_incomplete_card.xml new file mode 100644 index 0000000000000000000000000000000000000000..6967cbc8040d458ae636b9683426fa628ff153d8 --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/vaccination_home_incomplete_card.xml @@ -0,0 +1,93 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@drawable/vaccination_incomplete" + android:padding="@dimen/card_padding"> + + + <ImageView + android:id="@+id/show_more_action" + android:layout_width="@dimen/icon_size_risk_card" + android:layout_height="@dimen/icon_size_risk_card" + android:importantForAccessibility="no" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" + app:srcCompat="@drawable/ic_forward" + app:tint="@color/colorStableLight" /> + + <ImageView + android:id="@+id/icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toBottomOf="@+id/show_more_action" + app:srcCompat="@drawable/vaccination_card_icon_incomplete" /> + + + <TextView + android:id="@+id/card_title" + style="@style/body2" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginEnd="8dp" + android:accessibilityHeading="true" + android:focusable="false" + android:text="@string/vaccination_card_status_title" + android:textColor="@color/colorTextPrimary1InvertedStable" + app:layout_constraintEnd_toStartOf="@id/show_more_action" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <TextView + android:id="@+id/vaccination_label" + style="@style/headline5Bold" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginEnd="16dp" + android:accessibilityHeading="true" + android:focusable="false" + android:textColor="@color/colorTextPrimary1InvertedStable" + app:layout_constraintEnd_toStartOf="@+id/icon" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/card_title" + android:text="@string/vaccination_card_status_vaccination_name" /> + + <TextView + android:id="@+id/vaccination_state" + style="@style/body1" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginTop="16dp" + android:layout_marginEnd="16dp" + android:accessibilityHeading="true" + android:focusable="false" + android:text="@string/vaccination_card_status_vaccination_incomplete" + android:textColor="@color/colorTextPrimary1InvertedStable" + app:layout_constraintBottom_toTopOf="@+id/person_name" + app:layout_constraintEnd_toStartOf="@+id/icon" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/vaccination_label" + app:layout_constraintVertical_chainStyle="packed" /> + + <TextView + android:id="@+id/person_name" + style="@style/body1" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginTop="8dp" + android:layout_marginEnd="16dp" + android:accessibilityHeading="true" + android:focusable="false" + android:textColor="@color/colorTextPrimary1InvertedStable" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toStartOf="@+id/icon" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/vaccination_state" + tools:text="François-Joan d'Arsøns - van Halen" /> + +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/layout/vaccination_home_registration_card.xml b/Corona-Warn-App/src/main/res/layout/vaccination_home_registration_card.xml new file mode 100644 index 0000000000000000000000000000000000000000..2be066af08c0c27deb751432415e7542e439d709 --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/vaccination_home_registration_card.xml @@ -0,0 +1,72 @@ +<?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"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + tools:showIn="@layout/home_card_container_layout"> + + <TextView + android:id="@+id/title" + style="@style/headline5" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/card_padding" + android:layout_marginTop="@dimen/card_padding" + android:layout_marginEnd="@dimen/spacing_small" + android:accessibilityHeading="true" + android:focusable="false" + android:text="@string/vaccination_card_registration_title" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <TextView + android:id="@+id/body" + style="@style/subtitleMedium" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/card_padding" + android:layout_marginTop="@dimen/spacing_normal" + android:layout_marginEnd="@dimen/spacing_small" + android:focusable="false" + android:text="@string/vaccination_card_registration_body" + app:layout_constraintEnd_toStartOf="@id/icon" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/title" /> + + <ImageView + android:id="@+id/icon" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginEnd="@dimen/spacing_small" + android:importantForAccessibility="no" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="@id/body" + app:srcCompat="@drawable/vaccination_card_icon_registration" /> + + <androidx.constraintlayout.widget.Barrier + android:id="@+id/button_barrier" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + app:barrierDirection="bottom" + app:constraint_referenced_ids="icon,body" /> + + <Button + android:id="@+id/next_steps_action" + style="@style/buttonPrimary" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="@dimen/card_padding" + android:layout_marginTop="@dimen/spacing_small" + android:layout_marginEnd="@dimen/card_padding" + android:layout_marginBottom="@dimen/card_padding" + android:text="@string/vaccination_card_register" + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@id/button_barrier" /> + </androidx.constraintlayout.widget.ConstraintLayout> +</layout> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/layout/vaccination_list_certificate_card.xml b/Corona-Warn-App/src/main/res/layout/vaccination_list_certificate_card.xml new file mode 100644 index 0000000000000000000000000000000000000000..25aea54b9faa6e573d609dfa21c50e5d7d4c802a --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/vaccination_list_certificate_card.xml @@ -0,0 +1,64 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + xmlns:app="http://schemas.android.com/apk/res-auto" + style="@style/Card.Vaccination" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="24dp" + android:layout_marginTop="16dp" + android:padding="0dp" + android:orientation="vertical"> + + <androidx.constraintlayout.widget.ConstraintLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:padding="4dp"> + + <ImageView + android:id="@+id/qrCodeImage" + android:layout_width="0dp" + android:layout_height="0dp" + android:contentDescription="@string/vaccination_list_qr_code_accessibility" + app:layout_constraintDimensionRatio="H,1:1" + app:layout_constraintLeft_toLeftOf="parent" + app:layout_constraintRight_toRightOf="parent" + app:layout_constraintTop_toTopOf="parent" + tools:src="@drawable/ic_qrcode" + tools:tint="@android:color/black" /> + + <com.google.android.material.progressindicator.LinearProgressIndicator + android:id="@+id/progress_bar" + android:layout_width="150dp" + android:layout_height="wrap_content" + android:indeterminate="true" + app:hideAnimationBehavior="inward" + app:indicatorColor="@color/colorAccent" + app:layout_constraintBottom_toBottomOf="@id/qrCodeImage" + app:layout_constraintEnd_toEndOf="@id/qrCodeImage" + app:layout_constraintStart_toStartOf="@id/qrCodeImage" + app:layout_constraintTop_toTopOf="@id/qrCodeImage" /> + + </androidx.constraintlayout.widget.ConstraintLayout> + + <TextView + android:id="@+id/certificate_card_title" + style="@style/body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="16dp" + android:textSize="18sp" + android:textStyle="bold" + android:text="@string/vaccination_list_certificate_card_title" /> + + <TextView + android:id="@+id/certificate_card_subtitle" + style="@style/body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:layout_marginBottom="16dp" + android:layout_marginHorizontal="16dp" + tools:text="Noch 3 Tage gültig" /> + +</LinearLayout> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/layout/vaccination_list_incomplete_top_card.xml b/Corona-Warn-App/src/main/res/layout/vaccination_list_incomplete_top_card.xml new file mode 100644 index 0000000000000000000000000000000000000000..b5238b92da2f3a26ce42223a9921360882950def --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/vaccination_list_incomplete_top_card.xml @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + style="@style/Card.Vaccination" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="24dp" + android:layout_marginTop="16dp" + android:orientation="vertical" + android:paddingHorizontal="16dp" + android:paddingTop="24dp" + android:paddingBottom="12dp"> + + <TextView + android:id="@+id/top_card_title" + style="@style/headline4Bold" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:text="@string/vaccination_list_top_card_title" + android:textSize="30sp"/> + + <TextView + android:id="@+id/top_card_subtitle" + style="@style/body2Medium" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + android:text="@string/vaccination_list_top_card_subtitle" + android:textSize="18sp" /> + +</LinearLayout> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/layout/vaccination_list_name_card.xml b/Corona-Warn-App/src/main/res/layout/vaccination_list_name_card.xml new file mode 100644 index 0000000000000000000000000000000000000000..a749f7d47d5dde3d929bdf0641432371d2607c0e --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/vaccination_list_name_card.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" + style="@style/Card.Vaccination" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="24dp" + android:layout_marginTop="8dp" + android:orientation="vertical" + android:padding="16dp"> + + <TextView + android:id="@+id/name_card_title" + style="@style/body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:textSize="18sp" + android:textStyle="bold" + tools:text="Andrea Schneider" /> + + <TextView + android:id="@+id/name_card_subtitle" + style="@style/body2" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginTop="4dp" + tools:text="geboren am 18.04.1943" /> + +</LinearLayout> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/layout/vaccination_list_vaccination_card.xml b/Corona-Warn-App/src/main/res/layout/vaccination_list_vaccination_card.xml new file mode 100644 index 0000000000000000000000000000000000000000..2078b26bd35e9629862d68c7448a0d97068f3634 --- /dev/null +++ b/Corona-Warn-App/src/main/res/layout/vaccination_list_vaccination_card.xml @@ -0,0 +1,55 @@ +<?xml version="1.0" encoding="utf-8"?> +<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/vaccination_card" + style="@style/Card.Vaccination.Ripple" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_marginHorizontal="24dp" + android:layout_marginTop="8dp" + android:padding="16dp"> + + <ImageView + android:id="@+id/vaccination_icon" + android:layout_width="88dp" + android:layout_height="95dp" + android:importantForAccessibility="no" + app:srcCompat="@drawable/ic_vaccination_incomplete" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + + <TextView + android:id="@+id/vaccination_card_title" + style="@style/body2" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:text="@string/vaccination_list_vaccination_card_title" + android:textSize="18sp" + android:textStyle="bold" + app:layout_constraintEnd_toStartOf="@id/arrow_right" + app:layout_constraintStart_toEndOf="@id/vaccination_icon" + app:layout_constraintTop_toTopOf="parent" /> + + <TextView + android:id="@+id/vaccination_card_subtitle" + style="@style/body2Medium" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_marginStart="16dp" + android:layout_marginTop="4dp" + android:text="@string/vaccination_list_vaccination_card_subtitle" + app:layout_constraintEnd_toStartOf="@id/arrow_right" + app:layout_constraintStart_toEndOf="@id/vaccination_icon" + app:layout_constraintTop_toBottomOf="@id/vaccination_card_title" /> + + <ImageView + android:id="@+id/arrow_right" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:importantForAccessibility="no" + app:srcCompat="@drawable/ic_arrow_right_grey" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintTop_toTopOf="parent" /> + +</androidx.constraintlayout.widget.ConstraintLayout> \ No newline at end of file diff --git a/Corona-Warn-App/src/main/res/navigation/nav_graph.xml b/Corona-Warn-App/src/main/res/navigation/nav_graph.xml index b5478d44e7bb256e18d7cc6734c67a130f638784..c5c8028c76f5195eed471e00dcd9e218df7341a5 100644 --- a/Corona-Warn-App/src/main/res/navigation/nav_graph.xml +++ b/Corona-Warn-App/src/main/res/navigation/nav_graph.xml @@ -16,6 +16,9 @@ <!-- Rapid antigen test profile graph--> <include app:graph="@navigation/rapid_antigen_test_profile_nav_graph" /> + <!-- Vaccination graph--> + <include app:graph="@navigation/vaccination_nav_graph" /> + <!-- Main --> <fragment android:id="@+id/mainFragment" @@ -73,6 +76,9 @@ <action android:id="@+id/action_mainFragment_to_submissionTestResultKeysSharedFragment" app:destination="@id/submissionTestResultKeysSharedFragment" /> + <action + android:id="@+id/action_mainFragment_to_vaccinationNavGraph" + app:destination="@id/vaccination_nav_graph" /> </fragment> <fragment @@ -449,7 +455,7 @@ android:id="@+id/action_submissionSymptomIntroductionFragment_to_submissionDoneFragment" app:destination="@id/submissionDoneFragment" app:popUpTo="@id/mainFragment" - app:popUpToInclusive="false"/> + app:popUpToInclusive="false" /> </fragment> <fragment android:id="@+id/submissionSymptomCalendarFragment" @@ -476,7 +482,7 @@ android:id="@+id/action_submissionSymptomCalendarFragment_to_submissionDoneFragment" app:destination="@id/submissionDoneFragment" app:popUpTo="@id/mainFragment" - app:popUpToInclusive="false"/> + app:popUpToInclusive="false" /> </fragment> <fragment android:id="@+id/submissionConsentFragment" @@ -792,10 +798,10 @@ android:id="@+id/action_submissionDoneFragment_to_mainFragment" app:destination="@id/mainFragment" app:popUpTo="@id/nav_graph" - app:popUpToInclusive="true"/> + app:popUpToInclusive="true" /> <argument android:name="testType" - app:argType="de.rki.coronawarnapp.coronatest.type.CoronaTest$Type"/> + app:argType="de.rki.coronawarnapp.coronatest.type.CoronaTest$Type" /> </fragment> <fragment android:id="@+id/submissionTestResultKeysSharedFragment" @@ -806,4 +812,5 @@ android:name="testType" app:argType="de.rki.coronawarnapp.coronatest.type.CoronaTest$Type" /> </fragment> + </navigation> diff --git a/Corona-Warn-App/src/main/res/navigation/trace_location_attendee_nav_graph.xml b/Corona-Warn-App/src/main/res/navigation/trace_location_attendee_nav_graph.xml index b202c89c6ed2888b5fa2ea166927fa59979fbca3..494e80cbeffcbfbe77cf03a109ffc93dcc0a64e6 100644 --- a/Corona-Warn-App/src/main/res/navigation/trace_location_attendee_nav_graph.xml +++ b/Corona-Warn-App/src/main/res/navigation/trace_location_attendee_nav_graph.xml @@ -59,7 +59,7 @@ android:id="@+id/scanCheckInQrCodeFragment" android:name="de.rki.coronawarnapp.ui.presencetracing.attendee.scan.ScanCheckInQrCodeFragment" android:label="ScanCheckInQrCodeFragment" - tools:layout="@layout/fragment_scan_check_in_qr_code" /> + tools:layout="@layout/fragment_scan_qr_code" /> <fragment android:id="@+id/checkInsFragment" @@ -97,4 +97,4 @@ app:destination="@id/checkInOnboardingFragment" /> </fragment> -</navigation> \ No newline at end of file +</navigation> diff --git a/Corona-Warn-App/src/main/res/navigation/vaccination_nav_graph.xml b/Corona-Warn-App/src/main/res/navigation/vaccination_nav_graph.xml new file mode 100644 index 0000000000000000000000000000000000000000..a0c4e7a1a2e0edc2b0cd07f9ab73c611727e6800 --- /dev/null +++ b/Corona-Warn-App/src/main/res/navigation/vaccination_nav_graph.xml @@ -0,0 +1,43 @@ +<?xml version="1.0" encoding="utf-8"?> +<navigation xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:id="@+id/vaccination_nav_graph" + app:startDestination="@id/vaccinationQrCodeScanFragment"> + + <fragment + android:id="@+id/vaccinationQrCodeScanFragment" + android:name="de.rki.coronawarnapp.vaccination.ui.scan.VaccinationQrCodeScanFragment" + android:label="VaccinationQrCodeScanFragment" + tools:layout="@layout/fragment_scan_qr_code"> + <action + android:id="@+id/action_vaccinationQrCodeScanFragment_to_vaccinationDetailsFragment" + app:destination="@id/vaccinationDetailsFragment" + app:popUpTo="@id/vaccinationQrCodeScanFragment" + app:popUpToInclusive="true" /> + </fragment> + + <fragment + android:id="@+id/vaccinationListFragment" + android:name="de.rki.coronawarnapp.vaccination.ui.list.VaccinationListFragment" + android:label="fragment_vaccination_list" + tools:layout="@layout/fragment_vaccination_list"> + <argument + android:name="vaccinatedPersonId" + app:argType="string" /> + <action + android:id="@+id/action_vaccinationListFragment_to_vaccinationDetailsFragment" + app:destination="@id/vaccinationDetailsFragment" /> + <deepLink app:uri="coronawarnapp://vaccination-list/{vaccinatedPersonId}" /> + </fragment> + + <fragment + android:id="@+id/vaccinationDetailsFragment" + android:name="de.rki.coronawarnapp.vaccination.ui.details.VaccinationDetailsFragment" + android:label="fragment_vaccination_details" + tools:layout="@layout/fragment_vaccination_details"> + <argument + android:name="vaccinationCertificateId" + app:argType="string" /> + </fragment> +</navigation> diff --git a/Corona-Warn-App/src/main/res/values-de/vaccination_strings.xml b/Corona-Warn-App/src/main/res/values-de/vaccination_strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..54a86d4e31bfee48a76a4a53be35ab30e9d65206 --- /dev/null +++ b/Corona-Warn-App/src/main/res/values-de/vaccination_strings.xml @@ -0,0 +1,84 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- XTXT: Vaccination Details birth date --> + <string name="vaccination_details_birth_date">Geboren %1$s</string> + <!-- XTXT: Vaccination Details certificate date --> + <string name="vaccination_details_certificate_date">Datum der Impfung</string> + <!-- XTXT: Vaccination Details vaccine name --> + <string name="vaccination_details_vaccine_name">Impfstoff</string> + <!-- XTXT: Vaccination Details vaccine manufacturer --> + <string name="vaccination_details_vaccine_manufacturer">Hersteller</string> + <!-- XTXT: Vaccination Details certificate issuer --> + <string name="vaccination_details_certificate_issuer">Aussteller</string> + <!-- XTXT: Vaccination Details certificate country --> + <string name="vaccination_details_certificate_country">Land</string> + <!-- XTXT: Vaccination Details certificate id --> + <string name="vaccination_details_certificate_id">Zertifikationsnummer</string> + <!-- XBUT: Vaccination Details delete button --> + <string name="vaccination_details_delete_button">Impfzertifikat entfernen</string> + <!-- XTXT: Vaccination Details subtitle--> + <string name="vaccination_details_subtitle">Impfzertifikat</string> + <!-- XTXT: Vaccination Details title--> + <string name="vaccination_details_title">Impfung %1$d von %2$d</string> + <!-- XTXT: Vaccination Details deletion dialog title--> + <string name="vaccination_details_deletion_dialog_title">Wollen Sie das Impfzertifikat wirklich entfernen?</string> + <!-- XTXT: Vaccination Details deletion dialog message--> + <string name="vaccination_details_deletion_dialog_message">Wenn Sie das Impfzertifikat entfernen, kann die App die Impfung nicht mehr für die Prüfung Ihres Impfstatus berücksichtigen.</string> + <!-- XBUT: Vaccination Details deletion dialog positive button--> + <string name="vaccination_details_deletion_dialog_positive_button">Entfernen</string> + <!-- XBUT: Vaccination Details deletion dialog negative button--> + <string name="vaccination_details_deletion_dialog_negative_button">Abbrechen</string> + + <!-- XTXT: Vaccination List title--> + <string name="vaccination_list_title">Digitaler Impfnachweis</string> + <!-- XTXT: Vaccination List complete vaccination subtitle--> + <string name="vaccination_list_complete_vaccination_subtitle">SARS-CoV-2-Impfschutz</string> + <!-- XTXT: Vaccination List top card title--> + <string name="vaccination_list_top_card_title">SARS-CoV-2 Impfung</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_top_card_subtitle">Unvollständiger Impfschutz</string> + <!-- XTXT: Vaccination List name card subtitle--> + <string name="vaccination_list_name_card_subtitle">geboren %1$s</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_vaccination_card_title">Impfung %1$s von %2$s</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_vaccination_card_subtitle">durchgeführt am %1$s</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_certificate_card_title">COVID-19-Prüfzertifikat</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_certificate_card_subtitle">Noch %1$d Tage gültig</string> + <!-- XBUT: Vaccination List register additional vaccination button --> + <string name="vaccination_list_register_new_vaccination_button">Weitere Impfung registrieren</string> + <!-- XBUT: Vaccination List refresh button --> + <string name="vaccination_list_refresh_button">Aktualisieren</string> + <!-- XACT: Vaccination List Qr-Code for screen readers --> + <string name="vaccination_list_qr_code_accessibility">Qr-Code</string> + + <!-- #################################### + Homescreen cards + ###################################### --> + <!-- XHED: Title for Vaccination Certificate Registration Home Card --> + <string name="vaccination_card_registration_title">"Impfzertifikat registrieren"</string> + <!-- YTXT: Body text for Vaccination Certificate Registration Home Card --> + <string name="vaccination_card_registration_body">"Scannen Sie den QR-Code auf Ihrem Impfzertifikat, um Ihre Impfung zu registrieren und einen digitalen Impfnachweis zu erhalten."</string> + <!-- XBUT: button for Vaccination Certificate Registration Home Card --> + <string name="vaccination_card_register">"Registrieren"</string> + + <!-- XHED: Homescreen vaccination status card title --> + <string name="vaccination_card_status_title">Digitaler Impfnachweis</string> + <!-- XHED: Homescreen vaccination status card vaccination name --> + <string name="vaccination_card_status_vaccination_name">SARS-CoV-2 Impfschutz</string> + <!-- XTXT: Homescreen vaccination status card vaccination status label --> + <string name="vaccination_card_status_vaccination_incomplete">Unvollständiger Impfschutz</string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_invalid">Dieser QR-Code ist kein gültiges Impfzertifikat.</string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_not_yet_supported">Dieses Impfzertifikat wird in Ihrer App-Version noch nicht unterstützt. Bitte aktualisieren Sie Ihre App oder wenden Sie sich an die technische Hotline unter „App-Informationen“.</string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_scan_again">Das Impfzertifikat konnte nicht auf Ihrem Smartphone gespeichert werden. Bitte versuchen Sie es später noch einmal oder wenden Sie sich an die technische Hotline unter „App-Informationen“.</string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_already_registered">Das Impfzertifikat ist bereits in Ihrer App registriert.</string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_different_person">Die persönlichen Daten dieses Impfzertifikats stimmen nicht mit denen der bereits registrierten Zertifikate überein. Sie können in der App nur Zertifikate einer Person registrieren.</string> + +</resources> diff --git a/Corona-Warn-App/src/main/res/values-night/colors.xml b/Corona-Warn-App/src/main/res/values-night/colors.xml index 98ab8628d60233f4e20f818f634cf1ff47ad9347..9f357cf9d3ef6f1dbbe7dc2cca5455ab39d85364 100644 --- a/Corona-Warn-App/src/main/res/values-night/colors.xml +++ b/Corona-Warn-App/src/main/res/values-night/colors.xml @@ -77,4 +77,8 @@ <!-- QR Code Scan --> <color name="colorQrCodeScanToolbar">#FFFFFF</color> <color name="colorQrCodeScanMask">#BF000000</color> + + <!-- Vaccination --> + <color name="colorVaccinationCardBackground">#434445</color> + </resources> diff --git a/Corona-Warn-App/src/main/res/values/colors.xml b/Corona-Warn-App/src/main/res/values/colors.xml index 0c537ee78bf3599e4114803c7a7d9fbf89b59585..0e27295b4f82e1237ed2f313d904cff8ee851afc 100644 --- a/Corona-Warn-App/src/main/res/values/colors.xml +++ b/Corona-Warn-App/src/main/res/values/colors.xml @@ -98,7 +98,6 @@ <color name="colorTraceLocationGradientBackground">#F5F5F5</color> <color name="colorTraceLocationButtonTextColor">#F5F5F5</color> - <!-- QR Code Scan --> <color name="colorQrCodeScanToolbar">#000000</color> <color name="colorQrCodeScanMask">#BFFFFFFF</color> @@ -106,5 +105,8 @@ <!-- Swipe to delete background color --> <color name="swipeDeleteBackgroundColor">#EB4D3D</color> + <!-- Vaccination --> + <color name="colorVaccinationCardBackground">#FFFFFF</color> + <color name="whiteAlpha60">#99FFFFFF</color> </resources> diff --git a/Corona-Warn-App/src/main/res/values/styles.xml b/Corona-Warn-App/src/main/res/values/styles.xml index 5d15c348679f1361518f54ef889e8edb9b53f6c0..3923b66d0316395117d3425f382b7b9371484648 100644 --- a/Corona-Warn-App/src/main/res/values/styles.xml +++ b/Corona-Warn-App/src/main/res/values/styles.xml @@ -237,6 +237,13 @@ <item name="android:elevation">@dimen/elevation_none</item> </style> + <style name="Card.Vaccination" parent="Card.NoElevation"> + <item name="android:backgroundTint">@color/colorVaccinationCardBackground</item> + </style> + + <style name="Card.Vaccination.Ripple"> + <item name="android:background">@drawable/grey_card_ripple</item> + </style> <style name="Card.NoPadding"> <item name="android:padding">@dimen/no_padding</item> diff --git a/Corona-Warn-App/src/main/res/values/vaccination_strings.xml b/Corona-Warn-App/src/main/res/values/vaccination_strings.xml new file mode 100644 index 0000000000000000000000000000000000000000..ca5e6519bde032ed0c29a0bd443f0668a6f3a82c --- /dev/null +++ b/Corona-Warn-App/src/main/res/values/vaccination_strings.xml @@ -0,0 +1,84 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources xmlns:tools="http://schemas.android.com/tools" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2" tools:ignore="MissingTranslation"> + <!-- XTXT: Vaccination Details birth date --> + <string name="vaccination_details_birth_date">Geboren %1$s</string> + <!-- XTXT: Vaccination Details certificate date --> + <string name="vaccination_details_certificate_date">Datum der Impfung</string> + <!-- XTXT: Vaccination Details vaccine name --> + <string name="vaccination_details_vaccine_name">Impfstoff</string> + <!-- XTXT: Vaccination Details vaccine manufacturer --> + <string name="vaccination_details_vaccine_manufacturer">Hersteller</string> + <!-- XTXT: Vaccination Details certificate issuer --> + <string name="vaccination_details_certificate_issuer">Aussteller</string> + <!-- XTXT: Vaccination Details certificate country --> + <string name="vaccination_details_certificate_country">Land</string> + <!-- XTXT: Vaccination Details certificate id --> + <string name="vaccination_details_certificate_id">Zertifikationsnummer</string> + <!-- XBUT: Vaccination Details delete button --> + <string name="vaccination_details_delete_button">Impfzertifikat entfernen</string> + <!-- XTXT: Vaccination Details subtitle--> + <string name="vaccination_details_subtitle">Impfzertifikat</string> + <!-- XTXT: Vaccination Details title--> + <string name="vaccination_details_title">Impfung %1$d von %2$d</string> + <!-- XTXT: Vaccination Details deletion dialog title--> + <string name="vaccination_details_deletion_dialog_title">Wollen Sie das Impfzertifikat wirklich entfernen?</string> + <!-- XTXT: Vaccination Details deletion dialog message--> + <string name="vaccination_details_deletion_dialog_message">Wenn Sie das Impfzertifikat entfernen, kann die App die Impfung nicht mehr für die Prüfung Ihres Impfstatus berücksichtigen.</string> + <!-- XBUT: Vaccination Details deletion dialog positive button--> + <string name="vaccination_details_deletion_dialog_positive_button">Entfernen</string> + <!-- XBUT: Vaccination Details deletion dialog negative button--> + <string name="vaccination_details_deletion_dialog_negative_button">Abbrechen</string> + + <!-- XTXT: Vaccination List title--> + <string name="vaccination_list_title">Digitaler Impfnachweis</string> + <!-- XTXT: Vaccination List complete vaccination subtitle--> + <string name="vaccination_list_complete_vaccination_subtitle">SARS-CoV-2-Impfschutz</string> + <!-- XTXT: Vaccination List top card title--> + <string name="vaccination_list_top_card_title">SARS-CoV-2 Impfung</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_top_card_subtitle">Unvollständiger Impfschutz</string> + <!-- XTXT: Vaccination List name card subtitle--> + <string name="vaccination_list_name_card_subtitle">geboren %1$s</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_vaccination_card_title">Impfung %1$s von %2$s</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_vaccination_card_subtitle">durchgeführt am %1$s</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_certificate_card_title">COVID-19-Prüfzertifikat</string> + <!-- XTXT: Vaccination List top card subtitle--> + <string name="vaccination_list_certificate_card_subtitle">Noch %1$d Tage gültig</string> + <!-- XBUT: Vaccination List register additional vaccination button --> + <string name="vaccination_list_register_new_vaccination_button">Weitere Impfung registrieren</string> + <!-- XBUT: Vaccination List refresh button --> + <string name="vaccination_list_refresh_button">Aktualisieren</string> + <!-- XACT: Vaccination List Qr-Code for screen readers --> + <string name="vaccination_list_qr_code_accessibility">Qr-Code</string> + + <!-- #################################### + Homescreen cards + ###################################### --> + <!-- XHED: Title for Vaccination Certificate Registration Home Card --> + <string name="vaccination_card_registration_title">"Impfzertifikat registrieren"</string> + <!-- YTXT: Body text for Vaccination Certificate Registration Home Card --> + <string name="vaccination_card_registration_body">"Scannen Sie den QR-Code auf Ihrem Impfzertifikat, um Ihre Impfung zu registrieren und einen digitalen Impfnachweis zu erhalten."</string> + <!-- XBUT: button for Vaccination Certificate Registration Home Card --> + <string name="vaccination_card_register">"Registrieren"</string> + + <!-- XHED: Homescreen card title --> + <string name="vaccination_card_status_title">Digitaler Impfnachweis</string> + <!-- XHED: Homescreen vaccination status card vaccination name --> + <string name="vaccination_card_status_vaccination_name">SARS-CoV-2 Impfschutz</string> + <!-- XTXT: Homescreen card vaccination status label --> + <string name="vaccination_card_status_vaccination_incomplete">Unvollständiger Impfschutz</string> + + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_invalid"></string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_not_yet_supported"></string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_scan_again"></string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_already_registered"></string> + <!-- XTXT: Vaccination QR code scan error message--> + <string name="error_vc_different_person"></string> +</resources> diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/CoronaWarnApplicationTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/CoronaWarnApplicationTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..65ab5efe75b2ee24c625d50fde410893d31d355b --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/CoronaWarnApplicationTest.kt @@ -0,0 +1,167 @@ +package de.rki.coronawarnapp + +import androidx.work.WorkManager +import dagger.android.DispatchingAndroidInjector +import de.rki.coronawarnapp.appconfig.ConfigChangeDetector +import de.rki.coronawarnapp.appconfig.devicetime.DeviceTimeHandler +import de.rki.coronawarnapp.contactdiary.retention.ContactDiaryWorkScheduler +import de.rki.coronawarnapp.coronatest.CoronaTestRepository +import de.rki.coronawarnapp.coronatest.notification.ShareTestResultNotificationService +import de.rki.coronawarnapp.coronatest.type.pcr.execution.PCRResultScheduler +import de.rki.coronawarnapp.coronatest.type.pcr.notification.PCRTestResultAvailableNotificationService +import de.rki.coronawarnapp.coronatest.type.rapidantigen.execution.RAResultScheduler +import de.rki.coronawarnapp.coronatest.type.rapidantigen.notification.RATTestResultAvailableNotificationService +import de.rki.coronawarnapp.datadonation.analytics.worker.DataDonationAnalyticsScheduler +import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler +import de.rki.coronawarnapp.notification.GeneralNotifications +import de.rki.coronawarnapp.presencetracing.checkins.checkout.auto.AutoCheckOut +import de.rki.coronawarnapp.presencetracing.risk.execution.PresenceTracingRiskWorkScheduler +import de.rki.coronawarnapp.presencetracing.storage.retention.TraceLocationDbCleanUpScheduler +import de.rki.coronawarnapp.risk.RiskLevelChangeDetector +import de.rki.coronawarnapp.risk.execution.ExposureWindowRiskWorkScheduler +import de.rki.coronawarnapp.submission.auto.AutoSubmission +import de.rki.coronawarnapp.task.TaskController +import de.rki.coronawarnapp.util.CWADebug +import de.rki.coronawarnapp.util.WatchdogService +import de.rki.coronawarnapp.util.device.ForegroundState +import de.rki.coronawarnapp.util.di.AppInjector +import de.rki.coronawarnapp.util.di.ApplicationComponent +import de.rki.coronawarnapp.vaccination.core.execution.VaccinationUpdateScheduler +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.verifySequence +import org.conscrypt.Conscrypt +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import timber.log.Timber +import java.security.Security + +class CoronaWarnApplicationTest : BaseTest() { + + @MockK lateinit var applicationComponent: ApplicationComponent + @MockK lateinit var androidInjector: DispatchingAndroidInjector<Any> + @MockK lateinit var watchdogService: WatchdogService + @MockK lateinit var taskController: TaskController + @MockK lateinit var foregroundState: ForegroundState + @MockK lateinit var workManager: WorkManager + @MockK lateinit var configChangeDetector: ConfigChangeDetector + @MockK lateinit var riskLevelChangeDetector: RiskLevelChangeDetector + @MockK lateinit var deadmanNotificationScheduler: DeadmanNotificationScheduler + @MockK lateinit var contactDiaryWorkScheduler: ContactDiaryWorkScheduler + @MockK lateinit var dataDonationAnalyticsScheduler: DataDonationAnalyticsScheduler + @MockK lateinit var notificationHelper: GeneralNotifications + @MockK lateinit var deviceTimeHandler: DeviceTimeHandler + @MockK lateinit var autoSubmission: AutoSubmission + @MockK lateinit var coronaTestRepository: CoronaTestRepository + @MockK lateinit var autoCheckOut: AutoCheckOut + @MockK lateinit var traceLocationDbCleanupScheduler: TraceLocationDbCleanUpScheduler + @MockK lateinit var shareTestResultNotificationService: ShareTestResultNotificationService + @MockK lateinit var exposureWindowRiskWorkScheduler: ExposureWindowRiskWorkScheduler + @MockK lateinit var presenceTracingRiskWorkScheduler: PresenceTracingRiskWorkScheduler + @MockK lateinit var pcrTestResultScheduler: PCRResultScheduler + @MockK lateinit var raTestResultScheduler: RAResultScheduler + + @MockK lateinit var pcrTestResultAvailableNotificationService: PCRTestResultAvailableNotificationService + + @MockK lateinit var raTestResultAvailableNotificationService: RATTestResultAvailableNotificationService + @MockK lateinit var vaccinationUpdateScheduler: VaccinationUpdateScheduler + @MockK lateinit var rollingLogHistory: Timber.Tree + + @BeforeEach + fun setup() { + MockKAnnotations.init(this, relaxed = true) + + mockkStatic(Conscrypt::class) + every { Conscrypt.newProvider() } returns mockk() + + mockkStatic(Security::class) + every { Security.insertProviderAt(any(), any()) } returns 0 + + mockkObject(CWADebug) + CWADebug.apply { + every { init(any()) } just Runs + every { initAfterInjection(any()) } just Runs + } + + mockkObject(AppInjector) + AppInjector.apply { + every { init(any()) } returns applicationComponent + } + applicationComponent.apply { + every { inject(any<CoronaWarnApplication>()) } answers { + val app = arg<CoronaWarnApplication>(0) + app.component = applicationComponent + app.androidInjector = androidInjector + app.watchdogService = watchdogService + app.taskController = taskController + app.foregroundState = foregroundState + app.workManager = workManager + app.configChangeDetector = configChangeDetector + app.riskLevelChangeDetector = riskLevelChangeDetector + app.deadmanNotificationScheduler = deadmanNotificationScheduler + app.contactDiaryWorkScheduler = contactDiaryWorkScheduler + app.dataDonationAnalyticsScheduler = dataDonationAnalyticsScheduler + app.notificationHelper = notificationHelper + app.deviceTimeHandler = deviceTimeHandler + app.autoSubmission = autoSubmission + app.coronaTestRepository = coronaTestRepository + app.autoCheckOut = autoCheckOut + app.traceLocationDbCleanupScheduler = traceLocationDbCleanupScheduler + app.shareTestResultNotificationService = shareTestResultNotificationService + app.exposureWindowRiskWorkScheduler = exposureWindowRiskWorkScheduler + app.presenceTracingRiskWorkScheduler = presenceTracingRiskWorkScheduler + app.pcrTestResultScheduler = pcrTestResultScheduler + app.raTestResultScheduler = raTestResultScheduler + app.pcrTestResultAvailableNotificationService = pcrTestResultAvailableNotificationService + app.raTestResultAvailableNotificationService = raTestResultAvailableNotificationService + app.vaccinationUpdateScheduler = vaccinationUpdateScheduler + app.rollingLogHistory = object : Timber.Tree() { + override fun log(priority: Int, tag: String?, message: String, t: Throwable?) { + // NOOP + } + } + } + } + } + + private fun createInstance() = CoronaWarnApplication() + + @Test + fun `all setups are called`() { + createInstance().onCreate() + + verifySequence { + + watchdogService.launch() + contactDiaryWorkScheduler.setup() + + deadmanNotificationScheduler.setup() + + exposureWindowRiskWorkScheduler.setup() + presenceTracingRiskWorkScheduler.setup() + + pcrTestResultScheduler.setup() + raTestResultScheduler.setup() + + pcrTestResultAvailableNotificationService.setup() + raTestResultAvailableNotificationService.setup() + + vaccinationUpdateScheduler.setup() + + deviceTimeHandler.launch() + configChangeDetector.launch() + riskLevelChangeDetector.launch() + autoSubmission.setup() + autoCheckOut.setupMonitor() + traceLocationDbCleanupScheduler.scheduleDaily() + shareTestResultNotificationService.setup() + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt index cfbee9a6393b9464a3c8b85d14e5dd9158516988..2825adb0544c484dd185beade0c336934e322228 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/environment/EnvironmentSetupTest.kt @@ -68,6 +68,8 @@ class EnvironmentSetupTest : BaseTest() { dataDonationCdnUrl shouldBe "https://datadonation-${env.rawKey}" logUploadServerUrl shouldBe "https://logupload-${env.rawKey}" crowdNotifierPublicKey shouldBe "123_abc-${env.rawKey}" + vaccinationProofServerUrl shouldBe "https://vaccination_proof-${env.rawKey}" + vaccinationCdnUrl shouldBe "https://vaccination-${env.rawKey}" } } } @@ -127,7 +129,9 @@ class EnvironmentSetupTest : BaseTest() { EnvironmentSetup.EnvKey.LOG_UPLOAD.rawKey shouldBe "LOG_UPLOAD_SERVER_URL" EnvironmentSetup.EnvKey.SAFETYNET_API_KEY.rawKey shouldBe "SAFETYNET_API_KEY" EnvironmentSetup.EnvKey.CROWD_NOTIFIER_PUBLIC_KEY.rawKey shouldBe "CROWD_NOTIFIER_PUBLIC_KEY" - EnvironmentSetup.EnvKey.values().size shouldBe 9 + EnvironmentSetup.EnvKey.VACCINATION_PROOF.rawKey shouldBe "VACCINATION_PROOF_SERVER_URL" + EnvironmentSetup.EnvKey.VACCINATION_VALUE.rawKey shouldBe "VACCINATION_CDN_URL" + EnvironmentSetup.EnvKey.values().size shouldBe 11 } companion object { @@ -149,6 +153,8 @@ class EnvironmentSetupTest : BaseTest() { "VERIFICATION_CDN_URL": "https://verification-PROD", "DATA_DONATION_CDN_URL": "https://datadonation-PROD", "LOG_UPLOAD_SERVER_URL": "https://logupload-PROD", + "VACCINATION_PROOF_SERVER_URL": "https://vaccination_proof-PROD", + "VACCINATION_CDN_URL": "https://vaccination-PROD", "SAFETYNET_API_KEY": "placeholder-PROD", "PUB_KEYS_SIGNATURE_VERIFICATION": "12345678-PROD", "CROWD_NOTIFIER_PUBLIC_KEY": "123_abc-PROD" @@ -160,6 +166,8 @@ class EnvironmentSetupTest : BaseTest() { "VERIFICATION_CDN_URL": "https://verification-DEV", "DATA_DONATION_CDN_URL": "https://datadonation-DEV", "LOG_UPLOAD_SERVER_URL": "https://logupload-DEV", + "VACCINATION_PROOF_SERVER_URL": "https://vaccination_proof-DEV", + "VACCINATION_CDN_URL": "https://vaccination-DEV", "SAFETYNET_API_KEY": "placeholder-DEV", "PUB_KEYS_SIGNATURE_VERIFICATION": "12345678-DEV", "CROWD_NOTIFIER_PUBLIC_KEY": "123_abc-DEV" @@ -171,6 +179,8 @@ class EnvironmentSetupTest : BaseTest() { "VERIFICATION_CDN_URL": "https://verification-INT", "DATA_DONATION_CDN_URL": "https://datadonation-INT", "LOG_UPLOAD_SERVER_URL": "https://logupload-INT", + "VACCINATION_PROOF_SERVER_URL": "https://vaccination_proof-INT", + "VACCINATION_CDN_URL": "https://vaccination-INT", "SAFETYNET_API_KEY": "placeholder-INT", "PUB_KEYS_SIGNATURE_VERIFICATION": "12345678-INT", "CROWD_NOTIFIER_PUBLIC_KEY": "123_abc-INT" @@ -182,6 +192,8 @@ class EnvironmentSetupTest : BaseTest() { "VERIFICATION_CDN_URL": "https://verification-WRU", "DATA_DONATION_CDN_URL": "https://datadonation-WRU", "LOG_UPLOAD_SERVER_URL": "https://logupload-WRU", + "VACCINATION_PROOF_SERVER_URL": "https://vaccination_proof-WRU", + "VACCINATION_CDN_URL": "https://vaccination-WRU", "SAFETYNET_API_KEY": "placeholder-WRU", "PUB_KEYS_SIGNATURE_VERIFICATION": "12345678-WRU", "CREATE_TRACELOCATION_URL": "https://tracelocation-WRU", @@ -194,6 +206,8 @@ class EnvironmentSetupTest : BaseTest() { "VERIFICATION_CDN_URL": "https://verification-WRU-XD", "DATA_DONATION_CDN_URL": "https://datadonation-WRU-XD", "LOG_UPLOAD_SERVER_URL": "https://logupload-WRU-XD", + "VACCINATION_PROOF_SERVER_URL": "https://vaccination_proof-WRU-XD", + "VACCINATION_CDN_URL": "https://vaccination-WRU-XD", "SAFETYNET_API_KEY": "placeholder-WRU-XD", "PUB_KEYS_SIGNATURE_VERIFICATION": "12345678-WRU-XD", "CROWD_NOTIFIER_PUBLIC_KEY": "123_abc-WRU-XD" @@ -205,6 +219,8 @@ class EnvironmentSetupTest : BaseTest() { "VERIFICATION_CDN_URL": "https://verification-WRU-XA", "DATA_DONATION_CDN_URL": "https://datadonation-WRU-XA", "LOG_UPLOAD_SERVER_URL": "https://logupload-WRU-XA", + "VACCINATION_PROOF_SERVER_URL": "https://vaccination_proof-WRU-XA", + "VACCINATION_CDN_URL": "https://vaccination-WRU-XA", "SAFETYNET_API_KEY": "placeholder-WRU-XA", "PUB_KEYS_SIGNATURE_VERIFICATION": "12345678-WRU-XA", "CROWD_NOTIFIER_PUBLIC_KEY": "123_abc-WRU-XA" @@ -216,6 +232,8 @@ class EnvironmentSetupTest : BaseTest() { "VERIFICATION_CDN_URL": "https://verification-TESTER-MOCK", "DATA_DONATION_CDN_URL": "https://datadonation-TESTER-MOCK", "LOG_UPLOAD_SERVER_URL": "https://logupload-TESTER-MOCK", + "VACCINATION_PROOF_SERVER_URL": "https://vaccination_proof-TESTER-MOCK", + "VACCINATION_CDN_URL": "https://vaccination-TESTER-MOCK", "SAFETYNET_API_KEY": "placeholder-TESTER-MOCK", "PUB_KEYS_SIGNATURE_VERIFICATION": "12345678-TESTER-MOCK", "CROWD_NOTIFIER_PUBLIC_KEY": "123_abc-TESTER-MOCK" @@ -227,6 +245,8 @@ class EnvironmentSetupTest : BaseTest() { "VERIFICATION_CDN_URL": "https://verification-LOCAL", "DATA_DONATION_CDN_URL": "https://datadonation-LOCAL", "LOG_UPLOAD_SERVER_URL": "https://logupload-LOCAL", + "VACCINATION_PROOF_SERVER_URL": "https://vaccination_proof-LOCAL", + "VACCINATION_CDN_URL": "https://vaccination-LOCAL", "SAFETYNET_API_KEY": "placeholder-LOCAL", "PUB_KEYS_SIGNATURE_VERIFICATION": "12345678-LOCAL", "CROWD_NOTIFIER_PUBLIC_KEY": "123_abc-LOCAL" diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt index 3e45f0f9e0e9dd99e70cab18024572854c0acc9a..20cdd3a3532661af3174fb773bf43bc587ceffee 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt @@ -21,6 +21,7 @@ import de.rki.coronawarnapp.util.TimeStamper import de.rki.coronawarnapp.util.bluetooth.BluetoothSupport import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool import de.rki.coronawarnapp.util.shortcuts.AppShortcutsHelper +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository import io.kotest.matchers.shouldBe import io.mockk.MockKAnnotations import io.mockk.coEvery @@ -62,6 +63,7 @@ class HomeFragmentViewModelTest : BaseTest() { @MockK lateinit var traceLocationOrganizerSettings: TraceLocationOrganizerSettings @MockK lateinit var timeStamper: TimeStamper @MockK lateinit var bluetoothSupport: BluetoothSupport + @MockK lateinit var vaccinationRepository: VaccinationRepository @BeforeEach fun setup() { @@ -74,6 +76,8 @@ class HomeFragmentViewModelTest : BaseTest() { every { coronaTestRepository.coronaTests } returns emptyFlow() + every { vaccinationRepository.vaccinationInfos } returns emptyFlow() + coEvery { appConfigProvider.currentConfig } returns emptyFlow() coEvery { statisticsProvider.current } returns emptyFlow() @@ -100,7 +104,8 @@ class HomeFragmentViewModelTest : BaseTest() { tracingSettings = tracingSettings, traceLocationOrganizerSettings = traceLocationOrganizerSettings, timeStamper = timeStamper, - bluetoothSupport = bluetoothSupport + bluetoothSupport = bluetoothSupport, + vaccinationRepository = vaccinationRepository, ) @Test diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/Base32Test.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base32ExtensionsTest.kt similarity index 89% rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/Base32Test.kt rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base32ExtensionsTest.kt index 92e08398518bafc4470cc7c6e463b77675f7a830..9ea3944045b218e40a5782e65e83e8efdf6d4028 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/Base32Test.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base32ExtensionsTest.kt @@ -1,12 +1,14 @@ -package de.rki.coronawarnapp.util +package de.rki.coronawarnapp.util.encoding +import de.rki.coronawarnapp.util.NoPaddingTestProvider +import de.rki.coronawarnapp.util.WithPaddingTestProvider import io.kotest.matchers.shouldBe import okio.ByteString.Companion.toByteString import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.ArgumentsSource import testhelpers.BaseTest -class Base32Test : BaseTest() { +class Base32ExtensionsTest : BaseTest() { @ParameterizedTest @ArgumentsSource(NoPaddingTestProvider::class) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base45DecoderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base45DecoderTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..3e6a8bc53506f2ce361fab5ca91c02059defca16 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base45DecoderTest.kt @@ -0,0 +1,23 @@ +package de.rki.coronawarnapp.util.encoding + +import io.kotest.matchers.shouldBe +import okio.internal.commonAsUtf8ToByteArray +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class Base45DecoderTest : BaseTest() { + + @Test + fun `encode - direct`() { + Base45Decoder.encode("AB".toByteArray()) shouldBe "BB8" + Base45Decoder.encode("Hello!!".commonAsUtf8ToByteArray()) shouldBe "%69 VD92EX0" + Base45Decoder.encode("base-45".commonAsUtf8ToByteArray()) shouldBe "UJCLQE7W581" + } + + @Test + fun `decode - direct`() { + Base45Decoder.decode("BB8") shouldBe "AB".toByteArray() + Base45Decoder.decode("%69 VD92EX0") shouldBe "Hello!!".toByteArray() + Base45Decoder.decode("UJCLQE7W581") shouldBe "base-45".toByteArray() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base45ExtensionsTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base45ExtensionsTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f3af93059e26963f129702b15665b9fb086deb52 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/encoding/Base45ExtensionsTest.kt @@ -0,0 +1,24 @@ +package de.rki.coronawarnapp.util.encoding + +import io.kotest.matchers.shouldBe +import okio.ByteString.Companion.toByteString +import okio.internal.commonAsUtf8ToByteArray +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class Base45ExtensionsTest : BaseTest() { + + @Test + fun `encode - extension`() { + "AB".toByteArray().toByteString().base45() shouldBe "BB8" + "Hello!!".commonAsUtf8ToByteArray().toByteString().base45() shouldBe "%69 VD92EX0" + "base-45".commonAsUtf8ToByteArray().toByteString().base45() shouldBe "UJCLQE7W581" + } + + @Test + fun `decode - extension`() { + "BB8".decodeBase45() shouldBe "AB".toByteArray().toByteString() + "%69 VD92EX0".decodeBase45() shouldBe "Hello!!".toByteArray().toByteString() + "UJCLQE7W581".decodeBase45() shouldBe "base-45".toByteArray().toByteString() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt index 812d53b027a0a988bf3eff783e52b8ba787a3f5b..063debf6e86734acf173dd68af3cae32ea7d7548 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/worker/WorkerBinderTest.kt @@ -28,6 +28,7 @@ import de.rki.coronawarnapp.submission.SubmissionRepository import de.rki.coronawarnapp.task.TaskController import de.rki.coronawarnapp.util.di.AppContext import de.rki.coronawarnapp.util.serialization.BaseGson +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository import io.github.classgraph.ClassGraph import io.kotest.matchers.collections.shouldContainAll import io.mockk.mockk @@ -167,4 +168,7 @@ class MockProvider { @Provides fun ratResultScheduler(): RAResultScheduler = mockk() + + @Provides + fun vaccinationRepository(): VaccinationRepository = mockk() } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/RawCOSEObjectTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/RawCOSEObjectTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..b7c61187f27de4c09cb5a279b52d00d4b918895b --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/RawCOSEObjectTest.kt @@ -0,0 +1,51 @@ +package de.rki.coronawarnapp.vaccination.core + +import com.google.gson.GsonBuilder +import de.rki.coronawarnapp.util.serialization.fromJson +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class RawCOSEObjectTest : BaseTest() { + + @BeforeEach + fun setup() { + } + + @Test + fun `comparison and conversion`() { + val rawRaw = "The Cake Is A Lie!".toByteArray() + val rawRaw2 = "The Cake Is Not A Lie!".toByteArray() // This is a lie + val rawCOSEObject1 = RawCOSEObject(rawRaw) + val rawCOSEObject2 = RawCOSEObject(rawRaw2) + + rawRaw shouldNotBe rawRaw2 + rawCOSEObject1 shouldNotBe rawCOSEObject2 + + rawCOSEObject1.asByteArray shouldBe rawRaw + rawCOSEObject2.asByteArray shouldBe rawRaw2 + } + + @Test + fun `serialization and deserialization`() { + val rawRaw = "The Cake Is A Lie!".toByteArray() + val rawRaw2 = "The Cake Is Not A Lie!".toByteArray() // This is a lie + val rawCOSEObject1 = RawCOSEObject(rawRaw) + val rawCOSEObject2 = RawCOSEObject(rawRaw2) + + val gson = GsonBuilder().apply { + registerTypeAdapter(RawCOSEObject::class.java, RawCOSEObject.JsonAdapter()) + }.create() + + val json1 = gson.toJson(rawCOSEObject1) + json1 shouldBe "\"VGhlIENha2UgSXMgQSBMaWUh\"" + gson.fromJson<RawCOSEObject>(json1) shouldBe rawCOSEObject1 + + val json2 = gson.toJson(rawCOSEObject2) + json2 shouldBe "\"VGhlIENha2UgSXMgTm90IEEgTGllIQ\\u003d\\u003d\"" + gson.fromJson<RawCOSEObject>(json2) shouldBe rawCOSEObject2 + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPersonTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPersonTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..3f0c885ab77bb48911eecd415f0e7f0c7bbb20b4 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinatedPersonTest.kt @@ -0,0 +1,39 @@ +package de.rki.coronawarnapp.vaccination.core + +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinatedPersonData +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationContainer +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class VaccinatedPersonTest : BaseTest() { + + @Test + fun `test name combinations`() { + val certificate = mockk<VaccinationCertificate>() + val vaccinationContainer = mockk<VaccinationContainer>().apply { + every { toVaccinationCertificate(any()) } returns certificate + } + val personData = mockk<VaccinatedPersonData>().apply { + every { vaccinations } returns setOf(vaccinationContainer) + } + val vaccinatedPerson = VaccinatedPerson( + data = personData, + valueSet = null + ) + + certificate.apply { + every { firstName } returns "Straw" + every { lastName } returns "Berry" + } + vaccinatedPerson.fullName shouldBe "Straw Berry" + + certificate.apply { + every { firstName } returns null // Thermo + every { lastName } returns "Siphon" + } + vaccinatedPerson.fullName shouldBe "Siphon" + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt new file mode 100644 index 0000000000000000000000000000000000000000..549976659c091ed165b7abc9523867b45d2c29fb --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/VaccinationTestData.kt @@ -0,0 +1,264 @@ +package de.rki.coronawarnapp.vaccination.core + +import de.rki.coronawarnapp.util.encoding.decodeBase45 +import de.rki.coronawarnapp.vaccination.core.common.RawCOSEObject +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateData +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateHeader +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateQRCode +import de.rki.coronawarnapp.vaccination.core.qrcode.VaccinationCertificateV1 +import de.rki.coronawarnapp.vaccination.core.repository.storage.ProofContainer +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinatedPersonData +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationContainer +import de.rki.coronawarnapp.vaccination.core.server.ProofCertificateV1 +import de.rki.coronawarnapp.vaccination.core.server.proof.ProofCertificateData +import de.rki.coronawarnapp.vaccination.core.server.proof.ProofCertificateResponse +import de.rki.coronawarnapp.vaccination.decoder.ZLIBDecompressor +import okio.ByteString.Companion.decodeBase64 +import okio.internal.commonAsUtf8ToByteArray +import org.joda.time.Instant +import org.joda.time.LocalDate + +object VaccinationTestData { + + val PERSON_A_VAC_1_JSON = VaccinationCertificateV1( + version = "1.0.0", + nameData = VaccinationCertificateV1.NameData( + givenName = "François-Joan", + givenNameStandardized = "FRANCOIS<JOAN", + familyName = "d'Arsøns - van Halen", + familyNameStandardized = "DARSONS<VAN<HALEN", + ), + dob = "2009-02-28", + vaccinationDatas = listOf( + VaccinationCertificateV1.VaccinationData( + targetId = "840539006", + vaccineId = "1119349007", + medicalProductId = "EU/1/20/1528", + marketAuthorizationHolderId = "ORG-100030215", + doseNumber = 1, + totalSeriesOfDoses = 2, + dt = "2021-04-21", + countryOfVaccination = "NL", + certificateIssuer = "Ministry of Public Health, Welfare and Sport", + uniqueCertificateIdentifier = "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ", + ) + ), + ) + + val PERSON_A_VAC_1_HEADER = VaccinationCertificateHeader( + issuer = "Ministry of Public Health, Welfare and Sport", + issuedAt = Instant.ofEpochMilli(1620149204473), + expiresAt = Instant.ofEpochMilli(11620149234473) + ) + + val PERSON_A_VAC_1_DATA = VaccinationCertificateData( + header = PERSON_A_VAC_1_HEADER, + vaccinationCertificate = PERSON_A_VAC_1_JSON + ) + + val PERSON_A_VAC_1_QRCODE = VaccinationCertificateQRCode( + parsedData = PERSON_A_VAC_1_DATA, + certificateCOSE = "VGhlIENha2UgaXMgTm90IGEgTGll".toCOSEObject() + ) + + val PERSON_A_VAC_1_CONTAINER = VaccinationContainer( + scannedAt = Instant.ofEpochMilli(1620062834471), + vaccinationCertificateCOSE = "VGhlIGNha2UgaXMgYSBsaWUu".toCOSEObject(), + ).apply { + preParsedData = PERSON_A_VAC_1_DATA + } + + val PERSON_A_VAC_2_JSON = VaccinationCertificateV1( + version = "1.0.0", + nameData = VaccinationCertificateV1.NameData( + givenName = "François-Joan", + givenNameStandardized = "FRANCOIS<JOAN", + familyName = "d'Arsøns - van Halen", + familyNameStandardized = "DARSONS<VAN<HALEN", + ), + dob = "2009-02-28", + vaccinationDatas = listOf( + VaccinationCertificateV1.VaccinationData( + targetId = "840539006", + vaccineId = "1119349007", + medicalProductId = "EU/1/20/1528", + marketAuthorizationHolderId = "ORG-100030215", + doseNumber = 1, + totalSeriesOfDoses = 2, + dt = "2021-04-22", + countryOfVaccination = "NL", + certificateIssuer = "Ministry of Public Health, Welfare and Sport", + uniqueCertificateIdentifier = "urn:uvci:01:NL:THECAKEISALIE", + ) + ), + ) + + val PERSON_A_VAC_2_HEADER = VaccinationCertificateHeader( + issuer = "Ministry of Public Health, Welfare and Sport", + issuedAt = Instant.ofEpochMilli(1620149204473), + expiresAt = Instant.ofEpochMilli(11620149234473) + ) + + val PERSON_A_VAC_2_DATA = VaccinationCertificateData( + header = PERSON_A_VAC_2_HEADER, + vaccinationCertificate = PERSON_A_VAC_2_JSON + ) + + val PERSON_A_VAC_2_QRCODE = VaccinationCertificateQRCode( + parsedData = PERSON_A_VAC_2_DATA, + certificateCOSE = "VGhlIGNha2UgaXMgYSBsaWUu".toCOSEObject() + ) + + val PERSON_A_VAC_2_CONTAINER = VaccinationContainer( + scannedAt = Instant.ofEpochMilli(1620149234473), + vaccinationCertificateCOSE = "VGhlIENha2UgaXMgTm90IGEgTGll".toCOSEObject(), + ).apply { + preParsedData = PERSON_A_VAC_2_DATA + } + + val PERSON_A_PROOF_JSON = ProofCertificateV1( + version = "1.0.0", + nameData = ProofCertificateV1.NameData( + givenName = "François-Joan", + givenNameStandardized = "FRANCOIS<JOAN", + familyName = "d'Arsøns - van Halen", + familyNameStandardized = "DARSONS<VAN<HALEN", + ), + dateOfBirth = LocalDate.parse("2009-02-28"), + vaccinationDatas = listOf( + ProofCertificateV1.VaccinationData( + targetId = "840539006", + vaccineId = "1119349007", + medicalProductId = "EU/1/20/1528", + marketAuthorizationHolderId = "ORG-100030215", + doseNumber = 2, + totalSeriesOfDoses = 2, + vaccinatedAt = LocalDate.parse("2021-04-22"), + countryOfVaccination = "DE", + certificateIssuer = "Ministry of Public Health, Welfare and Sport", + uniqueCertificateIdentifier = "urn:uvci:01:NL:THECAKEISALIE", + ) + ) + ) + + val PERSON_A_PROOF_DATA = ProofCertificateData( + proofCertificate = PERSON_A_PROOF_JSON, + issuedAt = Instant.EPOCH, + issuerCountryCode = "DE", + expiresAt = Instant.EPOCH + ) + + val PERSON_A_PROOF_1_CONTAINER = ProofContainer( + receivedAt = Instant.ofEpochMilli(1620062834474), + proofCOSE = RawCOSEObject.EMPTY, + ).apply { + preParsedData = PERSON_A_PROOF_DATA + } + + val PERSON_A_PROOF_1_RESPONSE = object : ProofCertificateResponse { + override val proofCertificateData: ProofCertificateData + get() = ProofCertificateData( + proofCertificate = PERSON_A_PROOF_JSON, + expiresAt = Instant.EPOCH, + issuedAt = Instant.EPOCH, + issuerCountryCode = "DE", + ) + override val proofCertificateCOSE: RawCOSEObject + get() = RawCOSEObject("VGhpc0lzQVByb29mQ09TRQ".decodeBase64()!!) + } + + val PERSON_A_DATA_2VAC_PROOF = VaccinatedPersonData( + vaccinations = setOf(PERSON_A_VAC_1_CONTAINER, PERSON_A_VAC_2_CONTAINER), + proofs = setOf(PERSON_A_PROOF_1_CONTAINER), + ) + + val PERSON_B_VAC_1_JSON = VaccinationCertificateV1( + version = "1.0.0", + nameData = VaccinationCertificateV1.NameData( + givenName = "Sir Jakob", + givenNameStandardized = "SIR<JAKOB", + familyName = "Von Mustermensch", + familyNameStandardized = "VON<MUSTERMENSCH", + ), + dob = "1996-12-24", + vaccinationDatas = listOf( + VaccinationCertificateV1.VaccinationData( + targetId = "840539006", + vaccineId = "1119349007", + medicalProductId = "EU/1/20/1528", + marketAuthorizationHolderId = "ORG-100030215", + doseNumber = 1, + totalSeriesOfDoses = 2, + dt = "2021-04-21", + countryOfVaccination = "NL", + certificateIssuer = "Ministry of Public Health, Welfare and Sport", + uniqueCertificateIdentifier = "urn:uvci:01:NL:PlA8UWS60Z4RZXVALl6GAZ", + ) + ) + ) + + val PERSON_B_VAC_1_HEADER = VaccinationCertificateHeader( + issuer = "Ministry of Public Health, Welfare and Sport", + issuedAt = Instant.ofEpochMilli(1620149204473), + expiresAt = Instant.ofEpochMilli(11620149234473) + ) + + val PERSON_B_VAC_1_DATA = VaccinationCertificateData( + header = PERSON_B_VAC_1_HEADER, + vaccinationCertificate = PERSON_B_VAC_1_JSON + ) + + val PERSON_B_VAC_1_CONTAINER = VaccinationContainer( + scannedAt = Instant.ofEpochMilli(1620062834471), + vaccinationCertificateCOSE = "VGhpc0lzSmFrb2I".toCOSEObject(), + ).apply { + preParsedData = PERSON_B_VAC_1_DATA + } + + val PERSON_B_DATA_1VAC_NOPROOF = VaccinatedPersonData( + vaccinations = setOf(PERSON_B_VAC_1_CONTAINER), + proofs = emptySet() + ) + + val PERSON_C_VAC_1_COSE: RawCOSEObject = + "6BFOXN*TS0BI\$ZD4N9:9S6RCVN5+O30K3/XIV0W23NTDEXWK G2EP4J0BGJLFX3R3VHXK.PJ:2DPF6R:5SVBHABVCNN95SWMPHQUHQN%A0SOE+QQAB-HQ/HQ7IR.SQEEOK9SAI4- 7Y15KBPD34 QWSP0WRGTQFNPLIR.KQNA7N95U/3FJCTG90OARH9P1J4HGZJKBEG%123ZC\$0BCI757TLXKIBTV5TN%2LXK-\$CH4TSXKZ4S/\$K%0KPQ1HEP9.PZE9Q\$95:UENEUW6646936HRTO\$9KZ56DE/.QC\$Q3J62:6LZ6O59++9-G9+E93ZM\$96TV6NRN3T59YLQM1VRMP\$I/XK\$M8PK66YBTJ1ZO8B-S-*O5W41FD\$ 81JP%KNEV45G1H*KESHMN2/TU3UQQKE*QHXSMNV25\$1PK50C9B/9OK5NE1 9V2:U6A1ELUCT16DEETUM/UIN9P8Q:KPFY1W+UN MUNU8T1PEEG%5TW5A 6YO67N6BBEWED/3LS3N6YU.:KJWKPZ9+CQP2IOMH.PR97QC:ACZAH.SYEDK3EL-FIK9J8JRBC7ADHWQYSK48UNZGG NAVEHWEOSUI2L.9OR8FHB0T5HM7I" + .let { ZLIBDecompressor().decompress(it.decodeBase45().toByteArray()) } + .let { RawCOSEObject(data = it) } + + val PERSON_C_VAC_1_CERTIFICATE = VaccinationCertificateV1( + version = "1.0.0", + nameData = VaccinationCertificateV1.NameData( + givenName = "Erika Dörte", + givenNameStandardized = "ERIKA<DOERTE", + familyName = "Schmitt Mustermann", + familyNameStandardized = "SCHMITT<MUSTERMANN", + ), + dob = "1964-08-12", + vaccinationDatas = listOf( + VaccinationCertificateV1.VaccinationData( + targetId = "840539006", + vaccineId = "1119349007", + medicalProductId = "EU/1/20/1528", + marketAuthorizationHolderId = "ORG-100030215", + doseNumber = 2, + totalSeriesOfDoses = 2, + dt = "2021-02-02", + countryOfVaccination = "DE", + certificateIssuer = "Bundesministerium für Gesundheit", + uniqueCertificateIdentifier = "01DE/84503/1119349007/DXSGWLWL40SU8ZFKIYIBK39A3#S", + ) + ) + ) + + val PERSON_C_VAC_1_CONTAINER = VaccinationContainer( + scannedAt = Instant.ofEpochMilli(1620062834471), + vaccinationCertificateCOSE = PERSON_C_VAC_1_COSE, + ) + + val PERSON_C_DATA_1VAC_NOPROOF = VaccinatedPersonData( + vaccinations = setOf(PERSON_C_VAC_1_CONTAINER), + proofs = emptySet(), + ) +} + +private fun String.toCOSEObject() = RawCOSEObject(data = this.commonAsUtf8ToByteArray()) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/VaccinationUpdateSchedulerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/VaccinationUpdateSchedulerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..32f3abd097c568da99fb9086cfaf9a7292cd91c7 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/VaccinationUpdateSchedulerTest.kt @@ -0,0 +1,165 @@ +package de.rki.coronawarnapp.vaccination.core.execution + +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkInfo +import androidx.work.WorkManager +import de.rki.coronawarnapp.task.TaskController +import de.rki.coronawarnapp.task.TaskFactory +import de.rki.coronawarnapp.task.TaskRequest +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.util.device.ForegroundState +import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson +import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask +import de.rki.coronawarnapp.vaccination.core.execution.worker.VaccinationUpdateWorkerRequestBuilder +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import org.joda.time.Instant +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.coroutines.runBlockingTest2 +import testhelpers.gms.MockListenableFuture + +class VaccinationUpdateSchedulerTest : BaseTest() { + + @MockK lateinit var taskController: TaskController + @MockK lateinit var vaccinationRepository: VaccinationRepository + @MockK lateinit var foregroundState: ForegroundState + @MockK lateinit var workManager: WorkManager + @MockK lateinit var workerRequestBuilder: VaccinationUpdateWorkerRequestBuilder + @MockK lateinit var workInfo: WorkInfo + @MockK lateinit var periodicWorkRequest: PeriodicWorkRequest + @MockK lateinit var timeStamper: TimeStamper + + private val nowUTC = Instant.ofEpochSecond(1611764225) + + private val vaccinationInfosFlow = MutableStateFlow(emptySet<VaccinatedPerson>()) + private val foregroundStateFlow = MutableStateFlow(false) + private val taskRequestSlot = slot<TaskRequest>() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + // Happy path is no proofs, no executions / workers + every { taskController.submit(any()) } just Runs + every { timeStamper.nowUTC } returns nowUTC + + every { workerRequestBuilder.createPeriodicWorkRequest() } returns periodicWorkRequest + + workManager.apply { + every { enqueueUniquePeriodicWork(any(), any(), periodicWorkRequest) } returns mockk() + every { cancelUniqueWork(any()) } returns mockk() + every { getWorkInfosForUniqueWork(any()) } returns MockListenableFuture.forResult(listOf(workInfo)) + } + + every { workInfo.state } returns WorkInfo.State.SUCCEEDED + + vaccinationRepository.apply { + every { vaccinationInfos } returns vaccinationInfosFlow + } + + every { foregroundState.isInForeground } returns foregroundStateFlow + + every { taskController.submit(any()) } just Runs + } + + private fun createInstance(scope: CoroutineScope) = VaccinationUpdateScheduler( + appScope = scope, + taskController = taskController, + vaccinationRepository = vaccinationRepository, + foregroundState = foregroundState, + workManager = workManager, + workerRequestBuilder = workerRequestBuilder, + timeStamper = timeStamper, + ) + + private fun mockPerson( + isEligbleForPC: Boolean, + hasPendingCheck: Boolean = false, + lastProofCheckTime: Instant = Instant.now() + ): VaccinatedPerson = mockk<VaccinatedPerson>().apply { + every { isEligbleForProofCertificate } returns isEligbleForPC + every { isProofCertificateCheckPending } returns hasPendingCheck + every { lastProofCheckAt } returns lastProofCheckTime + } + + @Test + fun `the worker is canceled if there is no elligble vaccination certificate`() = + runBlockingTest2(ignoreActive = true) { + val instance = createInstance(scope = this) + instance.setup() + + verify { + workManager.cancelUniqueWork("VaccinationUpdateWorker") + } + } + + @Test + fun `any pending proofs cause the worker to be scheduled`() = runBlockingTest2(ignoreActive = true) { + vaccinationInfosFlow.value = setOf(mockPerson(hasPendingCheck = true, isEligbleForPC = true)) + + createInstance(scope = this).setup() + + verify { + workManager.enqueueUniquePeriodicWork( + "VaccinationUpdateWorker", + ExistingPeriodicWorkPolicy.KEEP, + periodicWorkRequest + ) + } + } + + @Test + fun `reaching foreground state with pending proofs causes immediate refresh`() = + runBlockingTest2(ignoreActive = true) { + vaccinationInfosFlow.value = setOf(mockPerson(hasPendingCheck = true, isEligbleForPC = true)) + + createInstance(scope = this).setup() + + verify(exactly = 0) { taskController.submit(any()) } + + foregroundStateFlow.value = true + + advanceUntilIdle() + + verify { taskController.submit(capture(taskRequestSlot)) } + + taskRequestSlot.captured.apply { + type shouldBe VaccinationUpdateTask::class + errorHandling shouldBe TaskFactory.Config.ErrorHandling.SILENT + } + } + + @Test + fun `reaching foreground state with stale proof data causes immediate refresh`() = + runBlockingTest2(ignoreActive = true) { + vaccinationInfosFlow.value = setOf(mockPerson(isEligbleForPC = true, lastProofCheckTime = Instant.EPOCH)) + + createInstance(scope = this).setup() + + verify(exactly = 0) { taskController.submit(any()) } + + foregroundStateFlow.value = true + + advanceUntilIdle() + + verify { taskController.submit(capture(taskRequestSlot)) } + + taskRequestSlot.captured.apply { + type shouldBe VaccinationUpdateTask::class + errorHandling shouldBe TaskFactory.Config.ErrorHandling.SILENT + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/task/VaccinationUpdateTaskTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/task/VaccinationUpdateTaskTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..d1e7a0c15b92f14756e51e205f09478ba52bcb39 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/task/VaccinationUpdateTaskTest.kt @@ -0,0 +1,47 @@ +package de.rki.coronawarnapp.vaccination.core.execution.task + +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository +import io.mockk.MockKAnnotations +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import kotlinx.coroutines.test.runBlockingTest +import org.joda.time.Instant +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class VaccinationUpdateTaskTest : BaseTest() { + + @MockK lateinit var timeStamper: TimeStamper + @MockK lateinit var vaccinationRepository: VaccinationRepository + + private val currentInstant = Instant.ofEpochSecond(1611764225) + + @BeforeEach + fun setUp() { + MockKAnnotations.init(this) + + every { timeStamper.nowUTC } returns currentInstant + + coEvery { vaccinationRepository.refresh(any()) } just Runs + } + + private fun createInstance() = VaccinationUpdateTask( + timeStamper = timeStamper, + vaccinationRepository = vaccinationRepository + ) + + @Test + fun `task calls generic refresh`() = runBlockingTest { + val task = createInstance() + + task.run(VaccinationUpdateTask.Arguments) + + coVerify { vaccinationRepository.refresh(null) } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorkerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorkerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..60c8a9d2558c3bd8218fa729b74a167e46af3a1d --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/execution/worker/VaccinationUpdateWorkerTest.kt @@ -0,0 +1,89 @@ +package de.rki.coronawarnapp.vaccination.core.execution.worker + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerParameters +import de.rki.coronawarnapp.task.TaskController +import de.rki.coronawarnapp.task.TaskFactory +import de.rki.coronawarnapp.task.TaskRequest +import de.rki.coronawarnapp.task.TaskState +import de.rki.coronawarnapp.task.common.DefaultTaskRequest +import de.rki.coronawarnapp.task.submitBlocking +import de.rki.coronawarnapp.vaccination.core.execution.task.VaccinationUpdateTask +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.mockkStatic +import io.mockk.slot +import kotlinx.coroutines.test.runBlockingTest +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class VaccinationUpdateWorkerTest : BaseTest() { + + @MockK lateinit var context: Context + @MockK(relaxed = true) lateinit var workerParams: WorkerParameters + @MockK lateinit var taskController: TaskController + @MockK lateinit var taskState: TaskState + private val taskRequestSlot = slot<TaskRequest>() + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + mockkStatic("de.rki.coronawarnapp.task.TaskControllerExtensionsKt") + + coEvery { taskController.submitBlocking(capture(taskRequestSlot)) } answers { taskState } + + taskState.apply { + every { isSuccessful } returns true + every { error } returns null + } + } + + private fun createWorker() = VaccinationUpdateWorker( + context = context, + workerParams = workerParams, + taskController = taskController, + ) + + @Test + fun `sideeffect free`() { + createWorker() + } + + @Test + fun `task is run blockingly`() = runBlockingTest { + val worker = createWorker() + + worker.doWork() shouldBe ListenableWorker.Result.success() + + taskRequestSlot.captured shouldBe DefaultTaskRequest( + id = taskRequestSlot.captured.id, + type = VaccinationUpdateTask::class, + arguments = VaccinationUpdateTask.Arguments, + errorHandling = TaskFactory.Config.ErrorHandling.SILENT, + originTag = "VaccinationUpdateWorker" + ) + } + + @Test + fun `task errors are rethrown `() = runBlockingTest { + taskState.apply { + every { isSuccessful } returns false + every { error } returns Exception() + } + + val worker = createWorker() + + worker.doWork() shouldBe ListenableWorker.Result.retry() + + coVerify { + taskController.submitBlocking(any()) + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..e5b7b9573e2bc3022e894e833648fcb08b6650ed --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQRCodeExtractorTest.kt @@ -0,0 +1,102 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode + +import com.google.gson.Gson +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_BASE45_DECODING_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.HC_ZLIB_DECOMPRESSION_FAILED +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_HC_CWT_NO_ISS +import de.rki.coronawarnapp.vaccination.core.qrcode.InvalidHealthCertificateException.ErrorCode.VC_NO_VACCINATION_ENTRY +import de.rki.coronawarnapp.vaccination.decoder.ZLIBDecompressor +import io.kotest.assertions.throwables.shouldThrow +import io.kotest.matchers.shouldBe +import org.joda.time.Instant +import org.joda.time.LocalDate +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class VaccinationQRCodeExtractorTest : BaseTest() { + + private val zLIBDecompressor = ZLIBDecompressor() + private val healthCertificateCOSEDecoder = HealthCertificateCOSEDecoder() + private val vaccinationCertificateV1Decoder = VaccinationCertificateV1Parser(Gson()) + + private val extractor = VaccinationQRCodeExtractor( + zLIBDecompressor, + healthCertificateCOSEDecoder, + vaccinationCertificateV1Decoder + ) + + @Test + fun `happy path extraction`() { + extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode) + } + + @Test + fun `happy path extraction 2`() { + extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode2) + } + + @Test + fun `happy path extraction with data`() { + val qrCode = extractor.extract(VaccinationQrCodeTestData.validVaccinationQrCode3) + + with(qrCode.parsedData.header) { + issuer shouldBe "AT" + issuedAt shouldBe Instant.ofEpochSecond(1620392021) + expiresAt shouldBe Instant.ofEpochSecond(1620564821) + } + + with(qrCode.parsedData.vaccinationCertificate) { + with(nameData) { + familyName shouldBe "Musterfrau-Gößinger" + familyNameStandardized shouldBe "MUSTERFRAU<GOESSINGER" + givenName shouldBe "Gabriele" + givenNameStandardized shouldBe "GABRIELE" + } + dob shouldBe "1998-02-26" + dateOfBirth shouldBe LocalDate.parse("1998-02-26") + version shouldBe "1.0.0" + + with(vaccinationDatas[0]) { + uniqueCertificateIdentifier shouldBe "urn:uvci:01:AT:10807843F94AEE0EE5093FBC254BD813P" + countryOfVaccination shouldBe "AT" + doseNumber shouldBe 1 + dt shouldBe "2021-02-18" + certificateIssuer shouldBe "BMSGPK Austria" + marketAuthorizationHolderId shouldBe "ORG-100030215" + medicalProductId shouldBe "EU/1/20/1528" + totalSeriesOfDoses shouldBe 2 + targetId shouldBe "840539006" + vaccineId shouldBe "1119305005" + vaccinatedAt shouldBe LocalDate.parse("2021-02-18") + } + } + } + + @Test + fun `valid encoding but not a health certificate fails with VC_HC_CWT_NO_ISS`() { + shouldThrow<InvalidHealthCertificateException> { + extractor.extract(VaccinationQrCodeTestData.validEncoded) + }.errorCode shouldBe VC_HC_CWT_NO_ISS + } + + @Test + fun `random string fails with HC_BASE45_DECODING_FAILED`() { + shouldThrow<InvalidHealthCertificateException> { + extractor.extract("nothing here to see") + }.errorCode shouldBe HC_BASE45_DECODING_FAILED + } + + @Test + fun `uncompressed base45 string fails with HC_ZLIB_DECOMPRESSION_FAILED`() { + shouldThrow<InvalidHealthCertificateException> { + extractor.extract("6BFOABCDEFGHIJKLMNOPQRSTUVWXYZ %*+-./:") + }.errorCode shouldBe HC_ZLIB_DECOMPRESSION_FAILED + } + + @Test + fun `vaccination certificate missing fails with VC_NO_VACCINATION_ENTRY`() { + shouldThrow<InvalidHealthCertificateException> { + extractor.extract(VaccinationQrCodeTestData.certificateMissing) + }.errorCode shouldBe VC_NO_VACCINATION_ENTRY + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeTestData.java b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeTestData.java new file mode 100644 index 0000000000000000000000000000000000000000..7db2205367ff15e5183ffaedb939a161273f4b55 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/qrcode/VaccinationQrCodeTestData.java @@ -0,0 +1,9 @@ +package de.rki.coronawarnapp.vaccination.core.qrcode; + +public class VaccinationQrCodeTestData { + static public String validVaccinationQrCode = "HC1:6BFOXN*TS0BI$ZD4N9:9S6RCVN5+O30K3/XIV0W23NTDEXWK G2EP4J0BGJLFX3R3VHXK.PJ:2DPF6R:5SVBHABVCNN95SWMPHQUHQN%A0SOE+QQAB-HQ/HQ7IR.SQEEOK9SAI4- 7Y15KBPD34 QWSP0WRGTQFNPLIR.KQNA7N95U/3FJCTG90OARH9P1J4HGZJKBEG%123ZC$0BCI757TLXKIBTV5TN%2LXK-$CH4TSXKZ4S/$K%0KPQ1HEP9.PZE9Q$95:UENEUW6646936HRTO$9KZ56DE/.QC$Q3J62:6LZ6O59++9-G9+E93ZM$96TV6NRN3T59YLQM1VRMP$I/XK$M8PK66YBTJ1ZO8B-S-*O5W41FD$ 81JP%KNEV45G1H*KESHMN2/TU3UQQKE*QHXSMNV25$1PK50C9B/9OK5NE1 9V2:U6A1ELUCT16DEETUM/UIN9P8Q:KPFY1W+UN MUNU8T1PEEG%5TW5A 6YO67N6BBEWED/3LS3N6YU.:KJWKPZ9+CQP2IOMH.PR97QC:ACZAH.SYEDK3EL-FIK9J8JRBC7ADHWQYSK48UNZGG NAVEHWEOSUI2L.9OR8FHB0T5HM7I"; + static public String validVaccinationQrCode2 = "HC1:NCFOXN%TS3DHZN4HAF*PQFKKGTNA.Q/R8WRU2FC6L9N*CH PC.IU:N AJPJPC%OQHIZC4.OI1RM8ZA.A53XHMKN4NN3F85QNCY0O%0VZ001HOC9JU0D0HT0HO1PM:K$$09B9LW4T*8+DC%H0PZBITH$*SBAKYE9*FJTJAHD4UDADPSDJIM4KF/B0C2SFIH:9$GCQOS62PR6WPHN6D7LLK*2HG%89UV-0LZ 2ZJJ4FF86O:HO73SM1IO-O.Z80GHS-O:S9UZ4+FJE 4Y3LL/II 07LPMIH-O9XZQSH9R$FXQGDVBK*RZP3:*DG1W7SGT$7S%RMSG2UQYI9*FGCPAXRQ3E2N+E .1:L7O:7X/5Q+MSA7G6MBYO+JQLHP71RJW63X7VUONC6V35HW6SZ6FT5D75W9AV88E34+V4YC5/HQWOQ6$S4N4N31SHPO3Q0E447H9VAK:6.5G$N3ZF7W2SBJT7QG+8UJII3MACIBG2U76MGX3$YB.S7PIJRVOBTN6DTEUIOS7ZKJJEL%.B PT2LO36KT8SP50M/O$4"; + static public String validEncoded = "6BFB 9B8OYK3DR3D92BSQAQAHSOMEQ3%1GEVQT4H4O8G3.13G$H6+DH.157SWEV21SD7F2OPY1O-9LRFG0NGCUEPS5LLKJ:1CEJTLA2SADI887A/P3UHL20FTA9ZTRPSVUXO19LEZBQF3VJE$77D5FFC91ZFKCPP%90VS09P2QDQBCMY7-AE0/RW1R:ICP76XRS5UGC82WDNRJ9R7SX331MI9C7WNE5ZL1795NTA/P-35.N65O65ZQ8SU2:KY:C9K9PKD6+K%DI$YQ-9A:CKZ+5HPQNIF7N3K UEU6GEKHCO03MC%QN+LN+C5TTB1B94EC$38QC5O5DP262N:X7JYR/XH/A8%-1KZFTODRY3I 859G-IS9TMY4JM21TAV$N2NK3%BW8K7GI6%O8DUKUT036EF$8:32RBK*0IHJISK5SLTT21KYE7 U/316$I08A/XBU4IZYAGD3UVOJQI2YH3JMXHS1IPE%FOJN$HOV%B3FWCDCP65/%RKP2W2M4A9X7GETNASOXZ0Q/Q5LUNMJ QH+-2:4FW$33+4 +AY7GV-15/717GXY4H4O.:RM/USWV70PV8NGL5XP15NQ3K217GC:1WQEJNBK1RU6J.4K9/J%VQOHA+EW I0YMQ 0"; + static public String certificateMissing = "HC1:NCFNA0%00FFWTWGVLKJ99K83X4C8DTTMMX*4P8B3XK2F3$8JVJG2F3$%IQJG/IC6TAY50.FK6ZK6:ETPCBEC8ZKW.CNWE.Y92OAGY82+8UB8-R7/0A1OA1C9K09UIAW.CE$E7%E7WE KEVKER EB39W4N*6K3/D5$CMPCG/DA8DBB85IAAY8WY8I3DA8D0EC*KE: CZ CO/EZKEZ96446C56GVC*JC1A6NA73W5KF6TF627BSKL*8F.MLCM6$-I99MG$8THRJSCJVM/*V:0EY1QU 77*D9KR$SKIP5S-I2-RA1CC06+CHPYQX96*SUF3WZ36NM3XPK1P8.MAFZ6SHB"; + static public String validVaccinationQrCode3 = "HC1:NCFOXN%TS3DH3ZSUZK+.V0ETD%65NL-AH%TAIOOW%I-1W0658WA/UAN9AAT4V22F/8X*G3M9JUPY0BX/KR96R/S09T./0LWTKD33236J3TA3M*4VV2 73-E3ND3DAJ-43%*48YIB73A*G3W19UEBY5:PI0EGSP4*2D$43B+2SEB7:I/2DY73CIBC:G 7376BXBJBAJ UNFMJCRN0H3PQN*E33H3OA70M3FMJIJN523S+0B/S7-SN2H N37J3JFTULJ5CB3ZCIATULV:SNS8F-67N%21Q21$48X2+36D-I/2DBAJDAJCNB-43SZ4RZ4E%5B/9OK53:UCT16DEZIE IE9.M CVCT1+9V*QERU1MK93P5 U02Y9.G9/G9F:QQ28R3U6/V.*NT*QM.SY$N-P1S29 34S0BYBRC.UYS1U%O6QKN*Q5-QFRMLNKNM8JI0EUGP$I/XK$M8-L9KDI:ZH2E4EVS6O0FVAQNJT:EZ6Q%D0*T1.XSDYV0.VI2OKSNODA.BOD:C.OTXS02:M5OGJIF4LHJW7FFJ2NLGFL/EE%CJF+KM%V$AUS:H+NARLK IBMMG"; +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/VaccinationRepositoryTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/VaccinationRepositoryTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..8e26cdd9d304c7e03552cd17b9ec9d66fa9c365d --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/VaccinationRepositoryTest.kt @@ -0,0 +1,135 @@ +package de.rki.coronawarnapp.vaccination.core.repository + +import de.rki.coronawarnapp.util.TimeStamper +import de.rki.coronawarnapp.vaccination.core.VaccinationTestData +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinatedPersonData +import de.rki.coronawarnapp.vaccination.core.repository.storage.VaccinationStorage +import de.rki.coronawarnapp.vaccination.core.server.proof.VaccinationProofServer +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.flowOf +import org.joda.time.Instant +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.TestDispatcherProvider +import testhelpers.coroutines.runBlockingTest2 +import timber.log.Timber + +class VaccinationRepositoryTest : BaseTest() { + + @MockK lateinit var timeStamper: TimeStamper + + @MockK lateinit var storage: VaccinationStorage + @MockK lateinit var valueSetsRepository: ValueSetsRepository + @MockK lateinit var vaccinationProofServer: VaccinationProofServer + @MockK lateinit var vaccinationValueSet: VaccinationValueSet + + private var testStorage: Set<VaccinatedPersonData> = emptySet() + + private var nowUTC = Instant.ofEpochMilli(1234567890) + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + every { timeStamper.nowUTC } returns nowUTC + + every { valueSetsRepository.latestValueSet } returns flowOf(vaccinationValueSet) + + coEvery { vaccinationProofServer.getProofCertificate(any()) } returns VaccinationTestData.PERSON_A_PROOF_1_RESPONSE + + storage.apply { + every { personContainers } answers { testStorage } + every { personContainers = any() } answers { testStorage = arg(0) } + } + } + + private fun createInstance(scope: CoroutineScope) = VaccinationRepository( + appScope = scope, + dispatcherProvider = TestDispatcherProvider(), + timeStamper = timeStamper, + storage = storage, + valueSetsRepository = valueSetsRepository, + vaccinationProofServer = vaccinationProofServer, + ) + + @Test + fun `add new certificate - no prior data`() = runBlockingTest2(ignoreActive = true) { + val instance = createInstance(this) + + advanceUntilIdle() + + instance.registerVaccination(VaccinationTestData.PERSON_A_VAC_1_QRCODE).apply { + Timber.i("Returned cert is %s", this) + this.personIdentifier shouldBe VaccinationTestData.PERSON_A_VAC_1_CONTAINER.personIdentifier + } + } + + @Test + fun `add new certificate - existing data`() = runBlockingTest2(ignoreActive = true) { +// val dataBefore = VaccinationTestData.PERSON_A_DATA_2VAC_PROOF.copy( +// vaccinations = setOf(VaccinationTestData.PERSON_A_VAC_1_CONTAINER), +// proofs = emptySet() +// ) +// val dataAfter = VaccinationTestData.PERSON_A_DATA_2VAC_PROOF.copy( +// vaccinations = setOf( +// VaccinationTestData.PERSON_A_VAC_1_CONTAINER, +// VaccinationTestData.PERSON_A_VAC_2_CONTAINER.copy(scannedAt = nowUTC) +// ), +// proofs = emptySet() +// ) +// testStorage = setOf(dataBefore) +// +// val instance = createInstance(this) +// +// advanceUntilIdle() +// +// instance.registerVaccination(VaccinationTestData.PERSON_A_VAC_2_QRCODE).apply { +// Timber.i("Returned cert is %s", this) +// this.personIdentifier shouldBe VaccinationTestData.PERSON_A_VAC_2_CONTAINER.personIdentifier +// } +// +// testStorage.first() shouldBe dataAfter + } + + @Test + fun `add new certificate - if eligble for proof, start request`() = runBlockingTest2(ignoreActive = true) { +// TODO() + } + + @Test + fun `add new certificate - does not match existing person`() { +// TODO() + } + + @Test + fun `add new certificate - duplicate certificate`() { +// TODO() + } + + @Test + fun `clear data`() { +// TODO() + } + + @Test + fun `remove certificate`() { +// TODO() + } + + @Test + fun `remove certificate - starts proof check if we deleted a vaccination that was eligble for proof`() { +// TODO() + } + + @Test + fun `check for new proof certificate`() { +// TODO() + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ProofContainerTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ProofContainerTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..3676dac8ed9c0a8379c18031d1777d1ae028a951 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/ProofContainerTest.kt @@ -0,0 +1,12 @@ +package de.rki.coronawarnapp.vaccination.core.repository.storage + +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class ProofContainerTest : BaseTest() { + + @Test + fun `person identifier calculation`() { + // TODO + } +} 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 new file mode 100644 index 0000000000000000000000000000000000000000..7807ea034903f665924974b67390a65cf59a1239 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationContainerTest.kt @@ -0,0 +1,91 @@ +package de.rki.coronawarnapp.vaccination.core.repository.storage + +import de.rki.coronawarnapp.ui.Country +import de.rki.coronawarnapp.vaccination.core.VaccinatedPersonIdentifier +import de.rki.coronawarnapp.vaccination.core.VaccinationTestData +import de.rki.coronawarnapp.vaccination.core.server.valueset.VaccinationValueSet +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.joda.time.Instant +import org.joda.time.LocalDate +import org.junit.jupiter.api.Test +import testhelpers.BaseTest + +class VaccinationContainerTest : BaseTest() { + + private fun createInstance() = VaccinationContainer( + vaccinationCertificateCOSE = VaccinationTestData.PERSON_C_VAC_1_COSE, + scannedAt = Instant.ofEpochSecond(123456789) + ) + + @Test + fun `person identifier calculation`() { + createInstance().personIdentifier shouldBe VaccinatedPersonIdentifier( + dateOfBirth = LocalDate.parse("1964-08-12"), + firstNameStandardized = "ERIKA<DOERTE", + lastNameStandardized = "SCHMITT<MUSTERMANN" + ) + } + + @Test + fun `full property decoding`() { + createInstance().apply { + certificate shouldBe VaccinationTestData.PERSON_C_VAC_1_CERTIFICATE + vaccination shouldBe VaccinationTestData.PERSON_C_VAC_1_CERTIFICATE.vaccinationDatas.single() + certificateId shouldBe "01DE/84503/1119349007/DXSGWLWL40SU8ZFKIYIBK39A3#S" + isEligbleForProofCertificate shouldBe true + } + } + + @Test + fun `mapping to user facing data - valueset is null`() { + createInstance().toVaccinationCertificate(null).apply { + firstName shouldBe "Erika Dörte" + lastName shouldBe "Schmitt Mustermann" + dateOfBirth shouldBe LocalDate.parse("1964-08-12") + vaccinatedAt shouldBe LocalDate.parse("2021-02-02") + vaccineName shouldBe "1119349007" + vaccineManufacturer shouldBe "ORG-100030215" + medicalProductName shouldBe "EU/1/20/1528" + doseNumber shouldBe 2 + totalSeriesOfDoses shouldBe 2 + certificateIssuer shouldBe "Bundesministerium für Gesundheit" + certificateCountry shouldBe Country.DE + certificateId shouldBe "01DE/84503/1119349007/DXSGWLWL40SU8ZFKIYIBK39A3#S" + personIdentifier shouldBe VaccinatedPersonIdentifier( + dateOfBirth = LocalDate.parse("1964-08-12"), + firstNameStandardized = "ERIKA<DOERTE", + lastNameStandardized = "SCHMITT<MUSTERMANN" + ) + } + } + + @Test + fun `mapping to user facing data - with valueset`() { + val valueSet = mockk<VaccinationValueSet> { + every { getDisplayText("ORG-100030215") } returns "Manufactorer-Name" + every { getDisplayText("EU/1/20/1528") } returns "MedicalProduct-Name" + every { getDisplayText("1119349007") } returns "Vaccine-Name" + } + createInstance().toVaccinationCertificate(valueSet).apply { + firstName shouldBe "Erika Dörte" + lastName shouldBe "Schmitt Mustermann" + dateOfBirth shouldBe LocalDate.parse("1964-08-12") + vaccinatedAt shouldBe LocalDate.parse("2021-02-02") + vaccineName shouldBe "Vaccine-Name" + vaccineManufacturer shouldBe "Manufactorer-Name" + medicalProductName shouldBe "MedicalProduct-Name" + doseNumber shouldBe 2 + totalSeriesOfDoses shouldBe 2 + certificateIssuer shouldBe "Bundesministerium für Gesundheit" + certificateCountry shouldBe Country.DE + certificateId shouldBe "01DE/84503/1119349007/DXSGWLWL40SU8ZFKIYIBK39A3#S" + personIdentifier shouldBe VaccinatedPersonIdentifier( + dateOfBirth = LocalDate.parse("1964-08-12"), + firstNameStandardized = "ERIKA<DOERTE", + lastNameStandardized = "SCHMITT<MUSTERMANN" + ) + } + } +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorageTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorageTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..2e3a138b80d7c1afd6c69d61a56769774564f6ce --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/vaccination/core/repository/storage/VaccinationStorageTest.kt @@ -0,0 +1,82 @@ +package de.rki.coronawarnapp.vaccination.core.repository.storage + +import android.content.Context +import androidx.core.content.edit +import de.rki.coronawarnapp.util.serialization.SerializationModule +import de.rki.coronawarnapp.vaccination.core.VaccinationTestData +import io.kotest.matchers.shouldBe +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import testhelpers.BaseTest +import testhelpers.extensions.toComparableJsonPretty +import testhelpers.preferences.MockSharedPreferences + +class VaccinationStorageTest : BaseTest() { + + @MockK lateinit var context: Context + private lateinit var mockPreferences: MockSharedPreferences + + @BeforeEach + fun setup() { + MockKAnnotations.init(this) + + mockPreferences = MockSharedPreferences() + + every { + context.getSharedPreferences("vaccination_localdata", Context.MODE_PRIVATE) + } returns mockPreferences + } + + private fun createInstance() = VaccinationStorage( + context = context, + baseGson = SerializationModule().baseGson() + ) + + @Test + fun `init is sideeffect free`() { + createInstance() + } + + @Test + fun `storing empty set deletes data`() { + mockPreferences.edit { + putString("dontdeleteme", "test") + putString("vaccination.person.test", "test") + } + createInstance().personContainers = emptySet() + + mockPreferences.dataMapPeek.keys.single() shouldBe "dontdeleteme" + } + + @Test + fun `store one person`() { + val instance = createInstance() + instance.personContainers = setOf(VaccinationTestData.PERSON_C_DATA_1VAC_NOPROOF) + + val json = + (mockPreferences.dataMapPeek["vaccination.person.1964-08-12#SCHMITT<MUSTERMANN#ERIKA<DOERTE"] as String) + + json.toComparableJsonPretty() shouldBe """ + { + "vaccinationData": [ + { + "vaccinationCertificateCOSE": "${VaccinationTestData.PERSON_C_VAC_1_COSE.data.base64()}", + "scannedAt": 1620062834471 + } + ], + "proofData": [], + "lastSuccessfulProofCertificateRun": 0, + "proofCertificateRunPending": false + } + """.toComparableJsonPretty() + + instance.personContainers.single().apply { + this shouldBe VaccinationTestData.PERSON_C_DATA_1VAC_NOPROOF + this.vaccinations.single().vaccinationCertificateCOSE shouldBe VaccinationTestData.PERSON_C_VAC_1_COSE + this.proofs shouldBe emptySet() + } + } +} diff --git a/Corona-Warn-App/src/test/java/testhelpers/gms/MockListenableFuture.kt b/Corona-Warn-App/src/test/java/testhelpers/gms/MockListenableFuture.kt new file mode 100644 index 0000000000000000000000000000000000000000..829b08e38e771b1978216a82399c109cbd49a649 --- /dev/null +++ b/Corona-Warn-App/src/test/java/testhelpers/gms/MockListenableFuture.kt @@ -0,0 +1,21 @@ +package testhelpers.gms + +import com.google.common.util.concurrent.ListenableFuture +import io.mockk.every +import io.mockk.mockk +import java.util.concurrent.Executor + +object MockListenableFuture { + fun <T> forResult(result: T) = mockk<ListenableFuture<T>>().apply { + every { isDone } returns true + every { get() } returns result + every { get(any(), any()) } returns result + every { isCancelled } returns false + every { cancel(any()) } returns true + every { addListener(any(), any()) } answers { + val listener = arg<Runnable>(0) + val executor = arg<Executor>(1) + listener.run() + } + } +} 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 index c1b802516a2f5aa590dd3cf5ba6fcbd6d23e5906..899f6fd6ebec6a210ee296f95202f0f8bf1489ab 100644 --- 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 @@ -31,6 +31,8 @@ class DebugOptionsFragmentViewModelTest : BaseTestInstrumentation() { every { environmentSetup.verificationCdnUrl } returns "verificationUrl" every { environmentSetup.dataDonationCdnUrl } returns "dataDonationUrl" every { environmentSetup.logUploadServerUrl } returns "logUploadServerUrl" + every { environmentSetup.vaccinationProofServerUrl } returns "vaccinationProofServerUrl" + every { environmentSetup.vaccinationCdnUrl } returns "vaccinationCdnUrl" every { environmentSetup.crowdNotifierPublicKey } returns "crowdNotifierPublicKey" every { environmentSetup.appConfigPublicKey } returns "appConfigPublicKey" diff --git a/prod_environments.json b/prod_environments.json index 09d9015c150489eb2dac757a9dfbe5d25f476926..c5c3b3f63cb9db1cf02a4f9e542168f804038d2a 100644 --- a/prod_environments.json +++ b/prod_environments.json @@ -6,6 +6,8 @@ "VERIFICATION_CDN_URL": "https://verification.coronawarn.app", "DATA_DONATION_CDN_URL": "https://data.coronawarn.app", "LOG_UPLOAD_SERVER_URL": "https://logupload.coronawarn.app", + "VACCINATION_PROOF_SERVER_URL": "https://placeholder", + "VACCINATION_CDN_URL": "https://placeholder", "SAFETYNET_API_KEY": "placeholder", "PUB_KEYS_SIGNATURE_VERIFICATION": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEc7DEstcUIRcyk35OYDJ95/hTg3UVhsaDXKT0zK7NhHPXoyzipEnOp3GyNXDVpaPi3cAfQmxeuFMZAIX2+6A5Xg==", "CROWD_NOTIFIER_PUBLIC_KEY": "gwLMzE153tQwAOf2MZoUXXfzWTdlSpfS99iZffmcmxOG9njSK4RTimFOFwDh6t0Tyw8XR01ugDYjtuKwjjuK49Oh83FWct6XpefPi9Skjxvvz53i9gaMmUEc96pbtoaA" diff --git a/translation_v2.json b/translation_v2.json index e6b00ad77b2710265307def547d68c8165a88ec0..1feb7005563b7e43b096323400d1cb57e67f0df9 100644 --- a/translation_v2.json +++ b/translation_v2.json @@ -10,7 +10,8 @@ "contact_diary_strings.xml", "release_info_strings.xml", "event_registration_strings.xml", - "antigen_strings.xml" + "antigen_strings.xml", + "vaccination_strings.xml" ], "targetFolderPath": "../values-[langCode]" }