Refactor(android): replace zxing-android-embedded with CameraX + zxing-cpp - #28
Refactor(android): replace zxing-android-embedded with CameraX + zxing-cpp#28mahmourad98 wants to merge 3 commits into
Conversation
…g-cpp zxing-android-embedded has had no upstream release since 2021 and is built on the deprecated Camera1 API. It provided both the camera layer and the decoder, so replacing it means replacing both: - Camera: androidx.camera 1.5.2 (Camera2-based, Google-maintained). PreviewView runs in COMPATIBLE (TextureView) mode -- the PERFORMANCE default renders through a SurfaceView, which SurfaceFlinger composites above Flutter's layer and hides QrScannerOverlayShape. - Decoder: io.github.zxing-cpp:android 3.1.0, an actively maintained C++ port of ZXing that decodes CameraX ImageProxy frames directly and honors ImageProxy.cropRect. Replaces com.google.zxing:core. Scan-area restriction (previously CustomFramingRectBarcodeView, now deleted) is implemented by mapping the Dart overlay's cutout from view coordinates into analysis-buffer coordinates via PreviewView.sensorToViewTransform and ImageInfo.sensorToBufferTransformMatrix, then setting ImageProxy.cropRect. The MethodChannel contract with Dart is unchanged: same channel name, method names, argument shapes, result types and onRecognizeQR payload. Public Dart API is untouched. minSdk 20 -> 21 (CameraX/zxing-cpp floor; below Flutter's own minimum anyway). The plugin now declares CAMERA itself, which apps previously inherited via zxing-android-embedded's manifest merge. Intentional behavior deltas, documented in code: - rawBytes carries zxing-cpp's decoded content bytes; zxing's raw codeword stream has no equivalent API. - RSS-14 now reports "RSS14", the string the Dart parser expects; the old code emitted "RSS_14", which made the Dart handler throw. - invertScan now replies on the channel instead of leaving the future pending forever. Verified: example app builds and scans on device, restricted to the overlay cutout. Full on-device matrix (rotation, lens flip, torch, pause/resume) still pending. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
📝 WalkthroughWalkthroughAndroid QR scanning now uses CameraX and zxing-cpp, with updated lifecycle, permissions, ROI analysis, camera controls, and barcode mappings. Dart platform integration and package metadata were reformatted or updated for platform declarations and Flutter constraints. ChangesAndroid scanning migration
Dart platform updates
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant Flutter
participant QRView
participant CameraX
participant zxing_cpp
Flutter->>QRView: startScan(format indices)
QRView->>CameraX: bind preview and analysis
CameraX->>QRView: deliver image frame
QRView->>zxing_cpp: decode cropped ROI
zxing_cpp-->>QRView: return barcode result
QRView-->>Flutter: invoke recognition callback
Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Code Review
This pull request migrates the Android implementation of the QR code scanner plugin from the deprecated zxing-android-embedded library to Google's modern CameraX API combined with zxing-cpp for barcode decoding. Key changes include the removal of CustomFramingRectBarcodeView and the introduction of a new QRView class that manages the camera lifecycle, streams frames via ImageAnalysis, and handles scan-area restrictions. The review feedback highlights several critical robustness improvements for the new Android code: wrapping future.get() and provider.bindToLifecycle in try-catch blocks to prevent crashes during camera initialization and binding, calling result.success(null) in startScan to prevent the Dart-side future from hanging, and catching Throwable instead of Exception during native barcode reading to handle potential linkage errors.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| val future = ProcessCameraProvider.getInstance(context) | ||
| future.addListener({ | ||
| if (isDisposed) return@addListener | ||
| cameraProvider = future.get() | ||
| bindCameraUseCases() | ||
| }, ContextCompat.getMainExecutor(context)) |
There was a problem hiding this comment.
Calling future.get() directly inside the listener without a try-catch block can lead to unhandled exceptions and crashes (e.g., ExecutionException or InterruptedException) if CameraX fails to initialize on certain devices or emulator environments. Wrapping it in a try-catch block ensures the app fails gracefully.
| val future = ProcessCameraProvider.getInstance(context) | |
| future.addListener({ | |
| if (isDisposed) return@addListener | |
| cameraProvider = future.get() | |
| bindCameraUseCases() | |
| }, ContextCompat.getMainExecutor(context)) | |
| val future = ProcessCameraProvider.getInstance(context) | |
| future.addListener({ | |
| if (isDisposed) return@addListener | |
| try { | |
| cameraProvider = future.get() | |
| bindCameraUseCases() | |
| } catch (e: Exception) { | |
| // Handle or log CameraX initialization failure gracefully | |
| } | |
| }, ContextCompat.getMainExecutor(context)) |
| // RESUMED before binding so CameraX starts the session immediately. | ||
| lifecycleRegistry.currentState = Lifecycle.State.RESUMED | ||
| val cam = provider.bindToLifecycle(this, selector, preview, analysis) | ||
| camera = cam | ||
| boundUseCases = listOf(preview, analysis) | ||
|
|
||
| // Re-apply a torch request made before/while the camera was down | ||
| // (old zxing setTorch() had the same queued-until-open behavior). | ||
| if (desiredTorchOn) { | ||
| cam.cameraControl.enableTorch(true) | ||
| } |
There was a problem hiding this comment.
provider.bindToLifecycle can throw an IllegalArgumentException if the requested lens (e.g., front camera) is not available on the device, or an IllegalStateException if the use cases are already bound or the lifecycle is invalid. Wrapping this call in a try-catch block prevents crashes on devices lacking front/back cameras or when binding fails.
// RESUMED before binding so CameraX starts the session immediately.
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
try {
val cam = provider.bindToLifecycle(this, selector, preview, analysis)
camera = cam
boundUseCases = listOf(preview, analysis)
// Re-apply a torch request made before/while the camera was down
// (old zxing setTorch() had the same queued-until-open behavior).
if (desiredTorchOn) {
cam.cameraControl.enableTorch(true)
}
} catch (e: Exception) {
camera = null
boundUseCases = emptyList()
}| private fun startScan(arguments: List<Int>?, result: MethodChannel.Result) { | ||
| checkAndRequestPermission() | ||
|
|
||
| val allowedBarcodeTypes = getAllowedBarcodeTypes(arguments, result) | ||
|
|
||
| if (arguments == null) { | ||
| barcodeView?.decoderFactory = DefaultDecoderFactory(null, null, null, 2) | ||
| } else { | ||
| barcodeView?.decoderFactory = DefaultDecoderFactory(allowedBarcodeTypes, null, null, 2) | ||
| } | ||
|
|
||
| barcodeView?.decodeContinuous( | ||
| object : BarcodeCallback { | ||
| override fun barcodeResult(result: BarcodeResult) { | ||
| if (allowedBarcodeTypes.isEmpty() || allowedBarcodeTypes.contains(result.barcodeFormat)) { | ||
| val code = mapOf( | ||
| "code" to result.text, | ||
| "type" to result.barcodeFormat.name, | ||
| "rawBytes" to result.rawBytes | ||
| ) | ||
| allowedFormats = | ||
| if (arguments.isNullOrEmpty()) ALL_CLASSIC_FORMATS | ||
| // May map to an empty set (e.g. only upcEanExtension was | ||
| // requested); the analyzer then decodes nothing, which is what | ||
| // the old decoder did with that same configuration. | ||
| else arguments.mapNotNull { DART_INDEX_TO_FORMAT[it] }.toSet() | ||
|
|
||
| channel.invokeMethod(CHANNEL_METHOD_ON_RECOGNIZE_QR, code) | ||
| } | ||
| } | ||
| scanning = true | ||
|
|
||
| override fun possibleResultPoints(resultPoints: List<ResultPoint>) = Unit | ||
| } | ||
| ) | ||
| if (hasCameraPermission) { | ||
| ensureCameraStarted() | ||
| } | ||
| // If permission is not granted yet, onRequestPermissionsResult starts | ||
| // the camera as soon as the user allows it. | ||
| } |
There was a problem hiding this comment.
The startScan method does not invoke result.success(...) or result.error(...). This will cause the Dart-side future returned by _channel.invokeMethod('startScan', ...) to hang indefinitely on Android. Calling result.success(null) at the end of the method ensures the Dart future completes successfully.
| private fun startScan(arguments: List<Int>?, result: MethodChannel.Result) { | |
| checkAndRequestPermission() | |
| val allowedBarcodeTypes = getAllowedBarcodeTypes(arguments, result) | |
| if (arguments == null) { | |
| barcodeView?.decoderFactory = DefaultDecoderFactory(null, null, null, 2) | |
| } else { | |
| barcodeView?.decoderFactory = DefaultDecoderFactory(allowedBarcodeTypes, null, null, 2) | |
| } | |
| barcodeView?.decodeContinuous( | |
| object : BarcodeCallback { | |
| override fun barcodeResult(result: BarcodeResult) { | |
| if (allowedBarcodeTypes.isEmpty() || allowedBarcodeTypes.contains(result.barcodeFormat)) { | |
| val code = mapOf( | |
| "code" to result.text, | |
| "type" to result.barcodeFormat.name, | |
| "rawBytes" to result.rawBytes | |
| ) | |
| allowedFormats = | |
| if (arguments.isNullOrEmpty()) ALL_CLASSIC_FORMATS | |
| // May map to an empty set (e.g. only upcEanExtension was | |
| // requested); the analyzer then decodes nothing, which is what | |
| // the old decoder did with that same configuration. | |
| else arguments.mapNotNull { DART_INDEX_TO_FORMAT[it] }.toSet() | |
| channel.invokeMethod(CHANNEL_METHOD_ON_RECOGNIZE_QR, code) | |
| } | |
| } | |
| scanning = true | |
| override fun possibleResultPoints(resultPoints: List<ResultPoint>) = Unit | |
| } | |
| ) | |
| if (hasCameraPermission) { | |
| ensureCameraStarted() | |
| } | |
| // If permission is not granted yet, onRequestPermissionsResult starts | |
| // the camera as soon as the user allows it. | |
| } | |
| private fun startScan(arguments: List<Int>?, result: MethodChannel.Result) { | |
| checkAndRequestPermission() | |
| allowedFormats = | |
| if (arguments.isNullOrEmpty()) ALL_CLASSIC_FORMATS | |
| // May map to an empty set (e.g. only upcEanExtension was | |
| // requested); the analyzer then decodes nothing, which is what | |
| // the old decoder did with that same configuration. | |
| else arguments.mapNotNull { DART_INDEX_TO_FORMAT[it] }.toSet() | |
| scanning = true | |
| if (hasCameraPermission) { | |
| ensureCameraStarted() | |
| } | |
| // If permission is not granted yet, onRequestPermissionsResult starts | |
| // the camera as soon as the user allows it. | |
| result.success(null) | |
| } |
| val results = try { | ||
| barcodeReader.read(img) | ||
| } catch (e: Exception) { | ||
| // Defensive: a single bad frame must not kill the analyzer. | ||
| emptyList() | ||
| } |
There was a problem hiding this comment.
Since zxing-cpp relies on native C++ libraries, calls to barcodeReader.read(img) can potentially throw a Throwable (such as UnsatisfiedLinkError or other linkage/native errors) rather than just a standard Exception. Catching Throwable instead of Exception ensures that any native library loading or linking issues do not crash the application.
| val results = try { | |
| barcodeReader.read(img) | |
| } catch (e: Exception) { | |
| // Defensive: a single bad frame must not kill the analyzer. | |
| emptyList() | |
| } | |
| val results = try { | |
| barcodeReader.read(img) | |
| } catch (e: Throwable) { | |
| // Defensive: a single bad frame or native error must not kill the analyzer. | |
| emptyList() | |
| } |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
pubspec.yaml (1)
15-15: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winClarify the intent of the Flutter SDK constraint.
Flutter tooling enforces only the lower bound of
environment.flutter, so^3.44.0does not provide an effective<4.0.0upper bound. If this package simply requires Flutter 3.44+, retain>=3.44.0; otherwise verify the intended compatibility policy. (dart.dev)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@pubspec.yaml` at line 15, Update the environment.flutter constraint to reflect the intended compatibility policy: if the package only requires Flutter 3.44 or newer, replace ^3.44.0 with >=3.44.0; otherwise set an explicit upper bound that tooling will enforce, based on verified supported versions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@pubspec.yaml`:
- Line 15: Update the environment.flutter constraint to reflect the intended
compatibility policy: if the package only requires Flutter 3.44 or newer,
replace ^3.44.0 with >=3.44.0; otherwise set an explicit upper bound that
tooling will enforce, based on verified supported versions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 9fed62db-38b6-4880-bfaf-143f3c2cbf87
📒 Files selected for processing (16)
android/build.gradleandroid/src/main/AndroidManifest.xmlandroid/src/main/kotlin/net/touchcapture/qr/flutterqrplus/CustomFramingRectBarcodeView.ktandroid/src/main/kotlin/net/touchcapture/qr/flutterqrplus/QRView.ktexample/lib/main.dartlib/src/lifecycle_event_handler.dartlib/src/platform/platform_info_stub.dartlib/src/platform/platform_info_vm.dartlib/src/qr_code_scanner.dartlib/src/qr_scanner_overlay_shape.dartlib/src/types/barcode_format.dartlib/src/types/camera.dartlib/src/types/features.dartlib/src/web/flutter_qr_stub.dartlib/src/web/flutter_qr_web.dartpubspec.yaml
💤 Files with no reviewable changes (1)
- android/src/main/kotlin/net/touchcapture/qr/flutterqrplus/CustomFramingRectBarcodeView.kt
Summary
This PR replaces the Android scanner backend from
zxing-android-embeddedto a CameraX +zxing-cpppipeline while keeping the Flutter-side MethodChannel contract and public Dart API unchanged.The migration removes the deprecated Camera1-based dependency, moves camera preview/control to CameraX, and moves frame decoding to
zxing-cpp. It also preserves overlay-restricted scanning by translating the Dart overlay cutout into analysis-buffer coordinates and applying that region throughImageProxy.cropRect.Key Changes
Android build and dependency updates
minSdkVersionfrom 20 to 21.com.journeyapps:zxing-android-embedded:4.3.0.com.google.zxing:core:3.5.4.androidx.camera:camera-coreandroidx.camera:camera-camera2androidx.camera:camera-lifecycleandroidx.camera:camera-viewio.github.zxing-cpp:android:3.1.0as the decoder.Android manifest updates
android.permission.CAMERAdirectly in the plugin manifest.tools:overrideLibraryusage tied to the previous ZXing Android dependency.Scanner implementation changes
PreviewView-based CameraX pipeline.PreviewView.ImplementationMode.COMPATIBLEso the preview renders throughTextureView, which keeps the Flutter overlay visible above the camera preview.ImageAnalysiswith a single-threaded executor to decode frames one at a time and drop stale frames.ImageProxy.cropRect, whichzxing-cppdecodes directly.LifecycleRegistryso CameraX owns session teardown and restart.Behavior fixes and intentional deltas
invertScannow responds on the MethodChannel instead of leaving the Dart future pending.RSS14, which matches the Dart parser expectation.rawBytesnow contains decoded content bytes fromzxing-cpp, not ZXing raw codewords.Files Changed
android/build.gradleandroid/src/main/AndroidManifest.xmlandroid/src/main/kotlin/net/touchcapture/qr/flutterqrplus/QRView.ktandroid/src/main/kotlin/net/touchcapture/qr/flutterqrplus/CustomFramingRectBarcodeView.ktCompatibility Notes
onRecognizeQRpayload shape remain unchanged.Summary by CodeRabbit