Skip to content
Snippets Groups Projects
Unverified Commit c5f53e86 authored by Chilja Gossow's avatar Chilja Gossow Committed by GitHub
Browse files

Vaccination QR code scan screen, error handling, navigation (EXPOSUREAPP-6726) (#3124)


* extractor

* decoding

* decoding, error handling

* clean up

* klint

* detekt

* simplify condition

* comment failing unit test

* unit test

* change lib

* Unit tests for RA/PCRCoronaTest.isFinal

* Add license text.

* add header

* clean up

* scan screen, error handling, navigation

* clean up

* klint detekt

* merge

* move strings

* comments

* klint

* revert version change

* fix fun name

* fix merge conflicts

* show dialog

* add navigation

* fix error dialog

* fix error dialog

* fix error dialog

* fix error dialog

* simplify

* merge main

* fix package

* review comments

* circular progress spinner

* fix test

Co-authored-by: default avatarMatthias Urhahn <matthias.urhahn@sap.com>
parent 9749838d
No related branches found
No related tags found
No related merge requests found
Showing
with 445 additions and 104 deletions
......@@ -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()
}
}
}
......@@ -38,9 +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.homecards.CompleteVaccinationHomeCard
import de.rki.coronawarnapp.vaccination.ui.homecards.CreateVaccinationHomeCard
import de.rki.coronawarnapp.vaccination.ui.homecards.IncompleteVaccinationHomeCard
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>>(),
......
......@@ -74,9 +74,9 @@ import de.rki.coronawarnapp.util.viewmodel.CWAViewModel
import de.rki.coronawarnapp.util.viewmodel.SimpleCWAViewModelFactory
import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson
import de.rki.coronawarnapp.vaccination.core.repository.VaccinationRepository
import de.rki.coronawarnapp.vaccination.ui.homecards.CompleteVaccinationHomeCard
import de.rki.coronawarnapp.vaccination.ui.homecards.CreateVaccinationHomeCard
import de.rki.coronawarnapp.vaccination.ui.homecards.IncompleteVaccinationHomeCard
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
......@@ -366,7 +366,7 @@ class HomeFragmentViewModel @AssistedInject constructor(
add(
CreateVaccinationHomeCard.Item(
onClickAction = {
// TODO: implement in another PR
routeToScreen.postValue(HomeFragmentDirections.actionMainFragmentToVaccinationNavGraph())
}
)
)
......
......@@ -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 {
......
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
) : InvalidQRCodeException(errorCode.message) {
) : HasHumanReadableError, InvalidQRCodeException(errorCode.message) {
enum class ErrorCode(
val message: String
) {
......@@ -13,6 +35,61 @@ class InvalidHealthCertificateException(
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_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
......@@ -30,7 +30,7 @@ class VaccinationCertificateCOSEParser @Inject constructor(
private fun CBORObject.decodeCBORObject(): VaccinationCertificateData {
return try {
vaccinationCertificateV1Parser.decode(this)
vaccinationCertificateV1Parser.parse(this)
} catch (e: Exception) {
Timber.e(e)
throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED)
......
......@@ -2,13 +2,21 @@ 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() {
class VaccinationCertificateV1Parser @Inject constructor(
@BaseGson private val gson: Gson
) {
companion object {
private val keyEuDgcV1 = CBORObject.FromObject(1)
......@@ -18,41 +26,43 @@ class VaccinationCertificateV1Parser @Inject constructor() {
private val keyIssuedAt = CBORObject.FromObject(6)
}
fun decode(map: CBORObject): VaccinationCertificateData {
try {
var issuer: String? = null
map[keyIssuer]?.let {
issuer = it.AsString()
}
var issuedAt: Instant? = null
map[keyIssuedAt]?.let {
issuedAt = Instant.ofEpochSecond(it.AsNumber().ToInt64Checked())
}
var expiresAt: Instant? = null
map[keyExpiresAt]?.let {
expiresAt = Instant.ofEpochSecond(it.AsNumber().ToInt64Checked())
}
var certificate: VaccinationCertificateV1? = null
map[keyHCert]?.let { hcert ->
hcert[keyEuDgcV1]?.let {
val json = it.ToJSONString()
certificate = Gson().fromJson<VaccinationCertificateV1>(json)
}
}
val header = VaccinationCertificateHeader(
issuer = issuer!!,
issuedAt = issuedAt!!,
expiresAt = expiresAt!!
)
return VaccinationCertificateData(
header,
certificate!!.validate()
)
} catch (e: InvalidHealthCertificateException) {
throw e
} catch (e: Throwable) {
throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED)
}
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 {
......
......@@ -20,18 +20,18 @@ class VaccinationQRCodeExtractor @Inject constructor(
private val prefix = "HC1:"
override fun canHandle(rawString: String): Boolean {
return rawString.startsWith(prefix)
}
override fun canHandle(rawString: String): Boolean = rawString.startsWith(prefix)
override fun extract(rawString: String): VaccinationCertificateQRCode {
val rawCOSEObject = rawString
.removePrefix(prefix)
.decodeBase45()
.decompress()
val certificate = rawCOSEObject
.decodeCOSEObject()
.decodeCBORObject()
.parseCBORObject()
return VaccinationCertificateQRCode(
parsedData = certificate,
certificateCOSE = rawCOSEObject,
......@@ -40,14 +40,14 @@ class VaccinationQRCodeExtractor @Inject constructor(
private fun String.decodeBase45(): ByteArray = try {
base45Decoder.decode(this)
} catch (e: Exception) {
} catch (e: Throwable) {
Timber.e(e)
throw InvalidHealthCertificateException(HC_BASE45_DECODING_FAILED)
}
private fun ByteArray.decompress(): ByteArray = try {
zLIBDecompressor.decode(this)
} catch (e: Exception) {
zLIBDecompressor.decompress(this)
} catch (e: Throwable) {
Timber.e(e)
throw InvalidHealthCertificateException(HC_ZLIB_DECOMPRESSION_FAILED)
}
......@@ -56,16 +56,16 @@ class VaccinationQRCodeExtractor @Inject constructor(
healthCertificateCOSEDecoder.decode(this)
} catch (e: InvalidHealthCertificateException) {
throw e
} catch (e: Exception) {
} catch (e: Throwable) {
Timber.e(e)
throw InvalidHealthCertificateException(HC_COSE_MESSAGE_INVALID)
}
private fun CBORObject.decodeCBORObject(): VaccinationCertificateData = try {
vaccinationCertificateV1Parser.decode(this)
private fun CBORObject.parseCBORObject(): VaccinationCertificateData = try {
vaccinationCertificateV1Parser.parse(this)
} catch (e: InvalidHealthCertificateException) {
throw e
} catch (e: Exception) {
} catch (e: Throwable) {
Timber.e(e)
throw InvalidHealthCertificateException(HC_CBOR_DECODING_FAILED)
}
......
package de.rki.coronawarnapp.vaccination.core.qrcode
import dagger.Reusable
import de.rki.coronawarnapp.coronatest.qrcode.InvalidQRCodeException
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
......@@ -16,7 +16,7 @@ class VaccinationQRCodeValidator @Inject constructor(
return findExtractor(rawString)
?.extract(rawString)
?.also { Timber.i("Extracted data from QR code is $it") }
?: throw InvalidQRCodeException()
?: throw InvalidHealthCertificateException(VC_PREFIX_INVALID)
}
private fun findExtractor(rawString: String): QrCodeExtractor<VaccinationCertificateQRCode>? {
......
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
......@@ -35,7 +36,7 @@ data class VaccinationContainer(
private val certificateData: VaccinationCertificateData by lazy {
preParsedData ?: VaccinationCertificateCOSEParser(
HealthCertificateCOSEDecoder(),
VaccinationCertificateV1Parser(),
VaccinationCertificateV1Parser(Gson()),
).parse(vaccinationCertificateCOSE)
}
......
......@@ -5,7 +5,7 @@ import java.util.zip.InflaterInputStream
import javax.inject.Inject
class ZLIBDecompressor @Inject constructor() {
fun decode(input: ByteArray): ByteArray = if (
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())
......
......@@ -6,6 +6,8 @@ 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 {
......@@ -15,4 +17,7 @@ abstract class VaccinationUIModule {
@ContributesAndroidInjector(modules = [VaccinationDetailsFragmentModule::class])
abstract fun vaccinationDetailsFragment(): VaccinationDetailsFragment
@ContributesAndroidInjector(modules = [VaccinationQrCodeScanModule::class])
abstract fun vaccinationQrCodeScanFragment(): VaccinationQrCodeScanFragment
}
package de.rki.coronawarnapp.vaccination.ui.homecards
package de.rki.coronawarnapp.vaccination.ui.homecard
import android.view.ViewGroup
import de.rki.coronawarnapp.R
......
package de.rki.coronawarnapp.vaccination.ui.homecards
package de.rki.coronawarnapp.vaccination.ui.homecard
import android.view.ViewGroup
import de.rki.coronawarnapp.R
......@@ -22,10 +22,17 @@ class CreateVaccinationHomeCard(parent: ViewGroup) :
payloads: List<Any>
) -> Unit = { item, payloads ->
itemView.setOnClickListener {
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 {
......
package de.rki.coronawarnapp.vaccination.ui.homecards
package de.rki.coronawarnapp.vaccination.ui.homecard
import android.view.ViewGroup
import de.rki.coronawarnapp.R
......
package de.rki.coronawarnapp.vaccination.ui.homecards
package de.rki.coronawarnapp.vaccination.ui.homecard
import de.rki.coronawarnapp.ui.main.home.items.HomeItem
import de.rki.coronawarnapp.vaccination.core.VaccinatedPerson
......
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
}
}
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>
}
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>
}
......@@ -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>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment