Modernize QR scanner: CameraX + ML Kit bundled (offline), improve image upload#53
Conversation
Replace zxing-lite (ancient CameraX 1.0.x + weaker ZXing decoder) with a modern CameraX 1.4.1 preview/ImageAnalysis pipeline feeding Google ML Kit's bundled barcode model (com.google.mlkit:barcode-scanning) — far more robust on angled/low-light/dense QR, and fully offline with no Google Play Services dependency (works on de-Googled ROMs). - Live scan: CameraX ImageAnalysis -> ML Kit (QR format only), STRATEGY_KEEP_ONLY_LATEST. - Image upload: decode bitmap -> ML Kit InputImage.fromBitmap (was ZXing CodeUtils); multi-image still supported; clear 'no QR found' message. - New ScannerOverlayView viewfinder (replaces king ViewfinderView) + Material torch FAB (hidden when no flash unit) with flash on/off icons. - Runtime CAMERA permission via ActivityResult API; torch via CameraControl. - Kept com.google.zxing:core for QR *generation* (QRCodeDialog); ML Kit can't encode. Compiles; APK +~5.7MB (bundled barcode model libbarhopper). On-device verification pending (device disconnected from ADB during testing).
…are API 26+, minSdk 21)
📝 WalkthroughWalkthroughReplaces the ZXing ChangesCameraX + ML Kit QR Scanner Migration
Sequence DiagramsequenceDiagram
participant User
participant ScannerActivity
participant PermissionLauncher
participant CameraX
participant BarcodeScanner
participant RawUpdater
participant ProfileManager
User->>ScannerActivity: open scanner
ScannerActivity->>PermissionLauncher: launch(CAMERA)
PermissionLauncher-->>ScannerActivity: granted=true
ScannerActivity->>CameraX: bindToLifecycle(Preview + ImageAnalysis)
CameraX->>ScannerActivity: deliver ImageProxy frames
ScannerActivity->>BarcodeScanner: process(InputImage)
BarcodeScanner-->>ScannerActivity: rawValue (QR payload)
ScannerActivity->>RawUpdater: parseRaw(text)
RawUpdater-->>ScannerActivity: profiles / SubscriptionFoundException
ScannerActivity->>ProfileManager: createProfile(profile)
ScannerActivity-->>User: finish() or navigate to MainActivity
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 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.
Inline comments:
In `@app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt`:
- Around line 208-224: The onText method is launching a GlobalScope job that
returns immediately, allowing finish() to be called before the profile import
completes. The found variable currently tracks only whether QR text was found,
not whether profiles were successfully imported, so invalid QR codes still close
the scanner. Refactor onText to be a suspend function that returns the count of
successfully imported profiles instead of launching a separate job. Update the
finally block logic to only call finish() when found is true AND the returned
import count is greater than zero, ensuring the activity only closes after
profiles are successfully imported.
- Around line 193-203: The current bitmap decoding in the image selection logic
uses full resolution images from the gallery which can exhaust heap memory.
After the bitmap is decoded (in either the ImageDecoder or
MediaStore.Images.Media.getBitmap branches), add a downsampling step to scale
the bitmap to a bounded maximum dimension before passing it to the InputImage
creation. Consider implementing a helper function that scales the bitmap
proportionally if either its width or height exceeds a reasonable maximum
threshold (such as 1024 or 2048 pixels) to ensure memory-safe processing of
arbitrarily large gallery images.
In `@app/src/main/res/layout/layout_scanner.xml`:
- Around line 43-45: The FAB torch button has a static contentDescription in the
layout that does not reflect the current torch state, making it inaccessible for
screen reader users. Update the toggleTorch() method to dynamically set the
contentDescription on the FAB (binding.fabTorch) based on the current torchOn
state whenever it changes. Create two separate string resources for distinct
"turn on" and "turn off" descriptions, and in the toggleTorch() method after
toggling the icon and updating torchOn, set the contentDescription to the
appropriate resource string based on the new torchOn value so screen readers
always report the correct action to the user.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: bd4030d5-4b52-4435-914b-725e9f2cc87f
📒 Files selected for processing (7)
app/build.gradle.ktsapp/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.ktapp/src/main/java/io/nekohasekai/sagernet/widget/ScannerOverlayView.ktapp/src/main/res/drawable/ic_baseline_flash_off_24.xmlapp/src/main/res/drawable/ic_baseline_flash_on_24.xmlapp/src/main/res/layout/layout_scanner.xmlapp/src/main/res/values/strings.xml
…ateful torch a11y Addresses CodeRabbit findings on the scanner: - Image import now decodes bounded to MAX_IMPORT_DIMEN (2048px longer edge) via ImageDecoder target size (P+) / BitmapFactory inSampleSize (pre-P), so large gallery images can't OOM before ML Kit runs; bitmaps are recycled after scanning. - Split parsing into a suspend importText() that returns the created-profile count; the multi-image flow awaits it and only finish()es after imports complete (and only when a profile was actually created, not merely when QR text was found). Live scan latches 'finished' then imports in the background. - Torch FAB content-description is now stateful (turn on / turn off) for screen readers.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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.
Inline comments:
In `@app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt`:
- Around line 154-158: The live scan path in ScannerActivity.kt calls finish()
immediately before the background import completes, causing the toast in
onDestroy() to display "0 profile(s)" because importedN.get() hasn't been
incremented yet. Instead of calling finish() after scheduling
runOnDefaultDispatcher with importText(text), refactor this code to await the
completion of importText(text) before calling finish(), following the same
pattern used in the image import flow (lines 197-229). Move the finish() call
inside the runOnDefaultDispatcher block to execute only after importText(text)
has completed.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 99f15859-99cd-4302-b73a-9726403e9075
📒 Files selected for processing (2)
app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.ktapp/src/main/res/values/strings.xml
…ayout - Live scan now awaits importText() and then finish()es on the main thread, so importedN is populated before onDestroy() reads it (previously finished first, making the toast report '0 profile(s)'). - Layout torch FAB default contentDescription is now scan_torch_turn_on (matches the stateful runtime updates).
Summary
Modernizes the QR scanner: replaces the aging zxing-lite wrapper (which dragged in CameraX
1.0.x and a weaker ZXing decoder) with a current CameraX 1.4.1 + Google ML Kit pipeline using
the bundled barcode model — far more robust detection (angled / low-light / dense / damaged
codes) and fully offline with no Google Play Services dependency (works on de-Googled ROMs).
Changes
com.github.jenly1314:zxing-lite; addandroidx.camera:* 1.4.1+com.google.mlkit:barcode-scanning:17.3.0(bundled). Keepcom.google.zxing:corefor QRgeneration only (QRCodeDialog — ML Kit can't encode).
Preview+ImageAnalysis(KEEP_ONLY_LATEST) → ML Kit, QR format only.ML Kit InputImage.fromBitmap(was ZXing CodeUtils). Stillmulti-image; clear "No QR code found" message.
ScannerOverlayViewviewfinder (scrim + rounded scan window) replacing the kingViewfinderView; Material torch FAB (auto-hidden when the device has no flash unit) withflash on/off icons; runtime CAMERA permission via the ActivityResult API; torch via
CameraControl.Testing
DynamiteModule: Selected local version of com.google.mlkit.dynamite.barcode,barhopper ... created successfully) — noPlay Services download.
Notes / tradeoff
libbarhopper_v3.so). This is theapproved tradeoff for offline detection without a Google Play Services dependency.
Greptile Summary
This PR replaces the aging
zxing-litewrapper with a modern CameraX 1.4.1 + ML Kit bundled barcode scanner, providing fully offline QR detection without a Google Play Services dependency. It also adds a customScannerOverlayView, a Material torch FAB, runtime camera permission via the ActivityResult API, and bounded bitmap decoding for gallery image imports.zxing-literemoved; CameraXPreview+ImageAnalysis(KEEP_ONLY_LATEST) feeds frames to a bundled ML Kit client restricted toFORMAT_QR_CODE. ZXing core is kept for QR generation only.decodeBoundedBitmapcaps gallery images at 2048px to avoid OOM, then passes them to ML Kit viaTasks.await()— currently called onDispatchers.Default(a CPU-bound thread pool) rather thanDispatchers.IO.ScannerOverlayViewscrim/punch-through overlay, torch FAB auto-hidden when no flash unit is present, runtime CAMERA permission request.Confidence Score: 4/5
Safe to merge with a small fix: the image-import path blocks a Dispatchers.Default thread with Tasks.await(), which should use Dispatchers.IO instead.
The image-import coroutine runs on Dispatchers.Default (CPU thread pool, min 2 threads) and calls the blocking Tasks.await() inside it. During a multi-image import each ML Kit call pins one of those threads, starving other CPU-bound coroutines in the app. Everything else — live camera scanning, decodeBoundedBitmap OOM guard, torch control, permission flow, and overlay rendering — looks correct.
app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt — specifically the importCodeFile lambda's dispatcher choice for Tasks.await()
Important Files Changed
Flowchart
%%{init: {'theme': 'neutral'}}%% flowchart TD A([ScannerActivity.onCreate]) --> B{CAMERA permission?} B -- granted --> C[startCamera] B -- denied --> D[requestCamera.launch] D -- granted --> C D -- denied --> E([finish]) C --> F[ProcessCameraProvider.bindToLifecycle\nPreview + ImageAnalysis] F --> G{hasFlashUnit?} G -- yes --> H[Show torch FAB] G -- no --> I[Hide torch FAB] F --> J[[analyze loop\nKEEP_ONLY_LATEST]] J --> K{finished?} K -- yes --> L[imageProxy.close] K -- no --> M[ML Kit barcodeScanner.process] M --> N{barcode found?} N -- no --> L N -- yes --> O[finished = true\nrunOnDefaultDispatcher\nimportText] O --> P[onMainDispatcher: finish] A --> Q[Menu: Import from file] Q --> R[importCodeFile.launch] R --> S[runOnDefaultDispatcher\nDispatchers.Default ⚠️] S --> T[decodeBoundedBitmap\nmax 2048px] T --> U[Tasks.await ML Kit\nblocking call] U --> V{QR found?} V -- yes --> W[importText suspend] V -- no --> X[show scan_no_qr_found] W --> Y{imported > 0?} Y -- yes --> P%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%% flowchart TD A([ScannerActivity.onCreate]) --> B{CAMERA permission?} B -- granted --> C[startCamera] B -- denied --> D[requestCamera.launch] D -- granted --> C D -- denied --> E([finish]) C --> F[ProcessCameraProvider.bindToLifecycle\nPreview + ImageAnalysis] F --> G{hasFlashUnit?} G -- yes --> H[Show torch FAB] G -- no --> I[Hide torch FAB] F --> J[[analyze loop\nKEEP_ONLY_LATEST]] J --> K{finished?} K -- yes --> L[imageProxy.close] K -- no --> M[ML Kit barcodeScanner.process] M --> N{barcode found?} N -- no --> L N -- yes --> O[finished = true\nrunOnDefaultDispatcher\nimportText] O --> P[onMainDispatcher: finish] A --> Q[Menu: Import from file] Q --> R[importCodeFile.launch] R --> S[runOnDefaultDispatcher\nDispatchers.Default ⚠️] S --> T[decodeBoundedBitmap\nmax 2048px] T --> U[Tasks.await ML Kit\nblocking call] U --> V{QR found?} V -- yes --> W[importText suspend] V -- no --> X[show scan_no_qr_found] W --> Y{imported > 0?} Y -- yes --> PReviews (3): Last reviewed commit: "review: import before finishing on live ..." | Re-trigger Greptile