package cash.z.ecc.android.ui.scan import android.os.Bundle 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.ui.base.BaseFragment import cash.z.ecc.android.ui.send.SendViewModel 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() { 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 /** 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) // Initialize our background executor cameraExecutor = Executors.newSingleThreadExecutor() //Initialize WindowManager to retrieve display metrics windowManager = WindowInfoTracker.getOrCreate(view.context) // Wait for the views to be properly laid out fragmentCameraBinding.viewFinder.post { // 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() // 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())) } /** 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 = 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.") // CameraSelector val cameraSelector = CameraSelector.Builder().requireLensFacing(lensFacing).build() // 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() // 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() // 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 { // 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() } } } } } /** * [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 = 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 _fragmentCameraBinding?.textScanError?.text = getString(R.string.scan_invalid_address, network, qrContent) image.close() } else { /* continue scanning*/ _fragmentCameraBinding?.textScanError?.text = "" sendViewModel.toAddress = parsed mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send) } } } }