From c332dc67b5b630bdb68804896409de2353fab918 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Tue, 18 Nov 2025 21:43:40 +0100 Subject: [PATCH 1/3] implement torch for android --- camerautils/build.gradle.kts | 35 ++++++++ .../androidMain/kotlin/AndroidCameraUtils.kt | 29 +++++++ .../androidMain/kotlin/CameraUtils.android.kt | 13 +++ .../src/commonMain/kotlin/CameraPosition.kt | 0 .../src/commonMain/kotlin/CameraUtils.kt | 10 +++ .../src/iosMain/kotlin/CameraUtils.ios.kt | 8 ++ .../src/jvmMain/kotlin/CameraUtils.jvm.kt | 9 ++ .../shared/src/commonMain/kotlin/MainView.kt | 45 ++++++++-- scanner/build.gradle.kts | 1 + scanner/src/androidMain/kotlin/CameraView.kt | 84 +++++++++++++++++++ .../src/androidMain/kotlin/Scanner.android.kt | 3 +- scanner/src/androidMain/kotlin/ScannerView.kt | 65 -------------- scanner/src/commonMain/kotlin/Scanner.kt | 8 +- scanner/src/iosMain/kotlin/Scanner.ios.kt | 3 +- scanner/src/jvmMain/kotlin/Scanner.jvm.kt | 3 +- settings.gradle.kts | 1 + 16 files changed, 240 insertions(+), 77 deletions(-) create mode 100644 camerautils/build.gradle.kts create mode 100644 camerautils/src/androidMain/kotlin/AndroidCameraUtils.kt create mode 100644 camerautils/src/androidMain/kotlin/CameraUtils.android.kt rename {scanner => camerautils}/src/commonMain/kotlin/CameraPosition.kt (100%) create mode 100644 camerautils/src/commonMain/kotlin/CameraUtils.kt create mode 100644 camerautils/src/iosMain/kotlin/CameraUtils.ios.kt create mode 100644 camerautils/src/jvmMain/kotlin/CameraUtils.jvm.kt create mode 100644 scanner/src/androidMain/kotlin/CameraView.kt delete mode 100644 scanner/src/androidMain/kotlin/ScannerView.kt diff --git a/camerautils/build.gradle.kts b/camerautils/build.gradle.kts new file mode 100644 index 0000000..c696a01 --- /dev/null +++ b/camerautils/build.gradle.kts @@ -0,0 +1,35 @@ +import org.publicvalue.convention.config.configureIosTargets + +plugins { + id("org.publicvalue.convention.android.library") + id("org.publicvalue.convention.kotlin.multiplatform.mobile") + id("org.publicvalue.convention.centralPublish") + id("org.publicvalue.convention.compose.multiplatform") +} + +description = "Compose Multiplatform Camera Utilities for Android/iOS" + +kotlin { + configureIosTargets() + jvm() + + sourceSets { + commonMain { + dependencies { + implementation(compose.runtime) + implementation(compose.foundation) + } + } + + androidMain { + dependencies { + implementation(libs.androidx.camera.camera2) + } + } + + jvmMain { + dependencies { + } + } + } +} \ No newline at end of file diff --git a/camerautils/src/androidMain/kotlin/AndroidCameraUtils.kt b/camerautils/src/androidMain/kotlin/AndroidCameraUtils.kt new file mode 100644 index 0000000..cbec403 --- /dev/null +++ b/camerautils/src/androidMain/kotlin/AndroidCameraUtils.kt @@ -0,0 +1,29 @@ +package org.publicvalue.multiplatform.qrcode + +import android.content.Context +import android.hardware.camera2.CameraCharacteristics +import android.hardware.camera2.CameraManager +import androidx.core.content.ContextCompat + +class AndroidCameraUtils(context: Context) : CameraUtils { + private val cameraManager = ContextCompat.getSystemService(context,CameraManager::class.java) + + override fun setTorchMode( + cameraPosition: CameraPosition, + value: Boolean + ): Boolean { + val cameraId = cameraManager?.cameraIdList?.find { + cameraManager.getCameraCharacteristics(it).get(CameraCharacteristics.LENS_FACING) == cameraPosition.toCharacteristics() + } + if (cameraId == null) { + return false + } + cameraId.let { cameraManager.setTorchMode(it, value) } + return true + } +} + +fun CameraPosition.toCharacteristics() = when(this) { + CameraPosition.FRONT -> CameraCharacteristics.LENS_FACING_FRONT + CameraPosition.BACK -> CameraCharacteristics.LENS_FACING_BACK +} diff --git a/camerautils/src/androidMain/kotlin/CameraUtils.android.kt b/camerautils/src/androidMain/kotlin/CameraUtils.android.kt new file mode 100644 index 0000000..5d17ec1 --- /dev/null +++ b/camerautils/src/androidMain/kotlin/CameraUtils.android.kt @@ -0,0 +1,13 @@ +package org.publicvalue.multiplatform.qrcode + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext + +@Composable +actual fun rememberCameraUtils(): CameraUtils { + val context = LocalContext.current + return remember(context) { + AndroidCameraUtils(context) + } +} \ No newline at end of file diff --git a/scanner/src/commonMain/kotlin/CameraPosition.kt b/camerautils/src/commonMain/kotlin/CameraPosition.kt similarity index 100% rename from scanner/src/commonMain/kotlin/CameraPosition.kt rename to camerautils/src/commonMain/kotlin/CameraPosition.kt diff --git a/camerautils/src/commonMain/kotlin/CameraUtils.kt b/camerautils/src/commonMain/kotlin/CameraUtils.kt new file mode 100644 index 0000000..61ac44b --- /dev/null +++ b/camerautils/src/commonMain/kotlin/CameraUtils.kt @@ -0,0 +1,10 @@ +package org.publicvalue.multiplatform.qrcode + +import androidx.compose.runtime.Composable + +interface CameraUtils { + fun setTorchMode(cameraPosition: CameraPosition, value: Boolean): Boolean +} + +@Composable +expect fun rememberCameraUtils(): CameraUtils \ No newline at end of file diff --git a/camerautils/src/iosMain/kotlin/CameraUtils.ios.kt b/camerautils/src/iosMain/kotlin/CameraUtils.ios.kt new file mode 100644 index 0000000..b7d6f26 --- /dev/null +++ b/camerautils/src/iosMain/kotlin/CameraUtils.ios.kt @@ -0,0 +1,8 @@ +package org.publicvalue.multiplatform.qrcode + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberCameraUtils(): CameraUtils { + TODO("Not yet implemented") +} \ No newline at end of file diff --git a/camerautils/src/jvmMain/kotlin/CameraUtils.jvm.kt b/camerautils/src/jvmMain/kotlin/CameraUtils.jvm.kt new file mode 100644 index 0000000..2d53037 --- /dev/null +++ b/camerautils/src/jvmMain/kotlin/CameraUtils.jvm.kt @@ -0,0 +1,9 @@ +package org.publicvalue.multiplatform.qrcode + +import androidx.compose.runtime.Composable + +@Composable +actual fun rememberCameraUtils(): CameraUtils { + println("CameraUtils not implemented on JVM") + return object : CameraUtils {} +} \ No newline at end of file diff --git a/sample-app/shared/src/commonMain/kotlin/MainView.kt b/sample-app/shared/src/commonMain/kotlin/MainView.kt index 92e5e28..1a13a8a 100644 --- a/sample-app/shared/src/commonMain/kotlin/MainView.kt +++ b/sample-app/shared/src/commonMain/kotlin/MainView.kt @@ -1,12 +1,15 @@ package org.publicvalue.multiplatform.qrcode.sample +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -14,6 +17,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import kotlinx.coroutines.Job @@ -25,6 +29,7 @@ import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeMark import kotlin.time.TimeSource +import org.publicvalue.multiplatform.qrcode.rememberCameraUtils @Composable fun MainView() { @@ -38,11 +43,36 @@ fun MainView() { ) { Column(modifier = Modifier.padding(it)) { Text("Scan QR-Code below") - var scannerVisible by remember {mutableStateOf(false)} - Button(onClick = { - scannerVisible = !scannerVisible - }) { - Text("Toggle scanner (visible: $scannerVisible)") + var scannerVisible by remember { mutableStateOf(false) } + var enableTorch by remember { mutableStateOf(false) } + + val cameraUtils = rememberCameraUtils() + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Toggle scanner") + Switch( + checked = scannerVisible, + onCheckedChange = { + scannerVisible = it + } + ) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Toggle torch") + Switch( + checked = enableTorch, + onCheckedChange = { + enableTorch = it + if (!scannerVisible) { + cameraUtils.setTorchMode(CameraPosition.BACK, it) + } + } + ) + } } var lastCode by remember { mutableStateOf(null)} var snackbarJob by remember { mutableStateOf(null)} @@ -68,7 +98,8 @@ fun MainView() { false // continue scanning }, types = listOf(CodeType.QR), - cameraPosition = CameraPosition.BACK + cameraPosition = CameraPosition.BACK, + enableTorch = enableTorch, ) } } diff --git a/scanner/build.gradle.kts b/scanner/build.gradle.kts index 00b82c1..690e604 100644 --- a/scanner/build.gradle.kts +++ b/scanner/build.gradle.kts @@ -19,6 +19,7 @@ kotlin { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) + api(projects.camerautils) } } diff --git a/scanner/src/androidMain/kotlin/CameraView.kt b/scanner/src/androidMain/kotlin/CameraView.kt new file mode 100644 index 0000000..c1c4bee --- /dev/null +++ b/scanner/src/androidMain/kotlin/CameraView.kt @@ -0,0 +1,84 @@ +package org.publicvalue.multiplatform.qrcode + +import android.util.Log +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.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.LocalLifecycleOwner + +@Composable +fun CameraView( + modifier: Modifier = Modifier, + analyzer: BarcodeAnalyzer, + cameraPosition: CameraPosition, + enableTorch: Boolean, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraProviderFuture = remember(context) { + ProcessCameraProvider.getInstance(context) + } + + val preview = remember { Preview.Builder().build() } + val selector = remember(cameraPosition) { + CameraSelector.Builder() + .requireLensFacing(cameraPosition.toSelector()) + .build() + } + + val imageAnalysis = remember(context, analyzer) { + ImageAnalysis.Builder() + .build() + .also { it.setAnalyzer( ContextCompat.getMainExecutor(context), analyzer ) } + } + + val camera = remember(cameraProviderFuture, imageAnalysis) { + runCatching { + cameraProviderFuture.get().unbindAll() + val camera1 = cameraProviderFuture.get().bindToLifecycle( + lifecycleOwner, + selector, + preview, + imageAnalysis + ) + camera1 + }.onFailure { + Log.e("CAMERA", "Camera bind error ${it.localizedMessage}", it) + } + } + + LaunchedEffect(camera, enableTorch) { + camera.getOrNull()?.cameraControl?.enableTorch(enableTorch) + } + + DisposableEffect(cameraProviderFuture) { + onDispose { + cameraProviderFuture.get().unbindAll() + } + } + + AndroidView( + modifier = modifier.fillMaxSize(), + factory = { context -> + val previewView = PreviewView(context) + preview.setSurfaceProvider(previewView.surfaceProvider) + previewView + }, + ) +} + +fun CameraPosition.toSelector() = when(this) { + CameraPosition.FRONT -> CameraSelector.LENS_FACING_FRONT + CameraPosition.BACK -> CameraSelector.LENS_FACING_BACK +} \ 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 2bbab8f..a0d3393 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, + enableTorch: Boolean, ) { val analyzer = remember() { BarcodeAnalyzer(types.toFormat(), onScanned) } - CameraView(modifier, analyzer, cameraPosition) + CameraView(modifier, analyzer, cameraPosition, enableTorch) } @OptIn(ExperimentalPermissionsApi::class) diff --git a/scanner/src/androidMain/kotlin/ScannerView.kt b/scanner/src/androidMain/kotlin/ScannerView.kt deleted file mode 100644 index 9b5c682..0000000 --- a/scanner/src/androidMain/kotlin/ScannerView.kt +++ /dev/null @@ -1,65 +0,0 @@ -package org.publicvalue.multiplatform.qrcode - -import android.util.Log -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.layout.fillMaxSize -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.content.ContextCompat -import androidx.lifecycle.compose.LocalLifecycleOwner - -@Composable -fun CameraView( - modifier: Modifier = Modifier, - analyzer: BarcodeAnalyzer, - cameraPosition: CameraPosition -) { - 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) - } - } - .build() - - preview.setSurfaceProvider(previewView.surfaceProvider) - - 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) - } - previewView - } - ) -} \ No newline at end of file diff --git a/scanner/src/commonMain/kotlin/Scanner.kt b/scanner/src/commonMain/kotlin/Scanner.kt index ca0ad75..69d4268 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, + enableTorch: Boolean, ) /** @@ -43,6 +44,7 @@ fun ScannerWithPermissions( onScanned: (String) -> Boolean, types: List, cameraPosition: CameraPosition = CameraPosition.BACK, + enableTorch: Boolean, permissionText: String = "Camera is required for QR Code scanning", openSettingsLabel: String = "Open Settings", ) { @@ -51,6 +53,7 @@ fun ScannerWithPermissions( onScanned = onScanned, types = types, cameraPosition = cameraPosition, + enableTorch = enableTorch, permissionDeniedContent = { permissionState -> Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) { Text( @@ -80,6 +83,7 @@ fun ScannerWithPermissions( onScanned: (String) -> Boolean, types: List, cameraPosition: CameraPosition, + enableTorch: Boolean, permissionDeniedContent: @Composable (CameraPermissionState) -> Unit, ) { val permissionState = rememberCameraPermissionState() @@ -91,7 +95,7 @@ fun ScannerWithPermissions( } if (permissionState.status == CameraPermissionStatus.Granted) { - Scanner(modifier, types = types, onScanned = onScanned, cameraPosition = cameraPosition) + Scanner(modifier, types = types, onScanned = onScanned, cameraPosition = cameraPosition, enableTorch = enableTorch) } else { permissionDeniedContent(permissionState) } diff --git a/scanner/src/iosMain/kotlin/Scanner.ios.kt b/scanner/src/iosMain/kotlin/Scanner.ios.kt index 9d2398d..124504b 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, + enableTorch: Boolean, // TODO ) { UiScannerView( modifier = modifier, diff --git a/scanner/src/jvmMain/kotlin/Scanner.jvm.kt b/scanner/src/jvmMain/kotlin/Scanner.jvm.kt index 67035d1..04d9d42 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, + enableTorch: Boolean, ) { Text("Scanner not implemented for JVM") } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 8f6133e..91fe53c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,6 +20,7 @@ dependencyResolutionManagement { rootProject.name="EasyQRScan" include(":scanner") +include(":camerautils") include(":sample-app:shared") include(":sample-app:android-app") From 2de99a047dac023e6f246cedee6cf675a41a3c14 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Thu, 20 Nov 2025 18:34:54 +0100 Subject: [PATCH 2/3] implement torch for ios --- .../kotlin/AVCaptureDevice.getDevice.kt | 21 ++++++++ .../src/iosMain/kotlin/CameraUtils.ios.kt | 2 +- .../src/iosMain/kotlin/IosCameraUtils.kt | 47 +++++++++++++++++ .../src/jvmMain/kotlin/CameraUtils.jvm.kt | 11 +++- .../shared/src/commonMain/kotlin/MainView.kt | 2 +- scanner/src/iosMain/kotlin/Scanner.ios.kt | 22 +++++++- scanner/src/iosMain/kotlin/ScannerView.kt | 50 ++++--------------- 7 files changed, 108 insertions(+), 47 deletions(-) create mode 100644 camerautils/src/iosMain/kotlin/AVCaptureDevice.getDevice.kt create mode 100644 camerautils/src/iosMain/kotlin/IosCameraUtils.kt diff --git a/camerautils/src/iosMain/kotlin/AVCaptureDevice.getDevice.kt b/camerautils/src/iosMain/kotlin/AVCaptureDevice.getDevice.kt new file mode 100644 index 0000000..1850b90 --- /dev/null +++ b/camerautils/src/iosMain/kotlin/AVCaptureDevice.getDevice.kt @@ -0,0 +1,21 @@ +package org.publicvalue.multiplatform.qrcode + +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVCaptureDevicePositionBack +import platform.AVFoundation.AVCaptureDevicePositionFront +import platform.AVFoundation.AVMediaTypeVideo +import platform.AVFoundation.position + +fun AVCaptureDevice.Companion.getDevice(cameraPosition: CameraPosition): AVCaptureDevice? { + val devices = AVCaptureDevice.devicesWithMediaType(AVMediaTypeVideo).map { it as AVCaptureDevice } + + return 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) + } +} \ No newline at end of file diff --git a/camerautils/src/iosMain/kotlin/CameraUtils.ios.kt b/camerautils/src/iosMain/kotlin/CameraUtils.ios.kt index b7d6f26..ee48a77 100644 --- a/camerautils/src/iosMain/kotlin/CameraUtils.ios.kt +++ b/camerautils/src/iosMain/kotlin/CameraUtils.ios.kt @@ -4,5 +4,5 @@ import androidx.compose.runtime.Composable @Composable actual fun rememberCameraUtils(): CameraUtils { - TODO("Not yet implemented") + return IosCameraUtils() } \ No newline at end of file diff --git a/camerautils/src/iosMain/kotlin/IosCameraUtils.kt b/camerautils/src/iosMain/kotlin/IosCameraUtils.kt new file mode 100644 index 0000000..8dab646 --- /dev/null +++ b/camerautils/src/iosMain/kotlin/IosCameraUtils.kt @@ -0,0 +1,47 @@ +package org.publicvalue.multiplatform.qrcode + +import kotlinx.cinterop.BetaInteropApi +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.ObjCObjectVar +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.value +import platform.AVFoundation.AVCaptureDevice +import platform.AVFoundation.AVCaptureTorchModeOff +import platform.AVFoundation.AVCaptureTorchModeOn +import platform.AVFoundation.hasTorch +import platform.AVFoundation.setTorchMode +import platform.Foundation.NSError + +class IosCameraUtils: CameraUtils { + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) + override fun setTorchMode( + cameraPosition: CameraPosition, + value: Boolean + ): Boolean { + val device = AVCaptureDevice.getDevice(cameraPosition) + if (device == null || !device.hasTorch()) { + return false + } + + memScoped { + val error = alloc>() + device.lockForConfiguration(error.ptr) + if (error.value != null) { + throw Exception("Error locking device: ${error.value}") + } + } + device.setTorchMode( + if (value) { + AVCaptureTorchModeOn + } else { + AVCaptureTorchModeOff + } + ) + + device.unlockForConfiguration() + return true + } + +} diff --git a/camerautils/src/jvmMain/kotlin/CameraUtils.jvm.kt b/camerautils/src/jvmMain/kotlin/CameraUtils.jvm.kt index 2d53037..95bf4f3 100644 --- a/camerautils/src/jvmMain/kotlin/CameraUtils.jvm.kt +++ b/camerautils/src/jvmMain/kotlin/CameraUtils.jvm.kt @@ -4,6 +4,13 @@ import androidx.compose.runtime.Composable @Composable actual fun rememberCameraUtils(): CameraUtils { - println("CameraUtils not implemented on JVM") - return object : CameraUtils {} + return object : CameraUtils { + override fun setTorchMode( + cameraPosition: CameraPosition, + value: Boolean + ): Boolean { + println("CameraUtils not implemented on JVM") + return false + } + } } \ No newline at end of file diff --git a/sample-app/shared/src/commonMain/kotlin/MainView.kt b/sample-app/shared/src/commonMain/kotlin/MainView.kt index 1a13a8a..0c02f2b 100644 --- a/sample-app/shared/src/commonMain/kotlin/MainView.kt +++ b/sample-app/shared/src/commonMain/kotlin/MainView.kt @@ -67,7 +67,7 @@ fun MainView() { checked = enableTorch, onCheckedChange = { enableTorch = it - if (!scannerVisible) { + if (!scannerVisible) { // android needs separate handling when capturing video cameraUtils.setTorchMode(CameraPosition.BACK, it) } } diff --git a/scanner/src/iosMain/kotlin/Scanner.ios.kt b/scanner/src/iosMain/kotlin/Scanner.ios.kt index 124504b..46ae491 100644 --- a/scanner/src/iosMain/kotlin/Scanner.ios.kt +++ b/scanner/src/iosMain/kotlin/Scanner.ios.kt @@ -1,6 +1,8 @@ package org.publicvalue.multiplatform.qrcode import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -21,15 +23,31 @@ actual fun Scanner( onScanned: (String) -> Boolean, // return true to abort scanning types: List, cameraPosition: CameraPosition, - enableTorch: Boolean, // TODO + enableTorch: Boolean, ) { + var started by remember { mutableStateOf(false) } + val cameraUtils = rememberCameraUtils() + LaunchedEffect(enableTorch, started) { + if (started) { + cameraUtils.setTorchMode(cameraPosition, enableTorch) + } + } + DisposableEffect(Unit) { + onDispose { + cameraUtils.setTorchMode(cameraPosition, false) + } + } UiScannerView( modifier = modifier, onScanned = { onScanned(it) }, allowedMetadataTypes = types.toFormat(), - cameraPosition = cameraPosition + cameraPosition = cameraPosition, + onStarted = { + cameraUtils.setTorchMode(cameraPosition, enableTorch) + started = true + } ) } diff --git a/scanner/src/iosMain/kotlin/ScannerView.kt b/scanner/src/iosMain/kotlin/ScannerView.kt index 8459e41..7c9fb14 100644 --- a/scanner/src/iosMain/kotlin/ScannerView.kt +++ b/scanner/src/iosMain/kotlin/ScannerView.kt @@ -15,15 +15,12 @@ import kotlinx.cinterop.alloc import kotlinx.cinterop.cValue import kotlinx.cinterop.memScoped import kotlinx.cinterop.ptr -import kotlinx.cinterop.useContents import kotlinx.cinterop.value import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch 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.AVCaptureSession @@ -33,10 +30,8 @@ import platform.AVFoundation.AVCaptureVideoOrientationPortrait import platform.AVFoundation.AVCaptureVideoOrientationPortraitUpsideDown import platform.AVFoundation.AVCaptureVideoPreviewLayer import platform.AVFoundation.AVLayerVideoGravityResizeAspectFill -import platform.AVFoundation.AVMediaTypeVideo import platform.AVFoundation.AVMetadataMachineReadableCodeObject import platform.AVFoundation.AVMetadataObjectType -import platform.AVFoundation.position import platform.CoreGraphics.CGRect import platform.CoreGraphics.CGRectZero import platform.Foundation.NSError @@ -55,12 +50,14 @@ fun UiScannerView( // https://developer.apple.com/documentation/avfoundation/avmetadataobjecttype?language=objc allowedMetadataTypes: List, cameraPosition: CameraPosition, - onScanned: (String) -> Boolean + onScanned: (String) -> Boolean, + onStarted: () -> Unit, ) { val coordinator = remember { ScannerCameraCoordinator( onScanned = onScanned, - cameraPosition = cameraPosition + cameraPosition = cameraPosition, + onStarted = onStarted ) } @@ -73,6 +70,8 @@ fun UiScannerView( onDispose { listener.unregister() + // stop capture + coordinator.captureSession.stopRunning() } } @@ -80,7 +79,6 @@ fun UiScannerView( modifier = modifier.fillMaxSize(), factory = { val previewContainer = ScannerPreviewView(coordinator) - println("Calling prepare") coordinator.prepare(previewContainer.layer, allowedMetadataTypes) previewContainer }, @@ -89,14 +87,6 @@ fun UiScannerView( isNativeAccessibilityEnabled = true, ) ) - -// DisposableEffect(Unit) { -// onDispose { -// // stop capture -// coordinator. -// } -// } - } @OptIn(ExperimentalForeignApi::class) @@ -116,6 +106,7 @@ class ScannerPreviewView(private val coordinator: ScannerCameraCoordinator): UIV @OptIn(ExperimentalForeignApi::class) class ScannerCameraCoordinator( val onScanned: (String) -> Boolean, + val onStarted: () -> Unit, val cameraPosition: CameraPosition ): AVCaptureMetadataOutputObjectsDelegateProtocol, NSObject() { @@ -125,24 +116,13 @@ class ScannerCameraCoordinator( @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) fun prepare(layer: CALayer, allowedMetadataTypes: List) { captureSession = AVCaptureSession() - 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 device = AVCaptureDevice.getDevice(cameraPosition) if (device == null) { println("Device has no camera") return } - println("Initializing video input") val videoInput = memScoped { val error: ObjCObjectVar = alloc>() val videoInput = AVCaptureDeviceInput(device = device, error = error.ptr) @@ -154,7 +134,6 @@ class ScannerCameraCoordinator( } } - println("Adding video input") if (videoInput != null && captureSession.canAddInput(videoInput)) { captureSession.addInput(videoInput) } else { @@ -164,7 +143,6 @@ class ScannerCameraCoordinator( val metadataOutput = AVCaptureMetadataOutput() - println("Adding metadata output") if (captureSession.canAddOutput(metadataOutput)) { captureSession.addOutput(metadataOutput) @@ -174,26 +152,16 @@ class ScannerCameraCoordinator( println("Could not add output") return } - println("Adding preview layer") previewLayer = AVCaptureVideoPreviewLayer(session = captureSession).also { it.frame = layer.bounds it.videoGravity = AVLayerVideoGravityResizeAspectFill - println("Set orientation") setCurrentOrientation(newOrientation = UIDevice.currentDevice.orientation) - println("Adding sublayer") - layer.bounds.useContents { - println("Bounds: ${this.size.width}x${this.size.height}") - - } - layer.frame.useContents { - println("Frame: ${this.size.width}x${this.size.height}") - } layer.addSublayer(it) } - println("Launching capture session") GlobalScope.launch(Dispatchers.Default) { captureSession.startRunning() + onStarted() } } From 19abff5998eee0b5fb6423ecb9ebbdf99cfcba65 Mon Sep 17 00:00:00 2001 From: Julian Kalinowski Date: Thu, 20 Nov 2025 22:03:10 +0100 Subject: [PATCH 3/3] update readme --- README.md | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index c715acf..4e4ca35 100644 --- a/README.md +++ b/README.md @@ -15,24 +15,23 @@ Supported Compose version: | 1.6.x | 0.1.0+ | | 1.7 | 0.2 - 0.3 | | 1.8 | 0.4.0 | -| 1.9 | 0.5.0 | +| 1.9 | 0.5.0 - 0.6.x | # Dependency Add the dependency to your commonMain sourceSet (KMP) / Android dependencies (android only): ```kotlin -implementation("io.github.kalinjul.easyqrscan:scanner:0.5.0") +implementation("io.github.kalinjul.easyqrscan:scanner:0.6.0") ``` Or, for your libs.versions.toml: ```toml [versions] -easyqrscan = "0.5.0" +easyqrscan = "0.6.0" [libraries] easyqrscan = { module = "io.github.kalinjul.easyqrscan:scanner", version.ref = "easyqrscan" } ``` -# Usage -## Camera Permissions +# Setup Camera Permissions Include this at root level in your AndroidManifest.xml: ```xml @@ -43,10 +42,18 @@ Include this at root level in your AndroidManifest.xml: Add this key to the Info.plist in your xcode project: ```NSCameraUsageDescription``` and provide a description as value -## Compose UI +# Usage + +The scanner is included by calling a single composable function ```Scanner()``` or ```ScannerWithPermissions```: + ```kotlin // basic permission handling included: -ScannerWithPermissions(onScanned = { println(it); true }, types = listOf(CodeType.QR)) +ScannerWithPermissions( + onScanned = { println(it); true }, // return true to disable the scanner, false to continue scanning + types = listOf(CodeType.QR), + cameraPosition = CameraPosition.BACK, + enableTorch = false // toggle this to enable/disable the flashlight +) // or, if you handle permissions yourself: Scanner(onScanned = { println(it); true }, types = listOf(CodeType.QR))