Skip to content

Commit 807cdf2

Browse files
committed
chore: replace ML Kit barcode scanning with ZXing-C++ for QR code scanning
- add scanner overlay - refactor scanner logic
1 parent 50e6450 commit 807cdf2

File tree

3 files changed

+175
-61
lines changed

3 files changed

+175
-61
lines changed

app/build.gradle.kts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,13 +83,11 @@ dependencies {
8383

8484
implementation(libs.androidx.media)
8585
implementation(libs.androidx.hilt.work)
86-
implementation(libs.mlkit.barcode.scanning)
86+
implementation(libs.zxing.cpp.android)
8787
implementation(libs.camerax.core)
8888
implementation(libs.camerax.camera2)
8989
implementation(libs.camerax.lifecycle)
9090
implementation(libs.camerax.view)
91-
implementation(libs.camerax.mlkit.vision)
92-
implementation(libs.kotlinx.coroutines.play.services)
9391
implementation(libs.guava)
9492

9593
testImplementation(libs.junit)

app/src/main/java/com/castle/sefirah/presentation/sync/QrCodeScanner.kt

Lines changed: 170 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@ import android.Manifest
44
import android.content.pm.PackageManager
55
import androidx.activity.compose.rememberLauncherForActivityResult
66
import androidx.activity.result.contract.ActivityResultContracts
7-
import androidx.annotation.OptIn
87
import androidx.camera.core.CameraSelector
9-
import androidx.camera.core.ExperimentalGetImage
108
import androidx.camera.core.ImageAnalysis
119
import androidx.camera.core.Preview
10+
import androidx.camera.core.resolutionselector.ResolutionSelector
11+
import androidx.camera.core.resolutionselector.ResolutionStrategy
1212
import androidx.camera.lifecycle.ProcessCameraProvider
1313
import androidx.camera.view.PreviewView
14+
import androidx.compose.foundation.Canvas
1415
import androidx.compose.foundation.layout.Box
1516
import androidx.compose.foundation.layout.fillMaxSize
1617
import androidx.compose.foundation.layout.padding
@@ -28,6 +29,15 @@ import androidx.compose.runtime.remember
2829
import androidx.compose.runtime.setValue
2930
import androidx.compose.ui.Alignment
3031
import 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
3141
import androidx.compose.ui.platform.LocalContext
3242
import androidx.compose.ui.text.style.TextAlign
3343
import androidx.compose.ui.unit.dp
@@ -37,10 +47,8 @@ import androidx.lifecycle.LifecycleOwner
3747
import androidx.lifecycle.compose.LocalLifecycleOwner
3848
import androidx.navigation.NavHostController
3949
import 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
4350
import sefirah.network.util.QrCodeParser
51+
import zxingcpp.BarcodeReader
4452
import 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
122134
private 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+

gradle/libs.versions.toml

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,10 +65,8 @@ reorderable = "2.4.3"
6565
# Material Design
6666
material = "1.13.0"
6767
documentfile = "1.1.0"
68-
firebaseCrashlyticsBuildtools = "3.0.3"
69-
70-
# ML Kit
71-
mlkitBarcodeScanning = "17.3.0"
68+
# ZXing-C++
69+
zxingCpp = "2.3.0"
7270
cameraX = "1.4.0"
7371

7472
[libraries]
@@ -160,15 +158,14 @@ reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reo
160158
# Material Design
161159
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
162160

163-
# ML Kit
164-
mlkit-barcode-scanning = { module = "com.google.mlkit:barcode-scanning", version.ref = "mlkitBarcodeScanning" }
161+
# ZXing-C++
162+
zxing-cpp-android = { module = "io.github.zxing-cpp:android", version.ref = "zxingCpp" }
165163

166164
# CameraX
167165
camerax-core = { module = "androidx.camera:camera-core", version.ref = "cameraX" }
168166
camerax-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" }
169167
camerax-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" }
170168
camerax-view = { module = "androidx.camera:camera-view", version.ref = "cameraX" }
171-
camerax-mlkit-vision = { module = "androidx.camera:camera-mlkit-vision", version.ref = "cameraX" }
172169

173170

174171
richtext-commonmark = { module = "com.halilibo.compose-richtext:richtext-commonmark", version.ref = "richtext" }
@@ -184,7 +181,6 @@ android-gradlePlugin = { module = "com.android.tools.build:gradle", version.ref
184181
kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
185182
ksp-gradlePlugin = { group = "com.google.devtools.ksp", name = "com.google.devtools.ksp.gradle.plugin", version.ref = "ksp" }
186183
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentfile" }
187-
firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebaseCrashlyticsBuildtools" }
188184

189185
[plugins]
190186
android-application = { id = "com.android.application", version.ref = "agp" }

0 commit comments

Comments
 (0)