-
-
Save oianmol/77b84e498ca0210632ad2f3523c08752 to your computer and use it in GitHub Desktop.
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() | |
}, | |
) | |
} | |
@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.
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 ->
}
)
@oianmol Thanks for the gist! When running
./gradlew build
with this code snippet, the taskcompileIosMainKotlinMetadata
fails with the following trace. However, the ios app still launches successfully when built from xcode. Any idea what could be done about this? Refactoring theQrScannerScreen.ios.kt
code doesn't seem to affect it in any way. I've tried the previous kotlin version, still the same(sorry for the length)