-
-
Save Alex009/6b150b5027a04f9d4d1cf2072b713793 to your computer and use it in GitHub Desktop.
QR Code Scanner with Jetbrains Jetpack compose multiplatform!
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import android.Manifest | |
import android.content.pm.PackageManager | |
import android.util.Log | |
import android.view.ViewGroup | |
import androidx.activity.compose.rememberLauncherForActivityResult | |
import androidx.activity.result.contract.ActivityResultContracts | |
import androidx.camera.core.CameraSelector | |
import androidx.camera.core.ImageAnalysis | |
import androidx.camera.core.Preview | |
import androidx.camera.lifecycle.ProcessCameraProvider | |
import androidx.camera.view.PreviewView | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material.MaterialTheme | |
import androidx.compose.material.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.platform.LocalLifecycleOwner | |
import androidx.compose.ui.res.stringResource | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.viewinterop.AndroidView | |
import androidx.core.content.ContextCompat | |
import com.google.common.util.concurrent.ListenableFuture | |
import android.annotation.SuppressLint | |
import androidx.camera.core.ImageAnalysis | |
import androidx.camera.core.ImageProxy | |
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 | |
import java.util.concurrent.TimeUnit | |
import java.util.concurrent.ExecutorService | |
import java.util.concurrent.Executors | |
@Composable | |
actual fun QrScannerScreen(modifier: Modifier, onQrCodeScanned: (String) -> Unit) { | |
Box(modifier = modifier) { | |
QRCodeComposable(onQrCodeScanned) | |
Text( | |
text = stringResource(R.string.scan_barcode_instruction), | |
modifier = Modifier.background(Color.Black.copy(alpha = 0.3f)) | |
.align(Alignment.TopCenter).padding(48.dp), | |
style = MaterialTheme.typography.h6.copy(color = Color.White) | |
) | |
Text( | |
text = stringResource(R.string.scan_qr_code), | |
modifier = Modifier.background(Color.Black.copy(alpha = 0.3f)) | |
.align(Alignment.BottomCenter).padding(48.dp), | |
style = MaterialTheme.typography.h6.copy(color = Color.White) | |
) | |
} | |
} | |
@Composable | |
fun QRCodeComposable(onQrCodeScanned: (String) -> Unit) { | |
val context = LocalContext.current | |
val lifecycleOwner = LocalLifecycleOwner.current | |
var preview by remember { mutableStateOf<Preview?>(null) } | |
var hasCamPermission by remember { | |
mutableStateOf( | |
ContextCompat.checkSelfPermission( | |
context, | |
Manifest.permission.CAMERA | |
) == PackageManager.PERMISSION_GRANTED | |
) | |
} | |
val launcher = rememberLauncherForActivityResult( | |
contract = ActivityResultContracts.RequestPermission(), | |
onResult = { granted -> | |
hasCamPermission = granted | |
} | |
) | |
LaunchedEffect(key1 = true) { | |
launcher.launch(Manifest.permission.CAMERA) | |
} | |
if (hasCamPermission) { | |
AndroidView( | |
factory = { AndroidViewContext -> | |
PreviewView(AndroidViewContext).apply { | |
this.scaleType = PreviewView.ScaleType.FILL_CENTER | |
layoutParams = ViewGroup.LayoutParams( | |
ViewGroup.LayoutParams.MATCH_PARENT, | |
ViewGroup.LayoutParams.MATCH_PARENT, | |
) | |
implementationMode = PreviewView.ImplementationMode.COMPATIBLE | |
} | |
}, | |
modifier = Modifier.fillMaxSize(), | |
update = { previewView -> | |
val cameraSelector: CameraSelector = CameraSelector.Builder() | |
.requireLensFacing(CameraSelector.LENS_FACING_BACK) | |
.build() | |
val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor() | |
val cameraProviderFuture: ListenableFuture<ProcessCameraProvider> = | |
ProcessCameraProvider.getInstance(context) | |
cameraProviderFuture.addListener({ | |
preview = Preview.Builder().build().also { | |
it.setSurfaceProvider(previewView.surfaceProvider) | |
} | |
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() | |
val barcodeAnalyser = BarcodeAnalyser { barcodes -> | |
barcodes.forEach { barcode -> | |
barcode.rawValue?.let { barcodeValue -> | |
onQrCodeScanned(barcodeValue) | |
} | |
} | |
} | |
val imageAnalysis: ImageAnalysis = ImageAnalysis.Builder() | |
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) | |
.build() | |
.also { | |
it.setAnalyzer(cameraExecutor, barcodeAnalyser) | |
} | |
try { | |
cameraProvider.unbindAll() | |
cameraProvider.bindToLifecycle( | |
lifecycleOwner, | |
cameraSelector, | |
preview, | |
imageAnalysis | |
) | |
} catch (e: Exception) { | |
e.printStackTrace() | |
Log.e("qr code", e.message ?: "") | |
} | |
}, ContextCompat.getMainExecutor(context)) | |
} | |
) | |
} | |
} | |
class BarcodeAnalyser( | |
private val onBarcodeDetected: (barcodes: List<Barcode>) -> Unit, | |
) : ImageAnalysis.Analyzer { | |
private var lastAnalyzedTimeStamp = 0L | |
@SuppressLint("UnsafeOptInUsageError") | |
override fun analyze(image: ImageProxy) { | |
val currentTimestamp = System.currentTimeMillis() | |
if (currentTimestamp - lastAnalyzedTimeStamp >= TimeUnit.SECONDS.toMillis(1)) { | |
image.image?.let { imageToAnalyze -> | |
val options = BarcodeScannerOptions.Builder() | |
.setBarcodeFormats(Barcode.FORMAT_ALL_FORMATS) | |
.build() | |
val barcodeScanner = BarcodeScanning.getClient(options) | |
val imageToProcess = | |
InputImage.fromMediaImage(imageToAnalyze, image.imageInfo.rotationDegrees) | |
barcodeScanner.process(imageToProcess) | |
.addOnSuccessListener { barcodes -> | |
if (barcodes.isNotEmpty()) { | |
onBarcodeDetected(barcodes) | |
} | |
} | |
.addOnFailureListener { exception -> | |
exception.printStackTrace() | |
} | |
.addOnCompleteListener { | |
image.close() | |
} | |
} | |
lastAnalyzedTimeStamp = currentTimestamp | |
} else { | |
image.close() | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.* | |
import androidx.compose.material.Text | |
import androidx.compose.runtime.* | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.interop.UIKitView | |
import kotlinx.cinterop.CValue | |
import kotlinx.cinterop.ObjCAction | |
import platform.AVFoundation.* | |
import platform.AVFoundation.AVCaptureDeviceDiscoverySession.Companion.discoverySessionWithDeviceTypes | |
import platform.AVFoundation.AVCaptureDeviceInput.Companion.deviceInputWithDevice | |
import platform.AudioToolbox.AudioServicesPlaySystemSound | |
import platform.AudioToolbox.kSystemSoundID_Vibrate | |
import platform.CoreGraphics.CGRect | |
import platform.Foundation.NSNotification | |
import platform.Foundation.NSNotificationCenter | |
import platform.Foundation.NSSelectorFromString | |
import platform.QuartzCore.CATransaction | |
import platform.QuartzCore.kCATransactionDisableActions | |
import platform.UIKit.UIDevice | |
import platform.UIKit.UIDeviceOrientation | |
import platform.UIKit.UIView | |
import platform.darwin.NSObject | |
import platform.darwin.dispatch_get_main_queue | |
private sealed interface CameraAccess { | |
object Undefined : CameraAccess | |
object Denied : CameraAccess | |
object Authorized : CameraAccess | |
} | |
private val deviceTypes = listOf( | |
AVCaptureDeviceTypeBuiltInWideAngleCamera, | |
AVCaptureDeviceTypeBuiltInDualWideCamera, | |
AVCaptureDeviceTypeBuiltInDualCamera, | |
AVCaptureDeviceTypeBuiltInUltraWideCamera, | |
AVCaptureDeviceTypeBuiltInDuoCamera | |
) | |
@Composable | |
actual fun QrScannerScreen(modifier: Modifier, onQrCodeScanned: (String) -> Unit) { | |
var cameraAccess: CameraAccess by remember { mutableStateOf(CameraAccess.Undefined) } | |
LaunchedEffect(Unit) { | |
when (AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo)) { | |
AVAuthorizationStatusAuthorized -> { | |
cameraAccess = CameraAccess.Authorized | |
} | |
AVAuthorizationStatusDenied, AVAuthorizationStatusRestricted -> { | |
cameraAccess = CameraAccess.Denied | |
} | |
AVAuthorizationStatusNotDetermined -> { | |
AVCaptureDevice.requestAccessForMediaType( | |
mediaType = AVMediaTypeVideo | |
) { success -> | |
cameraAccess = if (success) CameraAccess.Authorized else CameraAccess.Denied | |
} | |
} | |
} | |
} | |
Box( | |
modifier.fillMaxSize().background(Color.Black), | |
contentAlignment = Alignment.Center | |
) { | |
when (cameraAccess) { | |
CameraAccess.Undefined -> { | |
// Waiting for the user to accept permission | |
} | |
CameraAccess.Denied -> { | |
Text("Camera access denied", color = Color.White) | |
} | |
CameraAccess.Authorized -> { | |
AuthorizedCamera(onQrCodeScanned) | |
} | |
} | |
} | |
} | |
@Composable | |
private fun BoxScope.AuthorizedCamera(onQrCodeScanned: (String) -> Unit) { | |
val camera: AVCaptureDevice? = remember { | |
discoverySessionWithDeviceTypes( | |
deviceTypes = deviceTypes, | |
mediaType = AVMediaTypeVideo, | |
position = AVCaptureDevicePositionBack, | |
).devices.firstOrNull() as? AVCaptureDevice | |
} | |
if (camera != null) { | |
RealDeviceCamera(camera, onQrCodeScanned) | |
} else { | |
Text( | |
""" | |
Camera is not available on simulator. | |
Please try to run on a real iOS device. | |
""".trimIndent(), color = Color.White | |
) | |
} | |
} | |
@Composable | |
private fun RealDeviceCamera( | |
camera: AVCaptureDevice, | |
onQrCodeScanned: (String) -> Unit, | |
) { | |
val capturePhotoOutput = remember { AVCapturePhotoOutput() } | |
var actualOrientation by remember { | |
mutableStateOf( | |
AVCaptureVideoOrientationPortrait | |
) | |
} | |
val captureSession: AVCaptureSession = remember { | |
AVCaptureSession().also { captureSession -> | |
captureSession.sessionPreset = AVCaptureSessionPresetPhoto | |
val captureDeviceInput: AVCaptureDeviceInput = | |
deviceInputWithDevice(device = camera, error = null)!! | |
captureSession.addInput(captureDeviceInput) | |
captureSession.addOutput(capturePhotoOutput) | |
//Initialize an AVCaptureMetadataOutput object and set it as the output device to the capture session. | |
val metadataOutput = AVCaptureMetadataOutput() | |
if (captureSession.canAddOutput(metadataOutput)) { | |
//Set delegate and use default dispatch queue to execute the call back | |
// fixed with https://youtrack.jetbrains.com/issue/KT-45755/iOS-delegate-protocol-is-empty | |
captureSession.addOutput(metadataOutput) | |
metadataOutput.setMetadataObjectsDelegate(objectsDelegate = object : NSObject(), | |
AVCaptureMetadataOutputObjectsDelegateProtocol { | |
override fun captureOutput( | |
output: AVCaptureOutput, | |
didOutputMetadataObjects: List<*>, | |
fromConnection: AVCaptureConnection | |
) { | |
didOutputMetadataObjects.firstOrNull()?.let { metadataObject -> | |
val readableObject = | |
metadataObject as? AVMetadataMachineReadableCodeObject | |
val code = readableObject?.stringValue ?: "" | |
AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) | |
onQrCodeScanned(code) | |
captureSession.stopRunning() | |
} | |
} | |
}, queue = dispatch_get_main_queue()) | |
metadataOutput.metadataObjectTypes = metadataOutput.availableMetadataObjectTypes() | |
} | |
} | |
} | |
val cameraPreviewLayer = remember { | |
AVCaptureVideoPreviewLayer(session = captureSession) | |
} | |
DisposableEffect(Unit) { | |
class OrientationListener : NSObject() { | |
@Suppress("UNUSED_PARAMETER") | |
@ObjCAction | |
fun orientationDidChange(arg: NSNotification) { | |
val cameraConnection = cameraPreviewLayer.connection | |
if (cameraConnection != null) { | |
actualOrientation = when (UIDevice.currentDevice.orientation) { | |
UIDeviceOrientation.UIDeviceOrientationPortrait -> | |
AVCaptureVideoOrientationPortrait | |
UIDeviceOrientation.UIDeviceOrientationLandscapeLeft -> | |
AVCaptureVideoOrientationLandscapeRight | |
UIDeviceOrientation.UIDeviceOrientationLandscapeRight -> | |
AVCaptureVideoOrientationLandscapeLeft | |
UIDeviceOrientation.UIDeviceOrientationPortraitUpsideDown -> | |
AVCaptureVideoOrientationPortrait | |
else -> cameraConnection.videoOrientation | |
} | |
cameraConnection.videoOrientation = actualOrientation | |
} | |
capturePhotoOutput.connectionWithMediaType(AVMediaTypeVideo) | |
?.videoOrientation = actualOrientation | |
} | |
} | |
val listener = OrientationListener() | |
val notificationName = platform.UIKit.UIDeviceOrientationDidChangeNotification | |
NSNotificationCenter.defaultCenter.addObserver( | |
observer = listener, | |
selector = NSSelectorFromString( | |
OrientationListener::orientationDidChange.name + ":" | |
), | |
name = notificationName, | |
`object` = null | |
) | |
onDispose { | |
NSNotificationCenter.defaultCenter.removeObserver( | |
observer = listener, | |
name = notificationName, | |
`object` = null | |
) | |
} | |
} | |
UIKitView( | |
modifier = Modifier.fillMaxSize(), | |
background = Color.Black, | |
factory = { | |
val cameraContainer = UIView() | |
cameraContainer.layer.addSublayer(cameraPreviewLayer) | |
cameraPreviewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill | |
captureSession.startRunning() | |
cameraContainer | |
}, | |
onResize = { view: UIView, rect: CValue<CGRect> -> | |
CATransaction.begin() | |
CATransaction.setValue(true, kCATransactionDisableActions) | |
view.layer.setFrame(rect) | |
cameraPreviewLayer.setFrame(rect) | |
CATransaction.commit() | |
}, | |
) | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment