Skip to content

Commit

Permalink
migrate qr code scanning to raw mlkit with camerax
Browse files Browse the repository at this point in the history
  • Loading branch information
avan1235 committed Sep 26, 2023
1 parent 598e9b4 commit 5c0a704
Show file tree
Hide file tree
Showing 18 changed files with 642 additions and 168 deletions.
2 changes: 2 additions & 0 deletions androidApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
android:name="android.hardware.camera"
android:required="false"/>

<uses-permission android:name="android.permission.CAMERA"/>

<application
android:allowBackup="false"
android:fullBackupContent="false"
Expand Down
10 changes: 8 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
[versions]
accompanistPermissions = "0.32.0"
# @keep
android-compileSdk = "34"
# @keep
Expand All @@ -10,7 +11,9 @@ androidx-appcompat-appcompat = "1.6.1"
androidx-core-ktx = "1.12.0"
# @pin
androidx-crypto = "1.1.0-alpha05"
barcodeScanning = "17.2.0"
buffer = "1.3.6"
cameraX = "1.2.3"
compose = "1.5.2"
decompose = "2.2.0-compose-experimental-alpha01"
encoding-base32 = "2.0.0"
Expand All @@ -30,16 +33,19 @@ kotlinx-serialization = "1.6.0"
moko-resources = "0.23.0"
multiplatform-settings = "1.0.0"
parcelize-darwin = "0.2.2"
quickie = "1.8.0"
uri-kmp = "0.0.14"
uuid = "0.8.1"
version-catalog-update = "0.8.1"
# @keep
versionCode = "8"

[libraries]
accompanist-permissions = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanistPermissions" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity-compose" }
androidx-appcompat-appcompat = { module = "androidx.appcompat:appcompat", version.ref = "androidx-appcompat-appcompat" }
androidx-camera = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" }
androidx-cameraLifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" }
androidx-cameraPreview = { module = "androidx.camera:camera-view", version.ref = "cameraX" }
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core-ktx" }
androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "androidx-crypto" }
buffer = { module = "com.ditchoom:buffer", version.ref = "buffer" }
Expand All @@ -59,11 +65,11 @@ kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutine
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
mlkit-barcodeScanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "barcodeScanning" }
moko-resoures = { module = "dev.icerock.moko:resources", version.ref = "moko-resources" }
moko-resoures-compose = { module = "dev.icerock.moko:resources-compose", version.ref = "moko-resources" }
multiplatform-settings = { module = "com.russhwolf:multiplatform-settings", version.ref = "multiplatform-settings" }
parcelize-darwinRuntime = { module = "com.arkivanov.parcelize.darwin:runtime", version.ref = "parcelize-darwin" }
quickie-bundled = { module = "io.github.g00fy2.quickie:quickie-bundled", version.ref = "quickie" }
uriKmp = { module = "com.eygraber:uri-kmp", version.ref = "uri-kmp" }
uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }

