Created
September 25, 2025 06:08
-
-
Save Tonnie-Dev/e1566a4eacd929850eb84f310afa0eb6 to your computer and use it in GitHub Desktop.
QR Analysis Flow
This file contains hidden or 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
| package com.tonyxlab.qrcraft.presentation.screens.scan.components | |
| import android.view.ViewGroup | |
| import androidx.annotation.OptIn | |
| import androidx.camera.camera2.interop.ExperimentalCamera2Interop | |
| import androidx.camera.core.Camera | |
| import androidx.camera.core.CameraSelector | |
| import androidx.camera.core.FocusMeteringAction | |
| 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.Column | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.material3.CircularProgressIndicator | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.platform.LocalContext | |
| import androidx.compose.ui.res.stringResource | |
| import androidx.compose.ui.viewinterop.AndroidView | |
| import androidx.core.content.ContextCompat | |
| import androidx.lifecycle.compose.LocalLifecycleOwner | |
| import com.tonyxlab.qrcraft.R | |
| import com.tonyxlab.qrcraft.data.QRCodeAnalyzer | |
| import com.tonyxlab.qrcraft.domain.QrData | |
| import com.tonyxlab.qrcraft.presentation.core.utils.spacing | |
| import com.tonyxlab.qrcraft.presentation.screens.scan.handling.ScanUiState | |
| import com.tonyxlab.qrcraft.presentation.theme.ui.OnOverlay | |
| @OptIn(ExperimentalCamera2Interop::class) | |
| @Composable | |
| fun CameraPreview( | |
| uiState: ScanUiState, | |
| onScanSuccess: (QrData) -> Unit, | |
| onAnalyzing: (Boolean) -> Unit, | |
| modifier: Modifier = Modifier | |
| ) { | |
| val lifecycleOwner = LocalLifecycleOwner.current | |
| val context = LocalContext.current | |
| val executor = ContextCompat.getMainExecutor(context) | |
| AndroidView( | |
| modifier = modifier, | |
| factory = { ctx -> | |
| PreviewView(ctx).apply { | |
| layoutParams = ViewGroup.LayoutParams( | |
| ViewGroup.LayoutParams.MATCH_PARENT, | |
| ViewGroup.LayoutParams.MATCH_PARENT | |
| ) | |
| scaleType = PreviewView.ScaleType.FILL_CENTER | |
| } | |
| } | |
| ) { previewView -> | |
| val cameraProviderFuture = | |
| ProcessCameraProvider.getInstance(context) | |
| cameraProviderFuture.addListener( | |
| { | |
| val analyzer = ImageAnalysis.Builder() | |
| .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) | |
| .build() | |
| .also { | |
| it.setAnalyzer( | |
| executor, | |
| QRCodeAnalyzer( | |
| onCodeScanned = { data -> | |
| onScanSuccess(data) | |
| }, | |
| onAnalyzing = { active -> | |
| onAnalyzing(active) | |
| }, | |
| consumeOnce = true | |
| ) | |
| ) | |
| } | |
| val cameraProvider = cameraProviderFuture.get() | |
| val preview = Preview.Builder() | |
| .build() | |
| .apply { | |
| surfaceProvider = previewView.surfaceProvider | |
| } | |
| val selector = CameraSelector.DEFAULT_BACK_CAMERA | |
| cameraProvider.unbindAll() | |
| cameraProvider.bindToLifecycle( | |
| lifecycleOwner, selector, preview, analyzer | |
| ) | |
| val camera = cameraProvider.bindToLifecycle( | |
| lifecycleOwner, selector, preview, analyzer | |
| ) | |
| //lockCenterFocus(previewView, camera) | |
| }, | |
| executor | |
| ) | |
| } | |
| if (uiState.isLoading) { | |
| Column( | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| CircularProgressIndicator( | |
| modifier = Modifier | |
| .size(MaterialTheme.spacing.spaceLarge) | |
| .padding(bottom = MaterialTheme.spacing.spaceMedium), | |
| color = OnOverlay | |
| ) | |
| Text( | |
| text = stringResource(id = R.string.cap_text_loading), | |
| style = MaterialTheme.typography.bodyLarge.copy( | |
| color = OnOverlay | |
| ) | |
| ) | |
| } | |
| } | |
| } | |
| fun lockCenterFocus(previewView: PreviewView, camera: Camera) { | |
| val factory = previewView.meteringPointFactory | |
| val center = factory.createPoint(previewView.width / 2f, previewView.height / 2f) | |
| val action = FocusMeteringAction.Builder( | |
| center, | |
| FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE | |
| ) | |
| .setAutoCancelDuration(2, java.util.concurrent.TimeUnit.SECONDS) | |
| .build() | |
| camera.cameraControl.startFocusAndMetering(action) | |
| } |
This file contains hidden or 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
| @file:kotlin.OptIn(ExperimentalAtomicApi::class) | |
| @file:OptIn(ExperimentalGetImage::class) | |
| package com.tonyxlab.qrcraft.data | |
| import androidx.annotation.OptIn | |
| import androidx.camera.core.ExperimentalGetImage | |
| 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 com.tonyxlab.qrcraft.domain.QrData | |
| import com.tonyxlab.qrcraft.domain.QrDataType | |
| import kotlin.concurrent.atomics.AtomicBoolean | |
| import kotlin.concurrent.atomics.ExperimentalAtomicApi | |
| class QRCodeAnalyzer( | |
| private val onCodeScanned: (QrData) -> Unit, | |
| private val onAnalyzing: (Boolean) -> Unit = {}, | |
| private val consumeOnce: Boolean = true | |
| ) : ImageAnalysis.Analyzer { | |
| private val onShotGuard = AtomicBoolean(false) | |
| private val options = BarcodeScannerOptions.Builder() | |
| .setBarcodeFormats(Barcode.FORMAT_QR_CODE) | |
| .build() | |
| private val scanner = BarcodeScanning.getClient(options) | |
| override fun analyze(imageProxy: ImageProxy) { | |
| val mediaImage = imageProxy.image ?: run { | |
| imageProxy.close() | |
| return | |
| } | |
| val image = InputImage.fromMediaImage( | |
| mediaImage, imageProxy.imageInfo.rotationDegrees | |
| ) | |
| scanner.process(image) | |
| .addOnSuccessListener { barcodes -> | |
| barcodes.ifEmpty { return@addOnSuccessListener } | |
| onAnalyzing(true) | |
| val barcode = | |
| barcodes.maxByOrNull { it.boundingBox?.width() ?: 0 } ?: barcodes.first() | |
| val data = barcode.toQrData() | |
| if (!consumeOnce || onShotGuard.compareAndSet( | |
| expectedValue = false, | |
| newValue = true | |
| ) | |
| ) { | |
| onCodeScanned(data) | |
| } | |
| } | |
| .addOnCompleteListener { | |
| imageProxy.close() | |
| } | |
| } | |
| private fun Barcode.toQrData(): QrData { | |
| this.url?.let { url -> | |
| return QrData( | |
| displayName = "Link", | |
| prettifiedData = url.url ?: displayValue.orEmpty(), | |
| qrDataType = QrDataType.LINK, | |
| rawDataValue = rawValue.orEmpty() | |
| ) | |
| } | |
| this.contactInfo?.let { contactInfo -> | |
| val name = listOfNotNull( | |
| contactInfo.name?.formattedName, | |
| contactInfo.name?.first, | |
| // contactInfo.name?.last | |
| ).joinToString(" ") | |
| .ifBlank { "Contact" } | |
| val pieces = buildList { | |
| add(name) | |
| contactInfo.emails.firstOrNull()?.address?.let { add(it) } | |
| contactInfo.phones.firstOrNull()?.number?.let { add(it) } | |
| contactInfo.organization?.firstOrNull() | |
| ?.let { add(it) } | |
| contactInfo.addresses.firstOrNull()?.addressLines?.joinToString() | |
| ?.let { add(it) } | |
| } | |
| return QrData( | |
| displayName = "Contact", | |
| prettifiedData = pieces.joinToString("\n") | |
| .ifBlank { displayValue.orEmpty() }, | |
| qrDataType = QrDataType.CONTACT, | |
| rawDataValue = rawValue.orEmpty() | |
| ) | |
| } | |
| this.phone?.let { phone -> | |
| return QrData( | |
| displayName = "Phone Number", | |
| prettifiedData = phone.number ?: displayValue.orEmpty(), | |
| qrDataType = QrDataType.PHONE_NUMBER, | |
| rawDataValue = rawValue.orEmpty() | |
| ) | |
| } | |
| this.geoPoint?.let { geoPoint -> | |
| val latLng = "${geoPoint.lat}, ${geoPoint.lng}" | |
| return QrData( | |
| displayName = "Geolocation", | |
| prettifiedData = latLng, | |
| qrDataType = QrDataType.GEOLOCATION, | |
| rawDataValue = rawValue.orEmpty() | |
| ) | |
| } | |
| this.wifi?.let { wiFi -> | |
| val security = when (wiFi.encryptionType) { | |
| Barcode.WiFi.TYPE_OPEN -> "Open" | |
| Barcode.WiFi.TYPE_WEP -> "WEP" | |
| Barcode.WiFi.TYPE_WPA -> "WPA/WPA2" | |
| else -> "Unknown" | |
| } | |
| val block = buildString { | |
| appendLine("SSID: ${wiFi.ssid ?: "--"}") | |
| appendLine("Password: ${wiFi.password ?: "--"}") | |
| append("Encryption: $security") | |
| } | |
| return QrData( | |
| displayName = "Wi-Fi", | |
| prettifiedData = block, | |
| qrDataType = QrDataType.WIFI, | |
| rawDataValue = rawValue.orEmpty() | |
| ) | |
| } | |
| return QrData( | |
| displayName = "Text", | |
| prettifiedData = displayValue ?: rawValue.orEmpty(), | |
| qrDataType = QrDataType.TEXT, | |
| rawDataValue = rawValue.orEmpty() | |
| ) | |
| } | |
| } | |
This file contains hidden or 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
| package com.tonyxlab.qrcraft.presentation.screens.scan.components | |
| import androidx.compose.foundation.Canvas | |
| import androidx.compose.foundation.layout.Arrangement | |
| import androidx.compose.foundation.layout.Box | |
| import androidx.compose.foundation.layout.Column | |
| import androidx.compose.foundation.layout.Spacer | |
| import androidx.compose.foundation.layout.fillMaxHeight | |
| import androidx.compose.foundation.layout.fillMaxSize | |
| import androidx.compose.foundation.layout.padding | |
| import androidx.compose.foundation.layout.size | |
| import androidx.compose.material3.CircularProgressIndicator | |
| import androidx.compose.material3.MaterialTheme | |
| import androidx.compose.material3.Text | |
| import androidx.compose.runtime.Composable | |
| import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.geometry.CornerRadius | |
| import androidx.compose.ui.geometry.Offset | |
| import androidx.compose.ui.geometry.Size | |
| import androidx.compose.ui.graphics.BlendMode | |
| import androidx.compose.ui.graphics.Color | |
| import androidx.compose.ui.graphics.CompositingStrategy | |
| import androidx.compose.ui.graphics.graphicsLayer | |
| import androidx.compose.ui.res.stringResource | |
| import androidx.compose.ui.text.style.TextAlign | |
| import androidx.compose.ui.unit.dp | |
| import com.tonyxlab.qrcraft.R | |
| import com.tonyxlab.qrcraft.presentation.core.utils.spacing | |
| import com.tonyxlab.qrcraft.presentation.theme.ui.OnOverlay | |
| import com.tonyxlab.qrcraft.presentation.theme.ui.Overlay | |
| import kotlin.math.min | |
| @Composable | |
| fun ScanOverlay( | |
| modifier: Modifier = Modifier, | |
| isLoading: Boolean | |
| ) { | |
| Box(modifier = modifier) { | |
| Canvas( | |
| modifier = Modifier | |
| .matchParentSize() | |
| .graphicsLayer { | |
| compositingStrategy = CompositingStrategy.Offscreen | |
| }) { | |
| val w = size.width | |
| val h = size.height | |
| val punchHoleFraction = .9f | |
| val frameSquareDimen = min(w, h) * punchHoleFraction | |
| val left = (w - frameSquareDimen) / 2f | |
| val top = (h - frameSquareDimen) / 2f | |
| val radius = 20.dp.toPx() | |
| // Draw scrim | |
| drawRect(Overlay) | |
| // Punch a transparent hole for the frame | |
| drawRoundRect( | |
| color = Color.Red, | |
| topLeft = Offset(left, top), | |
| size = Size(width = frameSquareDimen, height = frameSquareDimen), | |
| cornerRadius = CornerRadius(radius, radius), | |
| blendMode = BlendMode.Clear | |
| ) | |
| } | |
| // Hint Text | |
| Column( | |
| Modifier | |
| .fillMaxSize() | |
| .padding(horizontal = 24.dp), | |
| horizontalAlignment = Alignment.CenterHorizontally, | |
| verticalArrangement = Arrangement.Top | |
| ) { | |
| Spacer(Modifier.fillMaxHeight(0.25f)) | |
| Text( | |
| text = stringResource(id = R.string.cap_text_point_camera), | |
| style = MaterialTheme.typography.titleSmall.copy(color = OnOverlay), | |
| textAlign = TextAlign.Center | |
| ) | |
| } | |
| /* if (isLoading) { | |
| Column( | |
| modifier = Modifier.align(Alignment.Center), | |
| horizontalAlignment = Alignment.CenterHorizontally | |
| ) { | |
| CircularProgressIndicator( | |
| modifier = Modifier | |
| .size(MaterialTheme.spacing.spaceLarge) | |
| .padding(bottom = MaterialTheme.spacing.spaceMedium), | |
| color = OnOverlay | |
| ) | |
| Text( | |
| text = stringResource(id = R.string.cap_text_loading), | |
| style = MaterialTheme.typography.bodyLarge.copy( | |
| color = OnOverlay | |
| ) | |
| ) | |
| } | |
| }*/ | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment