diff --git a/CHANGELOG.md b/CHANGELOG.md index 008e5bf02..9fcdc8196 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ and this application adheres to [Semantic Versioning](https://semver.org/spec/v2 ### Changed - Send Confirmation & Send Progress screens have been refactored +- ZXing QR codes scanning library has been replaced with a more recent MLkit Barcodes scanning library, which gives + us better results in testing + +### Fixed +- The way how Zashi treats ZIP 321 single address within URIs results has been fixed ## [1.3.1 (822)] - 2025-01-07 diff --git a/docs/whatsNew/WHATS_NEW_EN.md b/docs/whatsNew/WHATS_NEW_EN.md index 75b308ceb..c2d7a10c9 100644 --- a/docs/whatsNew/WHATS_NEW_EN.md +++ b/docs/whatsNew/WHATS_NEW_EN.md @@ -14,6 +14,11 @@ directly impact users rather than highlighting other key architectural updates.* ### Changed - Send Confirmation & Send Progress screens have been refactored with bugfixes and optimizations +- ZXing QR codes scanning library has been replaced with a more recent MLkit Barcodes scanning library, which gives + us better results in testing + +### Fixed +- The way how Zashi treats ZIP 321 single address within URIs results has been fixed ## [1.3.1 (822)] - 2025-01-07 diff --git a/docs/whatsNew/WHATS_NEW_ES.md b/docs/whatsNew/WHATS_NEW_ES.md index b4ba7615d..b82a11047 100644 --- a/docs/whatsNew/WHATS_NEW_ES.md +++ b/docs/whatsNew/WHATS_NEW_ES.md @@ -14,6 +14,11 @@ directly impact users rather than highlighting other key architectural updates.* ### Changed - Send Confirmation & Send Progress screens have been refactored with bugfixes and optimizations +- ZXing QR codes scanning library has been replaced with a more recent MLkit Barcodes scanning library, which gives + us better results in testing + +### Fixed +- The way how Zashi treats ZIP 321 single address within URIs results has been fixed ## [1.3.1 (822)] - 2025-01-07 diff --git a/gradle.properties b/gradle.properties index 4cdefd992..31985b31a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -204,6 +204,7 @@ KOTLINX_SERIALIZABLE_JSON_VERSION=1.6.3 KOVER_VERSION=0.7.3 LOTTIE_VERSION=6.5.0 MARKDOWN_VERSION=0.7.3 +MLKIT_SCANNING_VERSION=17.3.0 PLAY_APP_UPDATE_VERSION=2.1.0 PLAY_APP_UPDATE_KTX_VERSION=2.1.0 PLAY_PUBLISHER_API_VERSION=v3-rev20231030-2.0.0 diff --git a/settings.gradle.kts b/settings.gradle.kts index e065684c7..07b2c4f6e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -80,13 +80,15 @@ dependencyResolutionManagement { "androidx.benchmark", "androidx.navigation", "com.android", + "com.google.android.apps.common.testing.accessibility.framework", "com.google.android.datatransport", "com.google.android.gms", "com.google.android.material", + "com.google.android.odml", "com.google.android.play", "com.google.firebase", + "com.google.mlkit", "com.google.testing.platform", - "com.google.android.apps.common.testing.accessibility.framework" ) val googleRegexes = listOf( "androidx\\..*", @@ -181,6 +183,7 @@ dependencyResolutionManagement { val kotlinxSerializableJsonVersion = extra["KOTLINX_SERIALIZABLE_JSON_VERSION"].toString() val lottieVersion = extra["LOTTIE_VERSION"].toString() val markdownVersion = extra["MARKDOWN_VERSION"].toString() + val mlkitScanningVersion = extra["MLKIT_SCANNING_VERSION"].toString() val playAppUpdateVersion = extra["PLAY_APP_UPDATE_VERSION"].toString() val playAppUpdateKtxVersion = extra["PLAY_APP_UPDATE_KTX_VERSION"].toString() val tinkVersion = extra["TINK_VERSION"].toString() @@ -245,6 +248,7 @@ dependencyResolutionManagement { library("kotlinx-serializable-json", "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializableJsonVersion") library("lottie", "com.airbnb.android:lottie-compose:$lottieVersion") library("markdown", "org.jetbrains:markdown:$markdownVersion") + library("mlkit-scanning", "com.google.mlkit:barcode-scanning:$mlkitScanningVersion") library("play-update", "com.google.android.play:app-update:$playAppUpdateVersion") library("play-update-ktx", "com.google.android.play:app-update-ktx:$playAppUpdateKtxVersion") library("tink", "com.google.crypto.tink:tink-android:$tinkVersion") diff --git a/ui-lib/build.gradle.kts b/ui-lib/build.gradle.kts index 2ec83806c..42875f844 100644 --- a/ui-lib/build.gradle.kts +++ b/ui-lib/build.gradle.kts @@ -140,6 +140,7 @@ dependencies { implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.immutable) implementation(libs.kotlinx.serializable.json) + implementation(libs.mlkit.scanning) implementation(libs.zcash.sdk) implementation(libs.zcash.sdk.incubator) implementation(libs.zcash.bip39) diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ParseUriValidationUseCase.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ParseUriValidationUseCase.kt index 8ff91c8bf..3e99d349a 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ParseUriValidationUseCase.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/common/usecase/Zip321ParseUriValidationUseCase.kt @@ -42,7 +42,8 @@ internal class Zip321ParseUriValidationUseCase( return when (paymentRequest) { is ZIP321.ParserResult.Request -> Zip321ParseUriValidation.Valid(zip321Uri) - // null or [ZIP321.ParserResult.SingleAddress] is not valid for our ZIP 321 Uri to Proposal use case + is ZIP321.ParserResult.SingleAddress -> + Zip321ParseUriValidation.SingleAddress(paymentRequest.singleRecipient.value) else -> Zip321ParseUriValidation.Invalid } } @@ -50,6 +51,8 @@ internal class Zip321ParseUriValidationUseCase( internal sealed class Zip321ParseUriValidation { data class Valid(val zip321Uri: String) : Zip321ParseUriValidation() + data class SingleAddress(val address: String) : Zip321ParseUriValidation() + data object Invalid : Zip321ParseUriValidation() } } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/util/MlkitQrCodeAnalyzer.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/util/MlkitQrCodeAnalyzer.kt new file mode 100644 index 000000000..316458249 --- /dev/null +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/util/MlkitQrCodeAnalyzer.kt @@ -0,0 +1,128 @@ +package co.electriccoin.zcash.ui.screen.scan.util + +import android.graphics.Bitmap +import android.graphics.Matrix +import androidx.annotation.OptIn +import androidx.camera.core.ExperimentalGetImage +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import co.electriccoin.zcash.spackle.Twig +import co.electriccoin.zcash.ui.screen.scankeystone.view.FramePosition +import com.google.mlkit.vision.barcode.BarcodeScannerOptions +import com.google.mlkit.vision.barcode.BarcodeScanning +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.common.InputImage + +class MlkitQrCodeAnalyzer( + private val framePosition: FramePosition, + private val onQrCodeScanned: (String) -> Unit, +) : ImageAnalysis.Analyzer { + private val supportedImageFormat = Barcode.FORMAT_QR_CODE + + @OptIn(ExperimentalGetImage::class) + override fun analyze(imageProxy: ImageProxy) { + Twig.verbose { "Mlkit image proxy: ${imageProxy.imageInfo}" } + + val mediaImage = imageProxy.image + if (mediaImage != null) { + val bitmap = imageProxy.toBitmap() + + val rotatedBitmap = bitmap.rotate(imageProxy.imageInfo.rotationDegrees) + val croppedBitmap = rotatedBitmap.crop(framePosition) + + // No rotation for cropped Bitmap + val image = InputImage.fromBitmap(croppedBitmap, 0) + + Twig.verbose { + "Scan result: " + + "Frame: $framePosition, " + "Format: ${mediaImage.format}, " + + "Image width: ${mediaImage.width}, " + + "Image height: ${mediaImage.height}" + "Rotation: ${imageProxy.imageInfo.rotationDegrees}" + } + + // Configure Barcode Scanner Options + val options = + BarcodeScannerOptions.Builder() + .setBarcodeFormats(supportedImageFormat) + // We could optionally use this to enhance scan success ratio. If it's specified, then the library + // will suggest zooming the camera if the barcode is too far away or too small to be detected. + // .setZoomSuggestionOptions() + .build() + + // Initialize Barcode Scanner + val scanner = BarcodeScanning.getClient(options) + + scanner.process(image) + .addOnSuccessListener { barcodes -> + for (barcode in barcodes) { + barcode.rawValue?.let { value -> + Twig.debug { "Mlkit barcode value: $value" } + onQrCodeScanned(value) + // Note that we only take the first code from the list of discovered codes + return@addOnSuccessListener + } + } + } + .addOnFailureListener { e -> + Twig.error(e) { "Barcode detection failed" } + } + .addOnCompleteListener { + // Close the image proxy + imageProxy.close() + } + } else { + imageProxy.close() + } + } +} + +private fun Bitmap.rotate(rotationDegrees: Int): Bitmap { + // Rotate the matrix by the specified degrees + val matrix = + Matrix().also { + it.postRotate(rotationDegrees.toFloat()) + } + return Bitmap.createBitmap( + // source + this, + // x + 0, + // y + 0, + // width + width, + // height + height, + // m + matrix, + // filter (Filter for better quality) + true + ) +} + +/* + * Crop Bitmap to the specified dimensions given by [FramePosition] + */ +@Suppress("UNUSED_PARAMETER") +private fun Bitmap.crop(framePosition: FramePosition): Bitmap { + // TODO [#1380]: Leverage FramePosition in QrCodeAnalyzer + // TODO [#1380]: https://github.com/Electric-Coin-Company/zashi-android/issues/1380 + return Bitmap.createBitmap( + this, + // left + (width * LEFT_OFFSET).toInt(), + // top + (height * TOP_OFFSET).toInt(), + // width + (width * WIDTH_OFFSET).toInt(), + // height + (height * HEIGHT_OFFSET).toInt(), + ) +} + +private const val LEFT_OFFSET = .15 +private const val TOP_OFFSET = .25 +private const val WIDTH_OFFSET = .7 +private const val HEIGHT_OFFSET = .45 diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt index 49ae3ad56..2fd166e93 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/view/ScanView.kt @@ -88,7 +88,7 @@ import co.electriccoin.zcash.ui.screen.scan.ScanTag import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState import co.electriccoin.zcash.ui.screen.scan.util.ImageUriToQrCodeConverter -import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer +import co.electriccoin.zcash.ui.screen.scan.util.MlkitQrCodeAnalyzer import co.electriccoin.zcash.ui.screen.scankeystone.view.CAMERA_TRANSLUCENT_BORDER import co.electriccoin.zcash.ui.screen.scankeystone.view.FramePosition import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -709,7 +709,7 @@ fun ImageAnalysis.qrCodeFlow(framePosition: FramePosition): Flow { callbackFlow { setAnalyzer( ContextCompat.getMainExecutor(context), - QrCodeAnalyzer( + MlkitQrCodeAnalyzer( framePosition = framePosition, onQrCodeScanned = { result -> Twig.debug { "Scan result onQrCodeScanned: $result" } diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/viewmodel/ScanViewModel.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/viewmodel/ScanViewModel.kt index 16e255dff..1fea25cdf 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/viewmodel/ScanViewModel.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scan/viewmodel/ScanViewModel.kt @@ -41,48 +41,76 @@ internal class ScanViewModel( mutex.withLock { if (!hasBeenScannedSuccessfully) { val addressValidationResult = getSynchronizer().validateAddress(result) - val zip321ValidationResult = zip321ParseUriValidationUseCase(result) - state.update { - if (addressValidationResult is AddressType.Valid) { - ScanValidationState.INVALID - } else if (zip321ValidationResult is Zip321ParseUriValidation.Valid) { - ScanValidationState.INVALID - } else { - ScanValidationState.NONE - } - } - - if (zip321ValidationResult is Zip321ParseUriValidation.Valid) { - hasBeenScannedSuccessfully = true - navigateBack.emit(ScanResultState.Zip321Uri(zip321ValidationResult.zip321Uri)) - } else if (addressValidationResult is AddressType.Valid) { - hasBeenScannedSuccessfully = true - - val serializableAddress = SerializableAddress(result, addressValidationResult) - - when (args) { - DEFAULT -> { - navigateBack.emit( - ScanResultState.Address( - Json.encodeToString( - SerializableAddress.serializer(), - serializableAddress - ) - ) - ) + when { + zip321ValidationResult is Zip321ParseUriValidation.Valid -> + { + hasBeenScannedSuccessfully = true + state.update { ScanValidationState.VALID } + navigateBack.emit(ScanResultState.Zip321Uri(zip321ValidationResult.zip321Uri)) } - - ADDRESS_BOOK -> { - navigateCommand.emit(AddContactArgs(serializableAddress.address)) + zip321ValidationResult is Zip321ParseUriValidation.SingleAddress -> + { + hasBeenScannedSuccessfully = true + val singleAddressValidation = + getSynchronizer() + .validateAddress(zip321ValidationResult.address) + when (singleAddressValidation) { + is AddressType.Invalid -> { + state.update { ScanValidationState.INVALID } + } + else -> { + state.update { ScanValidationState.VALID } + processAddress(zip321ValidationResult.address, singleAddressValidation) + } + } } + addressValidationResult is AddressType.Valid -> + { + hasBeenScannedSuccessfully = true + state.update { ScanValidationState.VALID } + processAddress(result, addressValidationResult) + } + else -> { + hasBeenScannedSuccessfully = false + state.update { ScanValidationState.INVALID } } } } } } + private suspend fun processAddress( + address: String, + addressType: AddressType + ) { + require(addressType is AddressType.Valid) + + val serializableAddress = + SerializableAddress( + address = address, + type = addressType + ) + + when (args) { + DEFAULT -> { + navigateBack.emit( + ScanResultState.Address( + Json.encodeToString( + SerializableAddress.serializer(), + serializableAddress + ) + ) + ) + } + + ADDRESS_BOOK -> { + navigateCommand.emit(AddContactArgs(serializableAddress.address)) + } + } + } + fun onScannedError() = viewModelScope.launch { mutex.withLock { diff --git a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scankeystone/view/ScanKeystoneView.kt b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scankeystone/view/ScanKeystoneView.kt index 5a5a30fbc..5d0776631 100644 --- a/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scankeystone/view/ScanKeystoneView.kt +++ b/ui-lib/src/main/java/co/electriccoin/zcash/ui/screen/scankeystone/view/ScanKeystoneView.kt @@ -88,7 +88,7 @@ import co.electriccoin.zcash.ui.design.util.stringRes import co.electriccoin.zcash.ui.screen.scan.ScanTag import co.electriccoin.zcash.ui.screen.scan.model.ScanScreenState import co.electriccoin.zcash.ui.screen.scan.model.ScanValidationState -import co.electriccoin.zcash.ui.screen.scan.util.QrCodeAnalyzer +import co.electriccoin.zcash.ui.screen.scan.util.MlkitQrCodeAnalyzer import co.electriccoin.zcash.ui.screen.scankeystone.model.ScanKeystoneState import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionState @@ -730,7 +730,7 @@ fun ImageAnalysis.qrCodeFlow(framePosition: FramePosition): Flow { callbackFlow { setAnalyzer( ContextCompat.getMainExecutor(context), - QrCodeAnalyzer( + MlkitQrCodeAnalyzer( framePosition = framePosition, onQrCodeScanned = { result -> Twig.debug { "Scan result onQrCodeScanned: $result" }