From 5696b62c8bc9b8473d5b8793a87ee43cf7a95e27 Mon Sep 17 00:00:00 2001 From: fekt Date: Sat, 3 Dec 2022 02:18:50 -0500 Subject: [PATCH] QR scanning fixes --- app/build.gradle | 15 +- app/src/main/AndroidManifest.xml | 12 +- .../z/ecc/android/ui/scan/ScanFragment.kt | 397 ++++++++++++------ .../z/ecc/android/ui/scan/ScanViewModel.kt | 4 +- app/src/main/res/layout/fragment_scan.xml | 2 +- .../java/cash/z/ecc/android/Dependencies.kt | 14 +- 6 files changed, 285 insertions(+), 159 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index d49fc32..f9545d3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -143,11 +143,16 @@ dependencies { implementation Deps.AndroidX.LEGACY implementation Deps.AndroidX.PAGING implementation Deps.AndroidX.RECYCLER - implementation Deps.AndroidX.CameraX.CAMERA2 - implementation Deps.AndroidX.CameraX.CORE - implementation Deps.AndroidX.CameraX.LIFECYCLE - implementation Deps.AndroidX.CameraX.View.EXT - implementation Deps.AndroidX.CameraX.View.VIEW + + def camerax_version = "1.2.0-rc01" + implementation "androidx.camera:camera-core:${camerax_version}" + implementation "androidx.camera:camera-camera2:${camerax_version}" + implementation "androidx.camera:camera-lifecycle:${camerax_version}" + implementation "androidx.camera:camera-view:${camerax_version}" + + //WindowManager + implementation "androidx.window:window:1.1.0-alpha01" + implementation Deps.AndroidX.Lifecycle.LIFECYCLE_RUNTIME_KTX implementation Deps.AndroidX.Navigation.FRAGMENT_KTX implementation Deps.AndroidX.Navigation.UI_KTX diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index baaecf1..8a25bd8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ - + @@ -11,8 +11,12 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/ZcashTheme"> - + android:theme="@style/ZcashTheme" + tools:targetApi="31"> + diff --git a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt index 3f0448f..f853f5d 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanFragment.kt @@ -1,216 +1,341 @@ package cash.z.ecc.android.ui.scan -import android.content.Context -import android.content.pm.PackageManager import android.os.Bundle -import android.util.DisplayMetrics +import android.util.Log import android.view.LayoutInflater import android.view.View +import android.view.ViewGroup +import android.widget.Toast import androidx.camera.core.* import androidx.camera.lifecycle.ProcessCameraProvider import androidx.core.content.ContextCompat import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels +import androidx.window.layout.WindowInfoTracker +import androidx.window.layout.WindowMetricsCalculator import cash.z.ecc.android.R import cash.z.ecc.android.databinding.FragmentScanBinding import cash.z.ecc.android.ext.onClickNavBack -import cash.z.ecc.android.feedback.Report -import cash.z.ecc.android.feedback.Report.Tap.SCAN_BACK import cash.z.ecc.android.ui.base.BaseFragment import cash.z.ecc.android.ui.send.SendViewModel -import cash.z.ecc.android.util.twig -import com.google.common.util.concurrent.ListenableFuture import kotlinx.coroutines.launch import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min -class ScanFragment : BaseFragment() { - override val screen = Report.Screen.SCAN +class ScanFragment : BaseFragment() { + private var _fragmentCameraBinding: FragmentScanBinding? = null + private val fragmentCameraBinding get() = _fragmentCameraBinding!! + private var displayId: Int = -1 + private var lensFacing: Int = CameraSelector.LENS_FACING_BACK + private var preview: Preview? = null + private var imageCapture: ImageCapture? = null + private var imageAnalyzer: ImageAnalysis? = null + private var camera: Camera? = null + private var cameraProvider: ProcessCameraProvider? = null private val viewModel: ScanViewModel by viewModels() - private val sendViewModel: SendViewModel by activityViewModels() + private lateinit var windowManager: WindowInfoTracker - private lateinit var cameraProviderFuture: ListenableFuture - - private var cameraExecutor: ExecutorService? = null + /** Blocking camera operations are performed using this executor */ + private lateinit var cameraExecutor: ExecutorService override fun inflate(inflater: LayoutInflater): FragmentScanBinding = FragmentScanBinding.inflate(inflater) override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - if (cameraExecutor != null) cameraExecutor?.shutdown() + + // Initialize our background executor cameraExecutor = Executors.newSingleThreadExecutor() - binding.backButtonHitArea.onClickNavBack() { tapped(SCAN_BACK) } - } + //Initialize WindowManager to retrieve display metrics + windowManager = WindowInfoTracker.getOrCreate(view.context) - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - if (!allPermissionsGranted()) getRuntimePermissions() - } + // Wait for the views to be properly laid out + fragmentCameraBinding.viewFinder.post { - override fun onAttach(context: Context) { - super.onAttach(context) - cameraProviderFuture = ProcessCameraProvider.getInstance(context) - cameraProviderFuture.addListener( - Runnable { - bindPreview(cameraProviderFuture.get()) - }, - ContextCompat.getMainExecutor(context) - ) + // Keep track of the display in which this view is attached + displayId = fragmentCameraBinding.viewFinder.display.displayId + + // Set up the camera and its use cases + setUpCamera() + } + + // Initialize back button + _fragmentCameraBinding?.backButtonHitArea?.onClickNavBack() } override fun onDestroyView() { + _fragmentCameraBinding = null super.onDestroyView() - cameraExecutor?.shutdown() - cameraExecutor = null + + // Shut down our background executor + cameraExecutor.shutdown() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + _fragmentCameraBinding = FragmentScanBinding.inflate(inflater, container, false) + return fragmentCameraBinding.root + } + + /** Initialize CameraX, and prepare to bind the camera use cases */ + private fun setUpCamera() { + val cameraProviderFuture = ProcessCameraProvider.getInstance(requireContext()) + cameraProviderFuture.addListener(Runnable { + + // CameraProvider + cameraProvider = cameraProviderFuture.get() + + // Select lensFacing depending on the available cameras + lensFacing = when { + hasBackCamera() -> CameraSelector.LENS_FACING_BACK + hasFrontCamera() -> CameraSelector.LENS_FACING_FRONT + else -> throw IllegalStateException("Back and front camera are unavailable") + } + + // Build and bind the camera use cases + bindCameraUseCases() + }, ContextCompat.getMainExecutor(requireContext())) } - private fun bindPreview(cameraProvider: ProcessCameraProvider) { - // Most of the code here is adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt - // it's worth keeping tabs on that implementation because they keep making breaking changes to these APIs! + /** Declare and bind preview, capture and analysis use cases */ + private fun bindCameraUseCases() { // Get screen metrics used to setup camera for full screen resolution - val metrics = DisplayMetrics().also { binding.preview.display.getRealMetrics(it) } - val screenAspectRatio = aspectRatio(metrics.widthPixels, metrics.heightPixels) - val rotation = binding.preview.display.rotation + /* + val metrics = WindowMetricsCalculator.getOrCreate().computeCurrentWindowMetrics(requireActivity()).bounds + Log.d("SilentDragon", "Screen metrics: ${metrics.width()} x ${metrics.height()}") + val screenAspectRatio = aspectRatio(metrics.width(), metrics.height()) + */ + + // Hardcode to square for now otherwise scanning doesn't work + val screenAspectRatio = aspectRatio(1, 1) + Log.d("SilentDragon", "Preview aspect ratio: $screenAspectRatio") + + val rotation = fragmentCameraBinding.viewFinder.display.rotation + + // CameraProvider + val cameraProvider = cameraProvider + ?: throw IllegalStateException("Camera initialization failed.") - val preview = - Preview.Builder().setTargetName("Preview").setTargetAspectRatio(screenAspectRatio) - .setTargetRotation(rotation).build() + // CameraSelector + val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build() - val cameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) + // Preview + preview = Preview.Builder() + // We request aspect ratio but no resolution + .setTargetAspectRatio(screenAspectRatio) + // Set initial target rotation + .setTargetRotation(rotation) + .build() + + // ImageCapture + imageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + // We request aspect ratio but no resolution to match preview config, but letting + // CameraX optimize for whatever specific resolution best fits our use cases + .setTargetAspectRatio(screenAspectRatio) + // Set initial target rotation, we will have to call this again if rotation changes + // during the lifecycle of this use case + .setTargetRotation(rotation) .build() - val imageAnalysis = ImageAnalysis.Builder().setTargetAspectRatio(screenAspectRatio) + // ImageAnalysis + imageAnalyzer = ImageAnalysis.Builder() + // We request aspect ratio but no resolution + .setTargetAspectRatio(screenAspectRatio) + // Set initial target rotation, we will have to call this again if rotation changes + // during the lifecycle of this use case .setTargetRotation(rotation) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() - imageAnalysis.setAnalyzer( - cameraExecutor!!, - QrAnalyzer { q, i -> - onQrScanned(q, i) + + // The analyzer can then be assigned to the instance + .also { + it.setAnalyzer(cameraExecutor, + QrAnalyzer { q, i -> + onQrScanned(q, i) + } + ) } - ) // Must unbind the use-cases before rebinding them cameraProvider.unbindAll() + if (camera != null) { + // Must remove observers from the previous camera instance + removeCameraStateObservers(camera!!.cameraInfo) + } + try { - cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageAnalysis) - preview.setSurfaceProvider(binding.preview.surfaceProvider) - } catch (t: Throwable) { - // TODO: consider bubbling this up to the user - mainActivity?.feedback?.report(t) - twig("Error while opening the camera: $t") + // A variable number of use-cases can be passed here - + // camera provides access to CameraControl & CameraInfo + camera = cameraProvider.bindToLifecycle( + this, cameraSelector, preview, imageCapture, imageAnalyzer) + + // Attach the viewfinder's surface provider to preview use case + preview?.setSurfaceProvider(fragmentCameraBinding.viewFinder.surfaceProvider) + observeCameraState(camera?.cameraInfo!!) + } catch (exc: Exception) { + Log.e(TAG, "Use case binding failed", exc) + } + } + + private fun removeCameraStateObservers(cameraInfo: CameraInfo) { + cameraInfo.cameraState.removeObservers(viewLifecycleOwner) + } + + private fun observeCameraState(cameraInfo: CameraInfo) { + cameraInfo.cameraState.observe(viewLifecycleOwner) { cameraState -> + run { + when (cameraState.type) { + CameraState.Type.PENDING_OPEN -> { + // Ask the user to close other camera apps + Toast.makeText(context, + "CameraState: Pending Open", + Toast.LENGTH_SHORT).show() + } + CameraState.Type.OPENING -> { + // Show the Camera UI + Toast.makeText(context, + "CameraState: Opening", + Toast.LENGTH_SHORT).show() + } + CameraState.Type.OPEN -> { + // Setup Camera resources and begin processing + Toast.makeText(context, + "CameraState: Open", + Toast.LENGTH_SHORT).show() + } + CameraState.Type.CLOSING -> { + // Close camera UI + Toast.makeText(context, + "CameraState: Closing", + Toast.LENGTH_SHORT).show() + } + CameraState.Type.CLOSED -> { + // Free camera resources + Toast.makeText(context, + "CameraState: Closed", + Toast.LENGTH_SHORT).show() + } + } + } + + cameraState.error?.let { error -> + when (error.code) { + // Open errors + CameraState.ERROR_STREAM_CONFIG -> { + // Make sure to setup the use cases properly + Toast.makeText(context, + "Stream config error", + Toast.LENGTH_SHORT).show() + } + // Opening errors + CameraState.ERROR_CAMERA_IN_USE -> { + // Close the camera or ask user to close another camera app that's using the + // camera + Toast.makeText(context, + "Camera in use", + Toast.LENGTH_SHORT).show() + } + CameraState.ERROR_MAX_CAMERAS_IN_USE -> { + // Close another open camera in the app, or ask the user to close another + // camera app that's using the camera + Toast.makeText(context, + "Max cameras in use", + Toast.LENGTH_SHORT).show() + } + CameraState.ERROR_OTHER_RECOVERABLE_ERROR -> { + Toast.makeText(context, + "Other recoverable error", + Toast.LENGTH_SHORT).show() + } + // Closing errors + CameraState.ERROR_CAMERA_DISABLED -> { + // Ask the user to enable the device's cameras + Toast.makeText(context, + "Camera disabled", + Toast.LENGTH_SHORT).show() + } + CameraState.ERROR_CAMERA_FATAL_ERROR -> { + // Ask the user to reboot the device to restore camera function + Toast.makeText(context, + "Fatal error", + Toast.LENGTH_SHORT).show() + } + // Closed errors + CameraState.ERROR_DO_NOT_DISTURB_MODE_ENABLED -> { + // Ask the user to disable the "Do Not Disturb" mode, then reopen the camera + Toast.makeText(context, + "Do not disturb mode enabled", + Toast.LENGTH_SHORT).show() + } + } + } } } /** - * Adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt#L350 + * [androidx.camera.core.ImageAnalysis.Builder] requires enum value of + * [androidx.camera.core.AspectRatio]. Currently it has values of 4:3 & 16:9. + * + * Detecting the most suitable ratio for dimensions provided in @params by counting absolute + * of preview ratio to one of the provided values. + * + * @param width - preview width + * @param height - preview height + * @return suitable aspect ratio */ private fun aspectRatio(width: Int, height: Int): Int { - val previewRatio = kotlin.math.max(width, height).toDouble() / kotlin.math.min( - width, - height - ) - if (kotlin.math.abs(previewRatio - (4.0 / 3.0)) - <= kotlin.math.abs(previewRatio - (16.0 / 9.0)) - ) { + val previewRatio = max(width, height).toDouble() / min(width, height) + if (abs(previewRatio - RATIO_4_3_VALUE) <= abs(previewRatio - RATIO_16_9_VALUE)) { return AspectRatio.RATIO_4_3 } return AspectRatio.RATIO_16_9 } + /** Returns true if the device has an available back camera. False otherwise */ + private fun hasBackCamera(): Boolean { + return cameraProvider?.hasCamera(CameraSelector.DEFAULT_BACK_CAMERA) ?: false + } + + /** Returns true if the device has an available front camera. False otherwise */ + private fun hasFrontCamera(): Boolean { + return cameraProvider?.hasCamera(CameraSelector.DEFAULT_FRONT_CAMERA) ?: false + } + + companion object { + + private const val TAG = "SilentDragon" + private const val RATIO_4_3_VALUE = 4.0 / 3.0 + private const val RATIO_16_9_VALUE = 16.0 / 9.0 + } + private fun onQrScanned(qrContent: String, image: ImageProxy) { + //Log.d("SilentDragon", "QR scanned: $qrContent") resumedScope.launch { val parsed = viewModel.parse(qrContent) if (parsed == null) { val network = viewModel.networkName - binding.textScanError.text = getString(R.string.scan_invalid_address, network, qrContent) + _fragmentCameraBinding?.textScanError?.text = + getString(R.string.scan_invalid_address, network, qrContent) image.close() } else { /* continue scanning*/ - binding.textScanError.text = "" + _fragmentCameraBinding?.textScanError?.text = "" sendViewModel.toAddress = parsed mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send) } } } - -// private fun updateOverlay(detectedObjects: DetectedObjects) { -// if (detectedObjects.objects.isEmpty()) { -// return -// } -// -// overlay.setSize(detectedObjects.imageWidth, detectedObjects.imageHeight) -// val list = mutableListOf() -// for (obj in detectedObjects.objects) { -// val box = obj.boundingBox -// val name = "${categoryNames[obj.classificationCategory]}" -// val confidence = -// if (obj.classificationCategory != FirebaseVisionObject.CATEGORY_UNKNOWN) { -// val confidence: Int = obj.classificationConfidence!!.times(100).toInt() -// "$confidence%" -// } else { -// "" -// } -// list.add(BoxData("$name $confidence", box)) -// } -// overlay.set(list) -// } - - // - // Permissions - // - - private val requiredPermissions: Array - get() { - return try { - val info = mainActivity?.packageManager - ?.getPackageInfo(mainActivity?.packageName ?: "", PackageManager.GET_PERMISSIONS) - val ps = info?.requestedPermissions - if (ps != null && ps.isNotEmpty()) { - ps - } else { - arrayOfNulls(0) - } - } catch (e: Exception) { - arrayOfNulls(0) - } - } - - private fun allPermissionsGranted(): Boolean { - for (permission in requiredPermissions) { - if (!isPermissionGranted(mainActivity!!, permission!!)) { - return false - } - } - return true - } - - private fun getRuntimePermissions() { - val allNeededPermissions = arrayListOf() - for (permission in requiredPermissions) { - if (!isPermissionGranted(mainActivity!!, permission!!)) { - allNeededPermissions.add(permission) - } - } - - if (allNeededPermissions.isNotEmpty()) { - requestPermissions(allNeededPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST) - } - } - - companion object { - private const val CAMERA_PERMISSION_REQUEST = 1002 - - private fun isPermissionGranted(context: Context, permission: String): Boolean { - return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED - } - } } diff --git a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanViewModel.kt b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanViewModel.kt index fbe87bd..1ceef91 100644 --- a/app/src/main/java/cash/z/ecc/android/ui/scan/ScanViewModel.kt +++ b/app/src/main/java/cash/z/ecc/android/ui/scan/ScanViewModel.kt @@ -12,9 +12,9 @@ class ScanViewModel : ViewModel() { val networkName get() = synchronizer.network.networkName suspend fun parse(qrCode: String): String? { - // temporary parse code to allow both plain addresses and those that start with zcash: + // temporary parse code to allow both plain addresses and those that start with hush: // TODO: replace with more robust ZIP-321 handling of QR codes - val address = if (qrCode.startsWith("zcash:")) { + val address = if (qrCode.startsWith("hush:")) { qrCode.substring(6, qrCode.indexOf("?").takeUnless { it == -1 } ?: qrCode.length) } else { qrCode diff --git a/app/src/main/res/layout/fragment_scan.xml b/app/src/main/res/layout/fragment_scan.xml index ce794f7..02bb264 100644 --- a/app/src/main/res/layout/fragment_scan.xml +++ b/app/src/main/res/layout/fragment_scan.xml @@ -46,7 +46,7 @@ tools:background="@color/zcashRed" />