@@ -4,13 +4,14 @@ import android.Manifest
44import android.content.pm.PackageManager
55import androidx.activity.compose.rememberLauncherForActivityResult
66import androidx.activity.result.contract.ActivityResultContracts
7- import androidx.annotation.OptIn
87import androidx.camera.core.CameraSelector
9- import androidx.camera.core.ExperimentalGetImage
108import androidx.camera.core.ImageAnalysis
119import androidx.camera.core.Preview
10+ import androidx.camera.core.resolutionselector.ResolutionSelector
11+ import androidx.camera.core.resolutionselector.ResolutionStrategy
1212import androidx.camera.lifecycle.ProcessCameraProvider
1313import androidx.camera.view.PreviewView
14+ import androidx.compose.foundation.Canvas
1415import androidx.compose.foundation.layout.Box
1516import androidx.compose.foundation.layout.fillMaxSize
1617import androidx.compose.foundation.layout.padding
@@ -28,6 +29,15 @@ import androidx.compose.runtime.remember
2829import androidx.compose.runtime.setValue
2930import androidx.compose.ui.Alignment
3031import androidx.compose.ui.Modifier
32+ import androidx.compose.ui.geometry.Offset
33+ import androidx.compose.ui.geometry.Rect
34+ import androidx.compose.ui.geometry.RoundRect
35+ import androidx.compose.ui.geometry.Size
36+ import androidx.compose.ui.graphics.Color
37+ import androidx.compose.ui.graphics.Path
38+ import androidx.compose.ui.graphics.PathOperation
39+ import androidx.compose.ui.graphics.drawscope.DrawScope
40+ import androidx.compose.ui.graphics.drawscope.Stroke
3141import androidx.compose.ui.platform.LocalContext
3242import androidx.compose.ui.text.style.TextAlign
3343import androidx.compose.ui.unit.dp
@@ -37,10 +47,8 @@ import androidx.lifecycle.LifecycleOwner
3747import androidx.lifecycle.compose.LocalLifecycleOwner
3848import androidx.navigation.NavHostController
3949import com.castle.sefirah.navigation.SyncRoute
40- import com.google.mlkit.vision.barcode.BarcodeScanning
41- import com.google.mlkit.vision.barcode.common.Barcode
42- import com.google.mlkit.vision.common.InputImage
4350import sefirah.network.util.QrCodeParser
51+ import zxingcpp.BarcodeReader
4452import java.util.concurrent.Executors
4553
4654@Composable
@@ -86,18 +94,23 @@ fun QrCodeScanner(
8694 )
8795 }
8896 hasCameraPermission -> {
89- CameraPreview (
90- onQrCodeScanned = { qrCodeData ->
91- // Parse QR code and navigate back to SyncScreen with the parsed data
92- val connectionData = QrCodeParser .parseQrCode(qrCodeData)
93- if (connectionData != null ) {
94- rootNavController.getBackStackEntry(SyncRoute .SyncScreen .route)
95- .savedStateHandle[" qr_code_result" ] = connectionData
96- }
97- rootNavController.popBackStack()
98- },
99- lifecycleOwner = lifecycleOwner
100- )
97+ Box (modifier = Modifier .fillMaxSize()) {
98+ CameraPreview (
99+ onQrCodeScanned = { qrCodeData ->
100+ // Parse QR code and navigate back to SyncScreen with the parsed data
101+ QrCodeParser .parseQrCode(qrCodeData)?.let { parsedData ->
102+ runCatching {
103+ rootNavController.getBackStackEntry(SyncRoute .SyncScreen .route)
104+ }.getOrNull()?.let { syncScreenEntry ->
105+ syncScreenEntry.savedStateHandle[" qr_code_result" ] = parsedData
106+ rootNavController.popBackStack(SyncRoute .SyncScreen .route, inclusive = false )
107+ }
108+ }
109+ },
110+ lifecycleOwner
111+ )
112+ QrCodeScanningOverlay ()
113+ }
101114 }
102115 }
103116
@@ -117,15 +130,12 @@ fun QrCodeScanner(
117130 }
118131}
119132
120- @OptIn(ExperimentalGetImage ::class )
121133@Composable
122134private fun CameraPreview (
123135 onQrCodeScanned : (String ) -> Unit ,
124136 lifecycleOwner : LifecycleOwner
125137) {
126138 val context = LocalContext .current
127- var scanned by remember { mutableStateOf(false ) }
128-
129139 AndroidView (
130140 factory = { ctx ->
131141 val previewView = PreviewView (ctx)
@@ -140,44 +150,42 @@ private fun CameraPreview(
140150 it.surfaceProvider = previewView.surfaceProvider
141151 }
142152
153+ val resolutionSelector = ResolutionSelector .Builder ()
154+ .setResolutionStrategy(
155+ ResolutionStrategy (
156+ android.util.Size (1280 , 720 ),
157+ ResolutionStrategy .FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
158+ )
159+ )
160+ .build()
161+
143162 val imageAnalysis = ImageAnalysis .Builder ()
163+ .setResolutionSelector(resolutionSelector)
144164 .setBackpressureStrategy(ImageAnalysis .STRATEGY_KEEP_ONLY_LATEST )
145165 .build()
146166
147- val barcodeScanner = BarcodeScanning .getClient()
148-
149- imageAnalysis.setAnalyzer(Executors .newSingleThreadExecutor()) { imageProxy ->
150- if (! scanned) {
151- val mediaImage = imageProxy.image
152- if (mediaImage == null ) {
153- imageProxy.close()
154- return @setAnalyzer
155- }
156- val image = InputImage .fromMediaImage(
157- mediaImage,
158- imageProxy.imageInfo.rotationDegrees
159- )
167+ val barcodeReader = BarcodeReader ()
168+ barcodeReader.options = BarcodeReader .Options (
169+ formats = setOf (BarcodeReader .Format .QR_CODE ),
170+ tryHarder = true ,
171+ tryRotate = true ,
172+ tryInvert = true ,
173+ tryDownscale = true
174+ )
160175
161- barcodeScanner.process(image)
162- .addOnSuccessListener { barcodes ->
163- for (barcode in barcodes) {
164- when (barcode.valueType) {
165- Barcode .TYPE_URL , Barcode .TYPE_TEXT -> {
166- barcode.rawValue?.let { qrCodeData ->
167- if (! scanned) {
168- scanned = true
169- onQrCodeScanned(qrCodeData)
170- }
171- }
172- }
176+ imageAnalysis.setAnalyzer(Executors .newSingleThreadExecutor()) { image ->
177+ image.use {
178+ try {
179+ val results = barcodeReader.read(it)
180+ if (results.isNotEmpty()) {
181+ results.first().text?.let { qrCodeText ->
182+ executor.execute {
183+ onQrCodeScanned(qrCodeText)
173184 }
174185 }
175186 }
176- .addOnCompleteListener {
177- imageProxy.close()
178- }
179- } else {
180- imageProxy.close()
187+ } catch (_: Exception ) {
188+ }
181189 }
182190 }
183191
@@ -205,3 +213,115 @@ private fun CameraPreview(
205213 )
206214}
207215
216+ @Composable
217+ private fun QrCodeScanningOverlay () {
218+ val strokeWidth = 4 .dp
219+ val cornerRadius = 24 .dp
220+ val arcRadius = 40 .dp
221+ val arcOffset = 20 .dp
222+ val overlayColor = MaterialTheme .colorScheme.primary
223+ val overlayAlpha = 0.6f
224+ val scanningAreaRatio = 0.7f
225+
226+ Box (modifier = Modifier .fillMaxSize()) {
227+ Canvas (modifier = Modifier .fillMaxSize()) {
228+ val scanningBounds = calculateScanningBounds(size, scanningAreaRatio)
229+ val radiusPx = cornerRadius.toPx()
230+ val arcRadiusPx = arcRadius.toPx()
231+ val arcOffsetPx = arcOffset.toPx()
232+ val strokeWidthPx = strokeWidth.toPx()
233+
234+ drawDimmedOverlay(scanningBounds, radiusPx, overlayAlpha)
235+ drawCornerArcs(scanningBounds, overlayColor, arcRadiusPx, arcOffsetPx, strokeWidthPx)
236+ }
237+ }
238+ }
239+
240+ private data class ScanningBounds (
241+ val left : Float ,
242+ val top : Float ,
243+ val right : Float ,
244+ val bottom : Float
245+ )
246+
247+ private fun calculateScanningBounds (size : Size , ratio : Float ): ScanningBounds {
248+ val centerX = size.width / 2
249+ val centerY = size.height / 2
250+ val scanningSize = size.width * ratio.coerceAtMost(size.height * ratio)
251+ val halfSize = scanningSize / 2
252+
253+ return ScanningBounds (
254+ left = centerX - halfSize,
255+ top = centerY - halfSize,
256+ right = centerX + halfSize,
257+ bottom = centerY + halfSize
258+ )
259+ }
260+
261+ private fun DrawScope.drawDimmedOverlay (
262+ bounds : ScanningBounds ,
263+ cornerRadius : Float ,
264+ alpha : Float
265+ ) {
266+ val fullScreenPath = Path ().apply {
267+ addRect(Rect (0f , 0f , this @drawDimmedOverlay.size.width, this @drawDimmedOverlay.size.height))
268+ }
269+
270+ val cutoutPath = Path ().apply {
271+ addRoundRect(
272+ RoundRect (
273+ left = bounds.left,
274+ top = bounds.top,
275+ right = bounds.right,
276+ bottom = bounds.bottom,
277+ radiusX = cornerRadius,
278+ radiusY = cornerRadius
279+ )
280+ )
281+ }
282+
283+ val overlayPath = Path .combine(
284+ operation = PathOperation .Difference ,
285+ path1 = fullScreenPath,
286+ path2 = cutoutPath
287+ )
288+
289+ drawPath(
290+ path = overlayPath,
291+ color = Color .Black .copy(alpha = alpha)
292+ )
293+ }
294+
295+ private fun DrawScope.drawCornerArcs (
296+ bounds : ScanningBounds ,
297+ color : Color ,
298+ arcRadius : Float ,
299+ arcOffset : Float ,
300+ strokeWidth : Float
301+ ) {
302+ val cornerConfigs = listOf (
303+ CornerConfig (180f , bounds.left - arcRadius + arcOffset, bounds.top - arcRadius + arcOffset),
304+ CornerConfig (270f , bounds.right - arcRadius - arcOffset, bounds.top - arcRadius + arcOffset),
305+ CornerConfig (90f , bounds.left - arcRadius + arcOffset, bounds.bottom - arcRadius - arcOffset),
306+ CornerConfig (0f , bounds.right - arcRadius - arcOffset, bounds.bottom - arcRadius - arcOffset)
307+ )
308+
309+ cornerConfigs.forEach { config ->
310+ drawArc(
311+ color = color,
312+ startAngle = config.startAngle,
313+ sweepAngle = 90f ,
314+ useCenter = false ,
315+ topLeft = Offset (config.x, config.y),
316+ size = Size (arcRadius * 2 , arcRadius * 2 ),
317+ style = Stroke (strokeWidth)
318+ )
319+ }
320+ }
321+
322+ private data class CornerConfig (
323+ val startAngle : Float ,
324+ val x : Float ,
325+ val y : Float
326+ )
327+
0 commit comments