forked from fekt/hush-android-wallet
fekt
1 year ago
6 changed files with 285 additions and 159 deletions
@ -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<FragmentScanBinding>() { |
|||
|
|||
override val screen = Report.Screen.SCAN |
|||
class ScanFragment : BaseFragment<FragmentScanBinding>() { |
|||
|
|||
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<ProcessCameraProvider> |
|||
|
|||
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) |
|||
|
|||
// 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 |
|||
|
|||
override fun onCreate(savedInstanceState: Bundle?) { |
|||
super.onCreate(savedInstanceState) |
|||
if (!allPermissionsGranted()) getRuntimePermissions() |
|||
// Set up the camera and its use cases |
|||
setUpCamera() |
|||
} |
|||
|
|||
override fun onAttach(context: Context) { |
|||
super.onAttach(context) |
|||
cameraProviderFuture = ProcessCameraProvider.getInstance(context) |
|||
cameraProviderFuture.addListener( |
|||
Runnable { |
|||
bindPreview(cameraProviderFuture.get()) |
|||
}, |
|||
ContextCompat.getMainExecutor(context) |
|||
) |
|||
// Initialize back button |
|||
_fragmentCameraBinding?.backButtonHitArea?.onClickNavBack() |
|||
} |
|||
|
|||
override fun onDestroyView() { |
|||
_fragmentCameraBinding = null |
|||
super.onDestroyView() |
|||
cameraExecutor?.shutdown() |
|||
cameraExecutor = null |
|||
|
|||
// Shut down our background executor |
|||
cameraExecutor.shutdown() |
|||
} |
|||
|
|||
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! |
|||
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 = 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 preview = |
|||
Preview.Builder().setTargetName("Preview").setTargetAspectRatio(screenAspectRatio) |
|||
.setTargetRotation(rotation).build() |
|||
val rotation = fragmentCameraBinding.viewFinder.display.rotation |
|||
|
|||
val cameraSelector = CameraSelector.Builder() |
|||
.requireLensFacing(CameraSelector.LENS_FACING_BACK) |
|||
// 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() |
|||
|
|||
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!!, |
|||
|
|||
// 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) |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* Adapted from: https://github.com/android/camera-samples/blob/master/CameraXBasic/app/src/main/java/com/android/example/cameraxbasic/fragments/CameraFragment.kt#L350 |
|||
*/ |
|||
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)) |
|||
) { |
|||
return AspectRatio.RATIO_4_3 |
|||
} |
|||
return AspectRatio.RATIO_16_9 |
|||
private fun removeCameraStateObservers(cameraInfo: CameraInfo) { |
|||
cameraInfo.cameraState.removeObservers(viewLifecycleOwner) |
|||
} |
|||
|
|||
private fun onQrScanned(qrContent: String, image: ImageProxy) { |
|||
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) |
|||
image.close() |
|||
} else { /* continue scanning*/ |
|||
binding.textScanError.text = "" |
|||
sendViewModel.toAddress = parsed |
|||
mainActivity?.safeNavigate(R.id.action_nav_scan_to_nav_send) |
|||
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() |
|||
} |
|||
|
|||
// private fun updateOverlay(detectedObjects: DetectedObjects) { |
|||
// if (detectedObjects.objects.isEmpty()) { |
|||
// return |
|||
// } |
|||
// |
|||
// overlay.setSize(detectedObjects.imageWidth, detectedObjects.imageHeight) |
|||
// val list = mutableListOf<BoxData>() |
|||
// 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<String?> |
|||
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) |
|||
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() |
|||
} |
|||
} catch (e: Exception) { |
|||
arrayOfNulls(0) |
|||
} |
|||
} |
|||
|
|||
private fun allPermissionsGranted(): Boolean { |
|||
for (permission in requiredPermissions) { |
|||
if (!isPermissionGranted(mainActivity!!, permission!!)) { |
|||
return false |
|||
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() |
|||
} |
|||
} |
|||
} |
|||
} |
|||
return true |
|||
} |
|||
|
|||
private fun getRuntimePermissions() { |
|||
val allNeededPermissions = arrayListOf<String>() |
|||
for (permission in requiredPermissions) { |
|||
if (!isPermissionGranted(mainActivity!!, permission!!)) { |
|||
allNeededPermissions.add(permission) |
|||
/** |
|||
* [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 |
|||
} |
|||
|
|||
if (allNeededPermissions.isNotEmpty()) { |
|||
requestPermissions(allNeededPermissions.toTypedArray(), CAMERA_PERMISSION_REQUEST) |
|||
/** 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 CAMERA_PERMISSION_REQUEST = 1002 |
|||
|
|||
private fun isPermissionGranted(context: Context, permission: String): Boolean { |
|||
return ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED |
|||
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) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
Loading…
Reference in new issue