Last active
November 21, 2024 20:45
-
-
Save oianmol/77b84e498ca0210632ad2f3523c08752 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) | |
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() | |
} | |
} | |
} |
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.runtime.Composable | |
import androidx.compose.ui.Modifier | |
@composable | |
expect fun QrScannerScreen(modifier: Modifier, onQrCodeScanned: (String) -> Unit) |
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() | |
}, | |
) | |
} | |
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.
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
@abbasalim
replacing captureSession.startRunning() with GlobalScope.launch(Dispatchers.IO) { captureSession.startRunning() } doesn't do anything compared when it was just captureSession.startRunning()
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.