Expand Down
9 changes: 8 additions & 1 deletion shared/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ kotlin {
optIn("androidx.compose.foundation.layout.ExperimentalLayoutApi")
optIn("androidx.compose.material3.ExperimentalMaterial3Api")
optIn("com.arkivanov.decompose.ExperimentalDecomposeApi")
optIn("com.google.accompanist.permissions.ExperimentalPermissionsApi")
}
}
val commonMain by getting {
Expand Down Expand Up @@ -94,9 +95,15 @@ kotlin {
api(libs.androidx.appcompat.appcompat)
api(libs.androidx.core.ktx)

implementation(libs.quickie.bundled)
implementation(libs.androidx.camera)
implementation(libs.androidx.cameraLifecycle)
implementation(libs.androidx.cameraPreview)

implementation(libs.mlkit.barcodeScanning)
implementation(libs.androidx.security.crypto)

implementation(libs.accompanist.permissions)

runtimeOnly(libs.kotlinx.coroutines.android)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package ml.dev.kotlin.openotp.qr


import android.media.Image
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.google.mlkit.vision.barcode.BarcodeScanner
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.lang.System.currentTimeMillis

internal class QRCodeAnalyzer(
private val handle: (QRResult) -> Unit,
private val onPassCompleted: (failureOccurred: Boolean) -> Unit,
) : ImageAnalysis.Analyzer {

private val barcodeScanner: BarcodeScanner? by lazy {
try {
BarcodeScanning.getClient(
BarcodeScannerOptions.Builder()
.setBarcodeFormats(Barcode.FORMAT_QR_CODE)
.build()
)
} catch (e: Exception) {
handleSynchronized(QRResult.QRError(e))
null
}
}

@Volatile
private var failureTimestamp: Long = NO_FAILURE_FLAG

@ExperimentalGetImage
override fun analyze(imageProxy: ImageProxy) {
val image = imageProxy.image ?: return

if (failureTimestamp.isFailure && currentTimeMillis() - failureTimestamp < FAILURE_THROTTLE_MILLIS) {
return imageProxy.close()
}
failureTimestamp = NO_FAILURE_FLAG

val scanner = barcodeScanner ?: return
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
scanner
.process(image.toInputImage(rotationDegrees))
.addOnSuccessListener { onNonEmptySuccess(it) }
.addOnFailureListener {
failureTimestamp = currentTimeMillis()
handleSynchronized(QRResult.QRError(it))
}
.addOnCompleteListener {
onPassCompleted(failureTimestamp.isFailure)
imageProxy.close()
}
}

@Synchronized
private fun handleSynchronized(result: QRResult) {
handle(result)
}

private fun onNonEmptySuccess(codes: List<Barcode?>?) {
val someCodes = codes?.mapNotNull { it?.rawValue }?.takeIf { it.isNotEmpty() } ?: return
handleSynchronized(QRResult.QRSuccess(someCodes))
}
}

private const val FAILURE_THROTTLE_MILLIS: Long = 1_000L

private const val NO_FAILURE_FLAG: Long = Long.MIN_VALUE

private inline val Long.isFailure: Boolean get() = this != NO_FAILURE_FLAG

@ExperimentalGetImage
private fun Image.toInputImage(rotationDegrees: Int): InputImage =
InputImage.fromMediaImage(this, rotationDegrees)
Original file line number Diff line number Diff line change
@@ -1,30 +1,93 @@
package ml.dev.kotlin.openotp.qr

import androidx.activity.compose.rememberLauncherForActivityResult
import android.widget.LinearLayout.LayoutParams
import androidx.appcompat.widget.ListPopupWindow
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import io.github.g00fy2.quickie.ScanCustomCode
import io.github.g00fy2.quickie.config.ScannerConfig
import io.github.g00fy2.quickie.QRResult as LibQRResult
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import com.google.accompanist.permissions.PermissionStatus
import com.google.accompanist.permissions.rememberPermissionState
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors

@Composable
actual fun rememberQRCodeScanner(resultHandler: (QRResult) -> Unit): (() -> Unit)? {
val scanQrCodeLauncher = rememberLauncherForActivityResult(ScanCustomCode()) {
val result = when (it) {
is LibQRResult.QRError -> QRResult.QRError(it.exception)
LibQRResult.QRMissingPermission -> QRResult.QRMissingPermission
is LibQRResult.QRSuccess -> QRResult.QRSuccess(it.content.rawValue)
LibQRResult.QRUserCanceled -> QRResult.QRUserCanceled
actual fun QRCodeScanner(
onResult: (QRResult) -> Boolean,
innerPadding: PaddingValues,
onIsLoadingChange: (Boolean) -> Unit,
) {
val localContext = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val backgroundColor = MaterialTheme.colorScheme.background
val analysisExecutor = rememberExecutor()
val cameraController = remember { LifecycleCameraController(localContext) }
AndroidView(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
factory = { context ->
PreviewView(context).apply {
setBackgroundColor(backgroundColor.toArgb())

layoutParams = LayoutParams(ListPopupWindow.MATCH_PARENT, ListPopupWindow.MATCH_PARENT)
scaleType = PreviewView.ScaleType.FILL_START
implementationMode = PreviewView.ImplementationMode.COMPATIBLE
controller = cameraController

var handleNext = true

cameraController.setImageAnalysisAnalyzer(
analysisExecutor,
QRCodeAnalyzer(
handle = { handleNext = handleNext && onResult(it) },
onPassCompleted = { failureOccurred -> onIsLoadingChange(failureOccurred) },
)
)

cameraController.bindToLifecycle(lifecycleOwner)
}
},
onRelease = {
cameraController.unbind()
analysisExecutor.shutdown()
}
resultHandler(result)
}
return {
scanQrCodeLauncher.launch(
ScannerConfig.build {
setHapticSuccessFeedback(true)
setHorizontalFrameRatio(1f)
setShowCloseButton(true)
setShowTorchToggle(true)
)
}

@Composable
actual fun rememberCameraPermissionState(): CameraPermissionState? {
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.CAMERA
)
return object : CameraPermissionState {
override val permission: CameraPermission
get() = when (cameraPermissionState.status) {
PermissionStatus.Granted -> CameraPermission.Granted
is PermissionStatus.Denied -> CameraPermission.Denied
}
)

override fun launchRequest() = cameraPermissionState.launchPermissionRequest()
}
}

@Composable
private fun rememberExecutor(): ExecutorService {
val executor = remember { Executors.newSingleThreadExecutor() }
DisposableEffect(Unit) {
onDispose {
executor.shutdown()
}
}
return executor
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import ml.dev.kotlin.openotp.component.OpenOtpAppComponent.Child
import ml.dev.kotlin.openotp.otp.OtpData
import ml.dev.kotlin.openotp.ui.screen.AddProviderScreen
import ml.dev.kotlin.openotp.ui.screen.MainScreen
import ml.dev.kotlin.openotp.ui.screen.ScanQRCodeScreen
import ml.dev.kotlin.openotp.ui.theme.OpenOtpTheme
import ml.dev.kotlin.openotp.util.ValueSettings
import org.koin.compose.koinInject
Expand Down Expand Up @@ -47,7 +48,8 @@ internal fun OpenOtpApp(component: OpenOtpAppComponent) {
snackbarHost = { SnackbarHost(snackbarHostState) },
) {
when (val instance = child.instance) {
is Child.Main -> MainScreen(mainComponent = instance.component)
is Child.Main -> MainScreen(instance.component)
is Child.ScanQRCode -> ScanQRCodeScreen(instance.component)
is Child.AddProvider -> AddProviderScreen(instance.totpComponent, instance.hotpComponent)
}
}
Expand Down
Loading

0 comments on commit 5c0a704

Please sign in to comment.