diff --git a/.gitignore b/.gitignore index 40d4baf..eee005d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ build/ .gradle UserInterfaceState.xcuserstate -.kotlin \ No newline at end of file +.kotlin +/local.properties diff --git a/sample-app/shared/src/commonMain/kotlin/MainView.kt b/sample-app/shared/src/commonMain/kotlin/MainView.kt index 8d3add2..d1fb828 100644 --- a/sample-app/shared/src/commonMain/kotlin/MainView.kt +++ b/sample-app/shared/src/commonMain/kotlin/MainView.kt @@ -17,6 +17,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import org.publicvalue.multiplatform.qrcode.CameraOrientation import org.publicvalue.multiplatform.qrcode.CameraPosition import org.publicvalue.multiplatform.qrcode.CodeType import org.publicvalue.multiplatform.qrcode.ScannerWithPermissions @@ -34,11 +35,17 @@ fun MainView() { Column(modifier = Modifier.padding(it)) { Text("Scan QR-Code below") var scannerVisible by remember {mutableStateOf(false)} + var cameraPosition by remember { mutableStateOf( CameraPosition.BACK)} Button(onClick = { scannerVisible = !scannerVisible }) { Text("Toggle scanner (visible: $scannerVisible)") } + Button(onClick = { + cameraPosition = if(cameraPosition== CameraPosition.BACK) CameraPosition.FRONT else CameraPosition.BACK + }) { + Text("Toggle camera (position: $cameraPosition)") + } if (scannerVisible) { val scope = rememberCoroutineScope() ScannerWithPermissions( @@ -50,9 +57,10 @@ fun MainView() { false // continue scanning }, types = listOf(CodeType.QR), - cameraPosition = CameraPosition.BACK + cameraPosition = cameraPosition, + defaultOrientation = CameraOrientation.LANDSCAPE ) } } } -} \ No newline at end of file +} diff --git a/scanner/src/androidMain/kotlin/Scanner.android.kt b/scanner/src/androidMain/kotlin/Scanner.android.kt index f01156d..a27d78a 100644 --- a/scanner/src/androidMain/kotlin/Scanner.android.kt +++ b/scanner/src/androidMain/kotlin/Scanner.android.kt @@ -20,11 +20,12 @@ actual fun Scanner( onScanned: (String) -> Boolean, types: List, cameraPosition: CameraPosition, + defaultOrientation: CameraOrientation? ) { val analyzer = remember() { BarcodeAnalyzer(types.toFormat(), onScanned) } - CameraView(modifier, analyzer, cameraPosition) + CameraView(modifier, analyzer, cameraPosition, defaultOrientation) } @OptIn(ExperimentalPermissionsApi::class) diff --git a/scanner/src/androidMain/kotlin/ScannerView.kt b/scanner/src/androidMain/kotlin/ScannerView.kt index ed6ceb5..be1e5c9 100644 --- a/scanner/src/androidMain/kotlin/ScannerView.kt +++ b/scanner/src/androidMain/kotlin/ScannerView.kt @@ -1,7 +1,8 @@ package org.publicvalue.multiplatform.qrcode import android.util.Log -import android.widget.LinearLayout +import android.view.Surface.ROTATION_0 +import android.view.Surface.ROTATION_180 import androidx.camera.core.CameraSelector import androidx.camera.core.ImageAnalysis import androidx.camera.core.Preview @@ -9,6 +10,7 @@ import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.view.PreviewView import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -20,47 +22,54 @@ import androidx.core.content.ContextCompat fun CameraView( modifier: Modifier = Modifier, analyzer: BarcodeAnalyzer, - cameraPosition: CameraPosition + cameraPosition: CameraPosition, + defaultOrientation: CameraOrientation? ) { val localContext = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(localContext) } - AndroidView( - modifier = modifier.fillMaxSize(), - factory = { context -> - val previewView = PreviewView(context) - val preview = Preview.Builder().build() - val selector = CameraSelector.Builder() - .let { - when (cameraPosition) { - CameraPosition.FRONT -> it.requireLensFacing(CameraSelector.LENS_FACING_FRONT) - CameraPosition.BACK -> it.requireLensFacing(CameraSelector.LENS_FACING_BACK) - } + val previewView = remember { PreviewView(localContext) } + + LaunchedEffect(cameraPosition) { + val preview = when (defaultOrientation) { + CameraOrientation.LANDSCAPE -> Preview.Builder().setTargetRotation(ROTATION_180).build() + CameraOrientation.PORTRAIT -> Preview.Builder().setTargetRotation(ROTATION_0).build() + null -> Preview.Builder().build() + } + val selector = CameraSelector.Builder() + .let { + when (cameraPosition) { + CameraPosition.FRONT -> it.requireLensFacing(CameraSelector.LENS_FACING_FRONT) + CameraPosition.BACK -> it.requireLensFacing(CameraSelector.LENS_FACING_BACK) } - .build() + }.build() + preview.setSurfaceProvider(previewView.surfaceProvider) - preview.setSurfaceProvider(previewView.surfaceProvider) + val imageAnalysis = ImageAnalysis.Builder().build() + imageAnalysis.setAnalyzer( + ContextCompat.getMainExecutor(localContext), + analyzer + ) - val imageAnalysis = ImageAnalysis.Builder().build() - imageAnalysis.setAnalyzer( - ContextCompat.getMainExecutor(context), - analyzer + runCatching { + cameraProviderFuture.get().unbindAll() + cameraProviderFuture.get().bindToLifecycle( + lifecycleOwner, + selector, + preview, + imageAnalysis ) + }.onFailure { + Log.e("CAMERA", "Camera bind error ${it.localizedMessage}", it) + } + } - runCatching { - cameraProviderFuture.get().unbindAll() - cameraProviderFuture.get().bindToLifecycle( - lifecycleOwner, - selector, - preview, - imageAnalysis - ) - }.onFailure { - Log.e("CAMERA", "Camera bind error ${it.localizedMessage}", it) - } + AndroidView( + modifier = modifier.fillMaxSize(), + factory = { context -> previewView } ) -} \ No newline at end of file +} diff --git a/scanner/src/commonMain/kotlin/CameraOrientation.kt b/scanner/src/commonMain/kotlin/CameraOrientation.kt new file mode 100644 index 0000000..103ff9c --- /dev/null +++ b/scanner/src/commonMain/kotlin/CameraOrientation.kt @@ -0,0 +1,6 @@ +package org.publicvalue.multiplatform.qrcode + +enum class CameraOrientation { + LANDSCAPE, + PORTRAIT +} diff --git a/scanner/src/commonMain/kotlin/Scanner.kt b/scanner/src/commonMain/kotlin/Scanner.kt index ca0ad75..be5e9ca 100644 --- a/scanner/src/commonMain/kotlin/Scanner.kt +++ b/scanner/src/commonMain/kotlin/Scanner.kt @@ -24,7 +24,8 @@ expect fun Scanner( modifier: Modifier = Modifier, onScanned: (String) -> Boolean, types: List, - cameraPosition: CameraPosition = CameraPosition.BACK + cameraPosition: CameraPosition = CameraPosition.BACK, + defaultOrientation: CameraOrientation? = null ) /** @@ -45,6 +46,7 @@ fun ScannerWithPermissions( cameraPosition: CameraPosition = CameraPosition.BACK, permissionText: String = "Camera is required for QR Code scanning", openSettingsLabel: String = "Open Settings", + defaultOrientation: CameraOrientation? ) { ScannerWithPermissions( modifier = modifier.clipToBounds(), @@ -61,7 +63,8 @@ fun ScannerWithPermissions( Text(openSettingsLabel) } } - } + }, + defaultOrientation ) } @@ -81,6 +84,7 @@ fun ScannerWithPermissions( types: List, cameraPosition: CameraPosition, permissionDeniedContent: @Composable (CameraPermissionState) -> Unit, + defaultOrientation: CameraOrientation? ) { val permissionState = rememberCameraPermissionState() @@ -91,8 +95,8 @@ fun ScannerWithPermissions( } if (permissionState.status == CameraPermissionStatus.Granted) { - Scanner(modifier, types = types, onScanned = onScanned, cameraPosition = cameraPosition) + Scanner(modifier, types = types, onScanned = onScanned, cameraPosition = cameraPosition, defaultOrientation = defaultOrientation) } else { permissionDeniedContent(permissionState) } -} \ No newline at end of file +} diff --git a/scanner/src/iosMain/kotlin/Scanner.ios.kt b/scanner/src/iosMain/kotlin/Scanner.ios.kt index 9d2398d..2f53f2f 100644 --- a/scanner/src/iosMain/kotlin/Scanner.ios.kt +++ b/scanner/src/iosMain/kotlin/Scanner.ios.kt @@ -20,7 +20,8 @@ actual fun Scanner( modifier: Modifier, onScanned: (String) -> Boolean, // return true to abort scanning types: List, - cameraPosition: CameraPosition + cameraPosition: CameraPosition, + defaultOrientation: CameraOrientation? ) { UiScannerView( modifier = modifier, @@ -28,7 +29,8 @@ actual fun Scanner( onScanned(it) }, allowedMetadataTypes = types.toFormat(), - cameraPosition = cameraPosition + cameraPosition = cameraPosition, + defaultOrientation = defaultOrientation ) } @@ -62,4 +64,4 @@ class IosMutableCameraPermissionState: MutableCameraPermissionState() { fun getCameraPermissionStatus(): CameraPermissionStatus { val authorizationStatus = AVCaptureDevice.authorizationStatusForMediaType(AVMediaTypeVideo) return if (authorizationStatus == AVAuthorizationStatusAuthorized) CameraPermissionStatus.Granted else CameraPermissionStatus.Denied -} \ No newline at end of file +} diff --git a/scanner/src/iosMain/kotlin/ScannerView.kt b/scanner/src/iosMain/kotlin/ScannerView.kt index a2b84b1..eb75661 100644 --- a/scanner/src/iosMain/kotlin/ScannerView.kt +++ b/scanner/src/iosMain/kotlin/ScannerView.kt @@ -20,12 +20,14 @@ import kotlinx.cinterop.value import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch +import platform.AVFoundation.AVCaptureConnection import platform.AVFoundation.AVCaptureDevice import platform.AVFoundation.AVCaptureDeviceInput import platform.AVFoundation.AVCaptureDevicePositionBack import platform.AVFoundation.AVCaptureDevicePositionFront import platform.AVFoundation.AVCaptureMetadataOutput import platform.AVFoundation.AVCaptureMetadataOutputObjectsDelegateProtocol +import platform.AVFoundation.AVCaptureOutput import platform.AVFoundation.AVCaptureSession import platform.AVFoundation.AVCaptureVideoOrientationLandscapeLeft import platform.AVFoundation.AVCaptureVideoOrientationLandscapeRight @@ -55,7 +57,8 @@ fun UiScannerView( // https://developer.apple.com/documentation/avfoundation/avmetadataobjecttype?language=objc allowedMetadataTypes: List, cameraPosition: CameraPosition, - onScanned: (String) -> Boolean + onScanned: (String) -> Boolean, + defaultOrientation: CameraOrientation? = null ) { val coordinator = remember { ScannerCameraCoordinator( @@ -63,6 +66,8 @@ fun UiScannerView( cameraPosition = cameraPosition ) } + if (coordinator.cameraInitialised) + coordinator.switchCamera(cameraPosition) DisposableEffect(Unit) { val listener = OrientationListener { orientation -> @@ -81,7 +86,7 @@ fun UiScannerView( factory = { val previewContainer = ScannerPreviewView(coordinator) println("Calling prepare") - coordinator.prepare(previewContainer.layer, allowedMetadataTypes) + coordinator.prepare(previewContainer.layer, allowedMetadataTypes, defaultOrientation) previewContainer }, properties = UIKitInteropProperties( @@ -100,7 +105,7 @@ fun UiScannerView( } @OptIn(ExperimentalForeignApi::class) -class ScannerPreviewView(private val coordinator: ScannerCameraCoordinator): UIView(frame = cValue { CGRectZero }) { +class ScannerPreviewView(private val coordinator: ScannerCameraCoordinator) : UIView(frame = cValue { CGRectZero }) { @OptIn(ExperimentalForeignApi::class) override fun layoutSubviews() { super.layoutSubviews() @@ -121,29 +126,34 @@ class ScannerCameraCoordinator( private var previewLayer: AVCaptureVideoPreviewLayer? = null lateinit var captureSession: AVCaptureSession + var frontDeviceInput: AVCaptureDeviceInput? = null + var backDeviceInput: AVCaptureDeviceInput? = null + var defaultDeviceInput: AVCaptureDeviceInput? = null + lateinit var currentCameraPositon: CameraPosition + var cameraInitialised = false - @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) - fun prepare(layer: CALayer, allowedMetadataTypes: List) { - captureSession = AVCaptureSession() + private fun setupCamera() { val devices = AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo).map { it as AVCaptureDevice } - - val device = devices.firstOrNull { device -> - when(cameraPosition) { - CameraPosition.FRONT -> device.position == AVCaptureDevicePositionFront - CameraPosition.BACK -> device.position == AVCaptureDevicePositionBack - } - } ?: run { - println("Could not find camera with position: $cameraPosition, using default camera") - AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) + val frontDevice: AVCaptureDevice? = devices.firstOrNull { it.position == AVCaptureDevicePositionFront } + val backDevice = devices.firstOrNull { it.position == AVCaptureDevicePositionBack } + frontDeviceInput = frontDevice?.let { + println("Initializing front camera input") + createDeviceInput(it) } - - if (device == null) { - println("Device has no camera") - return + backDeviceInput = backDevice?.let { + println("Initializing back camera input") + createDeviceInput(it) } + if (frontDeviceInput == null && backDeviceInput == null) { + println("Initializing default camera input") + val defaultDevice = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo) + defaultDeviceInput = defaultDevice?.let { + createDeviceInput(it) + } + } + } - println("Initializing video input") - val videoInput = memScoped { + private fun createDeviceInput(device: AVCaptureDevice) = memScoped { val error: ObjCObjectVar = alloc>() val videoInput = AVCaptureDeviceInput(device = device, error = error.ptr) if (error.value != null) { @@ -154,11 +164,48 @@ class ScannerCameraCoordinator( } } - println("Adding video input") - if (videoInput != null && captureSession.canAddInput(videoInput)) { - captureSession.addInput(videoInput) + fun switchCamera(cameraPosition: CameraPosition) { + if (::currentCameraPositon.isInitialized.not() || currentCameraPositon != cameraPosition) { + println("Trying to switch to camera position to $cameraPosition") + captureSession.beginConfiguration() + val frontInput = frontDeviceInput + val backInput = backDeviceInput + val defaultInput = defaultDeviceInput + if (cameraPosition == CameraPosition.FRONT && frontInput != null) { + addInput(frontInput) + println("Switched camera position to $cameraPosition successfully") + } else if (cameraPosition == CameraPosition.BACK && backInput != null) { + addInput(backInput) + println("Switched camera position to $cameraPosition successfully") + } else if (defaultInput != null) { + println("Could not find camera with position: $cameraPosition, using default camera") + addInput(defaultInput) + } + captureSession.commitConfiguration() + currentCameraPositon = cameraPosition } else { - println("Could not add input") + println("Skipping switching of the camera position") + } + } + + private fun addInput(input: AVCaptureDeviceInput) { + captureSession.inputs.map { + captureSession.removeInput(it as AVCaptureDeviceInput) + } + if (captureSession.canAddInput(input)){ + captureSession.addInput(input) + println("Adding input:$input successfully") + } + } + + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + fun prepare(layer: CALayer, allowedMetadataTypes: List, defaultOrientation: CameraOrientation?) { + captureSession = AVCaptureSession() + println("Initializing video input") + setupCamera() + switchCamera(cameraPosition) + if (listOf(frontDeviceInput, backDeviceInput, defaultDeviceInput).all { it == null }) { + println("Device has no camera") return } @@ -167,7 +214,6 @@ class ScannerCameraCoordinator( println("Adding metadata output") if (captureSession.canAddOutput(metadataOutput)) { captureSession.addOutput(metadataOutput) - metadataOutput.setMetadataObjectsDelegate(this, queue = dispatch_get_main_queue()) metadataOutput.metadataObjectTypes = allowedMetadataTypes } else { @@ -179,6 +225,7 @@ class ScannerCameraCoordinator( it.frame = layer.bounds it.videoGravity = AVLayerVideoGravityResizeAspectFill println("Set orientation") + it.orientation = if(defaultOrientation==null || defaultOrientation != CameraOrientation.LANDSCAPE) AVCaptureVideoOrientationPortrait else AVCaptureVideoOrientationLandscapeRight setCurrentOrientation(newOrientation = UIDevice.currentDevice.orientation) println("Adding sublayer") layer.bounds.useContents { @@ -195,6 +242,7 @@ class ScannerCameraCoordinator( GlobalScope.launch(Dispatchers.Default) { captureSession.startRunning() } + cameraInitialised = true } @@ -213,7 +261,7 @@ class ScannerCameraCoordinator( } } - override fun captureOutput(output: platform.AVFoundation.AVCaptureOutput, didOutputMetadataObjects: List<*>, fromConnection: platform.AVFoundation.AVCaptureConnection) { + override fun captureOutput(output: AVCaptureOutput, didOutputMetadataObjects: List<*>, fromConnection: AVCaptureConnection) { val metadataObject = didOutputMetadataObjects.firstOrNull() as? AVMetadataMachineReadableCodeObject metadataObject?.stringValue?.let { onFound(it) } } @@ -230,4 +278,4 @@ class ScannerCameraCoordinator( fun setFrame(rect: CValue) { previewLayer?.setFrame(rect) } -} \ No newline at end of file +} diff --git a/scanner/src/jvmMain/kotlin/Scanner.jvm.kt b/scanner/src/jvmMain/kotlin/Scanner.jvm.kt index 67035d1..01695df 100644 --- a/scanner/src/jvmMain/kotlin/Scanner.jvm.kt +++ b/scanner/src/jvmMain/kotlin/Scanner.jvm.kt @@ -9,7 +9,8 @@ actual fun Scanner( modifier: Modifier, onScanned: (String) -> Boolean, types: List, - cameraPosition: CameraPosition + cameraPosition: CameraPosition, + defaultOrientation: CameraOrientation? ) { Text("Scanner not implemented for JVM") -} \ No newline at end of file +}