Skip to content

Instantly share code, notes, and snippets.

@oianmol
Last active November 21, 2024 20:45
Show Gist options
  • Save oianmol/77b84e498ca0210632ad2f3523c08752 to your computer and use it in GitHub Desktop.
Save oianmol/77b84e498ca0210632ad2f3523c08752 to your computer and use it in GitHub Desktop.
QR Code Scanner with Jetbrains Jetpack compose multiplatform!
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)
DisposableEffect(cameraProviderFuture) {
onDispose {
cameraProviderFuture.get().unbindAll()
}
}
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()
}
}
}
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@composable
expect fun QrScannerScreen(modifier: Modifier, onQrCodeScanned: (String) -> Unit)
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()
},
)
}
@NwetNwetWai
Copy link

I am facing the same issue with iOS Version 17.6.1 (iPhone 13). After 5 or 6 seconds, it can't scan QR code. Is there any update?

In iOS, I was able to run the code and scan qr even bar codes. Only thing I noticed, when I don't immediately point the camera to a qr or bar code after a few seconds then try to scan, it does nothing.

@NwetNwetWai
Copy link

Update UIKitView as per UIKitView changes in compose Multiplatform version 1.7.0-beta01.

val previewContainer = UIView()

androidx.compose.ui.viewinterop.UIKitView(
    modifier = Modifier
        .fillMaxSize()
        .onSizeChanged { size ->
            val rect = CGRectMake(
                x = 0.0,
                y = 0.0,
                width = size.width.toDouble(),
                height = size.height.toDouble()
            )
            CATransaction.begin()
            CATransaction.setValue(true, kCATransactionDisableActions)

            previewContainer.layer.frame = rect
            coordinator.setFrame(rect)

            CATransaction.commit()
        },
    factory = {
        coordinator.prepare(previewContainer.layer)
        previewContainer
    },
    update = { view ->
    }
)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment