Skip to content

Modernize QR scanner: CameraX + ML Kit bundled (offline), improve image upload#53

Merged
hawkff merged 4 commits into
mainfrom
feature/mlkit-qr-scanner
Jun 21, 2026
Merged

Modernize QR scanner: CameraX + ML Kit bundled (offline), improve image upload#53
hawkff merged 4 commits into
mainfrom
feature/mlkit-qr-scanner

Conversation

@hawkff

@hawkff hawkff commented Jun 21, 2026

Copy link
Copy Markdown
Owner

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

  • Engine: remove com.github.jenly1314:zxing-lite; add androidx.camera:* 1.4.1 +
    com.google.mlkit:barcode-scanning:17.3.0 (bundled). Keep com.google.zxing:core for QR
    generation only (QRCodeDialog — ML Kit can't encode).
  • Live scan: CameraX Preview + ImageAnalysis (KEEP_ONLY_LATEST) → ML Kit, QR format only.
  • Image upload: decode bitmap → ML Kit InputImage.fromBitmap (was ZXing CodeUtils). Still
    multi-image; clear "No QR code found" message.
  • UX: new ScannerOverlayView viewfinder (scrim + rounded scan window) replacing the king
    ViewfinderView; Material torch FAB (auto-hidden when the device has no flash unit) with
    flash on/off icons; runtime CAMERA permission via the ActivityResult API; torch via
    CameraControl.
  • minSdk-safe layout padding (explicit attrs, not paddingHorizontal/Vertical which are API 26+).

Testing

  • CodeRabbit CLI: No findings.
  • Verified on-device (LineageOS A13, arm64):
    • Live camera scan added a profile.
    • Image upload (gallery) added a profile.
    • QR generation (QRCodeDialog) still renders (ZXing-core), no crash.
    • Logs confirm the bundled ML Kit model loads locally (DynamiteModule: Selected local version of com.google.mlkit.dynamite.barcode, barhopper ... created successfully) — no
      Play Services download.

Notes / tradeoff

  • APK grows ~+5.7MB per ABI (the bundled barcode model, libbarhopper_v3.so). This is the
    approved tradeoff for offline detection without a Google Play Services dependency.

Greptile Summary

This PR replaces the aging zxing-lite wrapper 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 custom ScannerOverlayView, a Material torch FAB, runtime camera permission via the ActivityResult API, and bounded bitmap decoding for gallery image imports.

  • Scanner engine: zxing-lite removed; CameraX Preview + ImageAnalysis (KEEP_ONLY_LATEST) feeds frames to a bundled ML Kit client restricted to FORMAT_QR_CODE. ZXing core is kept for QR generation only.
  • Image import: decodeBoundedBitmap caps gallery images at 2048px to avoid OOM, then passes them to ML Kit via Tasks.await() — currently called on Dispatchers.Default (a CPU-bound thread pool) rather than Dispatchers.IO.
  • UX: new ScannerOverlayView scrim/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

Filename Overview
app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt Core scanner rewrite: CameraX + ML Kit pipeline, runtime CAMERA permission, image import via Tasks.await() (blocking call placed on Dispatchers.Default — should use Dispatchers.IO)
app/src/main/java/io/nekohasekai/sagernet/widget/ScannerOverlayView.kt New custom view: scrim + PorterDuff CLEAR punch-through for scan window; hardware layer set correctly for CLEAR xfermode
app/build.gradle.kts Replaces zxing-lite with CameraX 1.4.1 + ML Kit bundled barcode scanning; retains ZXing core for QR generation
app/src/main/res/layout/layout_scanner.xml Updated layout: PreviewView, custom overlay, hint TextView, torch FAB; uses explicit padding attributes for minSdk compatibility
app/src/main/res/values/strings.xml Adds scanner-related strings; scan_toggle_torch is defined but unused (dead resource)

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
Loading
%%{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 --> P
Loading

Reviews (3): Last reviewed commit: "review: import before finishing on live ..." | Re-trigger Greptile

hawkff added 2 commits June 21, 2026 12:19
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).
@coderabbitai

coderabbitai Bot commented Jun 21, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Replaces the ZXing CameraScan-based QR pipeline with CameraX ImageAnalysis and ML Kit barcode scanning. A new ScannerOverlayView renders the scan window scrim with a transparent hole and white border. ScannerActivity is rewritten with a permission launcher, CameraX camera setup via ProcessCameraProvider, ML Kit frame analysis, updated image-file import with bitmap decoding, and revised lifecycle cleanup. Build dependencies are updated accordingly.

Changes

CameraX + ML Kit QR Scanner Migration

Layer / File(s) Summary
Dependency swap: ZXing-lite → CameraX + ML Kit
app/build.gradle.kts
Removes zxing-lite, adds CameraX 1.4.1 modules and com.google.mlkit:barcode-scanning, and retains com.google.zxing:core for QR generation only.
ScannerOverlayView: scrim, window geometry, and drawing
app/src/main/java/io/nekohasekai/sagernet/widget/ScannerOverlayView.kt
Adds ScannerOverlayView class with hardware-layered scrim, CLEAR-paint hole punching for a centered rounded-square window, and rounded white border. Exposes scanWindow geometry getter and setBorderColor() for dynamic styling.
Layout and drawable resources
app/src/main/res/layout/layout_scanner.xml, app/src/main/res/drawable/ic_baseline_flash_*.xml, app/src/main/res/values/strings.xml
Updates layout_scanner.xml to include ScannerOverlayView replacing ZXing viewfinder, adds scan-hint TextView and torch FloatingActionButton. Adds flash-on/off vector drawables and five new string resources for UI labels and hints.
ScannerActivity: state, permission, and CameraX setup
app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt
Replaces ZXing member state with CameraX/ML Kit fields, switches permission flow to ActivityResultContracts.RequestPermission, and implements startCamera() wiring ProcessCameraProvider, Preview, ResolutionSelector, and ImageAnalysis with keep-latest backpressure. Hides torch button if flash is unavailable.
ScannerActivity: QR analysis, torch control, image import, and payload handling
app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt
Implements analyze(ImageProxy) for live ML Kit QR detection, toggleTorch() via cameraControl, bitmap-based ML Kit scanning for imported images with OOM-safe decoding, the shared importText() handler invoking RawUpdater and ProfileManager, and onDestroy() cleanup.

Sequence Diagram

sequenceDiagram
  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
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 Hop hop, the ZXing days are done,
CameraX and ML Kit join the fun!
A punched-out square now lights the way,
The flashlight FAB has joined the fray.
Frames flow in, barcodes detected neat,
With cleanup on destroy — no memory feat!
The rabbit scans with modern flair. 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title clearly and concisely describes the main change: modernizing the QR scanner from zxing-lite to CameraX + ML Kit with offline bundled capabilities and improved image upload support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description comprehensively describes the migration from zxing-lite to CameraX + ML Kit, explains the technical rationale, documents all major changes, and includes verification details.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between b1c4fde and 392d90f.

📒 Files selected for processing (7)
  • app/build.gradle.kts
  • app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt
  • app/src/main/java/io/nekohasekai/sagernet/widget/ScannerOverlayView.kt
  • app/src/main/res/drawable/ic_baseline_flash_off_24.xml
  • app/src/main/res/drawable/ic_baseline_flash_on_24.xml
  • app/src/main/res/layout/layout_scanner.xml
  • app/src/main/res/values/strings.xml

Comment thread app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt Outdated
Comment thread app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt Outdated
Comment thread app/src/main/res/layout/layout_scanner.xml Outdated
Comment thread app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt Outdated
Comment thread app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt
…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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between 392d90f and 1802556.

📒 Files selected for processing (2)
  • app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt
  • app/src/main/res/values/strings.xml

Comment thread app/src/main/java/io/nekohasekai/sagernet/ui/ScannerActivity.kt Outdated
…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).
@hawkff hawkff merged commit 49d0283 into main Jun 21, 2026
5 checks passed
@hawkff hawkff deleted the feature/mlkit-qr-scanner branch June 21, 2026 17:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant