> For the complete documentation index, see [llms.txt](https://wong-coupon.gitbook.io/flutter/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://wong-coupon.gitbook.io/flutter/my-flutter/ui-media/qr-barcode-scanner-camera-permission.md).

# Safe QR/Barcode Scanning

Build a Flutter QR/barcode scanner with explicit permissions, serialized camera lifecycle, and a pipeline that treats raw payloads as untrusted input

## Outcome

The source I reviewed already made several sound decisions: the camera did not auto-start, the live stream accepted only QR codes, operations were queued, the camera stopped before opening another route, and it paused before processing a payload or choosing an image. The difficult part was not drawing four corners over a preview. It was camera ownership while the permission dialog, app lifecycle, image picker, parser, and navigation all changed state.

I kept those strengths and gathered them in a state-machine coordinator. The resulting design has six boundaries:

```
Permission gateway ─┐
App lifecycle ──────┤
Page/picker state ──┼─► ScannerCoordinator ─► serialized camera operations
                    │             │
Mobile scanner ─────┘             ▼
                       allowlist → raw value → parser → typed result
```

`ScannerCoordinator` is the only component that decides whether the camera may run:

```
camera should run
= permission granted
× app resumed
× page visible
× picker closed
× not processing
× not disposed
```

The camera and gallery use the same `ScanPolicy`. The overlay and decoder use the same `Rect`. Each processing cycle accepts one payload. The parser consumes `rawValue` and checks format and length before creating a domain result. Analytics receives only coarse categories; raw QR values, images, and file paths never enter logs.

I verified the public sample with Flutter 3.41.2 and Dart 3.11.0. `flutter analyze` reported no issues, both coordinator unit tests passed, and the Android debug build succeeded with JDK 17. A real-device camera, system permission dialogs, torch, focus, and real image formats remain separate release gates; neither widget tests nor an APK build prove those behaviors.

## Problem

### `onDetect` is not a transaction boundary

The camera can decode several frames while the parser, API call, or navigation for the first frame is still running. `DetectionSpeed.noDuplicates` reduces repeated callbacks inside the plugin, but it does not replace the feature's single-flight gate.

This short implementation can submit more than once, receive a callback after disposal, and open an action from unvalidated data:

```dart
MobileScanner(
  onDetect: (capture) {
    submit(capture.barcodes.first.rawValue!);
  },
)
```

A decoder callback proves only that a code was recognized. It does not prove that the code matches the business protocol, belongs to the current session, or is safe to open as a URL.

### `isRunning` does not serialize native operations

Two lifecycle callbacks can both read `isRunning == false` before the first native `start()` completes. A `stop()` that arrives while `start()` is in flight can also observe a controller that is not running yet and become a no-op.

A boolean is therefore a state snapshot, not a mutex. `start()`, `pause()`, `stop()`, and `dispose()` must pass through one operation chain.

### The visible frame can differ from the scan area

The source draws a centered `Rect` over the preview but does not pass that rect to the scanner. The UI tells the user to place a code inside the frame while the decoder can still select a QR code outside it or a second code in the background.

An overlay is meaningful only when the painter and `scanWindow` share the same geometry, or when the app filters bounding boxes in the same coordinate system.

### Permission is not a boolean

`denied`, `permanentlyDenied`, `restricted`, an unavailable camera, and a busy camera need different UX:

| State               | Action                                                                        |
| ------------------- | ----------------------------------------------------------------------------- |
| `denied`            | Explain in context and offer retry or a fallback.                             |
| `permanentlyDenied` | Open Settings, wait for app resume, then read status again.                   |
| `restricted`        | Explain the device or policy restriction; do not promise Settings can fix it. |
| unavailable         | Offer gallery or manual input when the product supports it.                   |
| transient failure   | Retry through the operation queue instead of overlapping starts.              |

Collapsing every branch into “permission false” produces the wrong UI and prevents tests from describing real recovery.

### Live camera and gallery can drift apart

The live scanner in the source accepts only QR codes. The gallery calls `analyzeImage(path)` without `formats`; in the scanner API I verified, an empty list allows every supported format. The app can therefore reject Code 128 through the camera but accept it from an image.

Format, parser, and multiple-code behavior must belong to one policy shared by both input sources.

### `displayValue` is not the protocol payload

`displayValue` is a user-friendly representation. The package does not define it as the exact encoded string. A parser needs `rawValue` or `rawDecodedBytes`, followed by its own encoding and protocol validation.

A QR value is also untrusted input. It can contain a URL, Wi-Fi credentials, a contact, long text, or a fake protocol. The scanner must not automatically open a URL, call an API, submit a form, or write the payload to analytics before the parser accepts it.

## Solution

### Pin public versions before writing the adapter

The sample verified for this article uses these exact dependencies:

```yaml
dependencies:
  flutter:
    sdk: flutter
  image_picker: 1.2.3
  mobile_scanner: 7.4.0
  permission_handler: 12.0.1
```

The reference source uses `permission_handler` 12.0.1, `image_picker` 0.8.9, and a private scanner fork from the 7 beta line. I did not copy its private installation URL or API.

The sample keeps `permission_handler` 12.0.1 because version 13.0.1 currently resolves an Android implementation that requires compile SDK 37. With Flutter 3.41.2's default Android toolchain, 13.0.1 failed while configuring the plugin before app compilation; 12.0.1 completed a debug build. This is an evidence-based pin, not a recommendation to stay on 12.x forever. Moving to 13.x requires upgrading and rechecking the complete Android toolchain against the package changelog.

### Create a neutral scanner contract

The business feature should not receive `MobileScannerController`. I put models and a driver at an app-owned boundary:

```dart
enum ScanFormat { qrCode, code128 }

final class DecodedCode {
  const DecodedCode({
    required this.format,
    required this.rawValue,
  });

  final ScanFormat format;
  final String? rawValue;
}

final class ScanCapture {
  const ScanCapture(this.codes);

  final List<DecodedCode> codes;
}

abstract interface class ScannerDriver {
  Stream<ScanCapture> get captures;

  Future<void> start();
  Future<void> pause();
  Future<void> stop();

  Future<ScanCapture?> analyzeImage(
    String path, {
    required Set<ScanFormat> formats,
  });

  Future<void> dispose();
}
```

The production adapter maps `BarcodeFormat` and `BarcodeCapture` into these models. A fake driver in tests needs neither a camera nor a MethodChannel.

This adapter initialization was compiled against `mobile_scanner` 7.4.0:

```dart
final class MobileScannerDriver implements ScannerDriver {
  MobileScannerDriver({required Set<ScanFormat> formats})
      : controller = MobileScannerController(
          autoStart: false,
          detectionSpeed: DetectionSpeed.noDuplicates,
          facing: CameraFacing.back,
          formats: formats.map(_toPluginFormat).toList(growable: false),
          returnImage: false,
        ) {
    captures = controller.barcodes.map(_decodeCapture);
  }

  final MobileScannerController controller;

  @override
  late final Stream<ScanCapture> captures;

  @override
  Future<void> start() => controller.start();

  @override
  Future<void> pause() => controller.pause();

  @override
  Future<void> stop() => controller.stop();

  @override
  Future<ScanCapture?> analyzeImage(
    String path, {
    required Set<ScanFormat> formats,
  }) async {
    final capture = await controller.analyzeImage(
      path,
      formats: formats.map(_toPluginFormat).toList(growable: false),
    );
    return capture == null ? null : _decodeCapture(capture);
  }

  @override
  Future<void> dispose() => controller.dispose();

  static BarcodeFormat _toPluginFormat(ScanFormat format) => switch (format) {
    ScanFormat.qrCode => BarcodeFormat.qrCode,
    ScanFormat.code128 => BarcodeFormat.code128,
  };

  static ScanFormat? _fromPluginFormat(BarcodeFormat format) => switch (format) {
    BarcodeFormat.qrCode => ScanFormat.qrCode,
    BarcodeFormat.code128 => ScanFormat.code128,
    _ => null,
  };

  static ScanCapture _decodeCapture(BarcodeCapture capture) {
    final codes = capture.barcodes
        .map((barcode) {
          final format = _fromPluginFormat(barcode.format);
          if (format == null) return null;
          return DecodedCode(
            format: format,
            rawValue: barcode.rawValue,
          );
        })
        .whereType<DecodedCode>()
        .toList(growable: false);
    return ScanCapture(codes);
  }
}
```

In production code, I keep the preview widget and the `controller` getter next to the adapter. The coordinator sees only `ScannerDriver`; the domain parser does not import the scanner package.

### Put formats and parsing in one scan policy

The policy is the single source of truth for the live camera and gallery:

```dart
final class ScanPolicy {
  const ScanPolicy({
    required this.allowedFormats,
    required this.validatePayload,
  });

  final Set<ScanFormat> allowedFormats;
  final bool Function(String rawValue) validatePayload;
}

const policy = ScanPolicy(
  allowedFormats: {ScanFormat.qrCode},
  validatePayload: validateExamplePayload,
);

bool validateExamplePayload(String value) {
  return value.length <= 256 && value.startsWith('example:');
}
```

`example:` is only a fake protocol. A production parser must also decide normalization, encoding, checksum or signature rules, session binding, and an error taxonomy. Do not call `trim()` or change case unless the protocol allows it.

The policy must also define what happens when a frame contains several codes: reject it, require exactly one code, or select one using a tested geometry rule. Do not take `barcodes.first` without a contract for ordering.

### Make a state machine the camera owner

I model phases separately from their input facts:

```dart
enum ScannerPhase {
  idle,
  requestingPermission,
  starting,
  scanning,
  pickingImage,
  analyzingImage,
  processing,
  suspended,
  failed,
  disposed,
}

bool get shouldRun =>
    permission == CameraAccess.granted &&
    appResumed &&
    pageVisible &&
    !pickerOpen &&
    !processing &&
    !disposed;
```

A lifecycle callback updates a fact and then calls `reconcile()`. It does not call the plugin controller directly.

Every native operation passes through one future chain:

```dart
Future<void> _operation = Future<void>.value();

Future<T> enqueue<T>(Future<T> Function() action) {
  final result = _operation.then((_) => action());
  _operation = result.then<void>((_) {}, onError: (_, _) {});
  return result;
}
```

The caller still receives the operation's error. The internal chain swallows that error only so later work can continue; the calling boundary must map it to scanner state or a telemetry category.

`reconcile()` also keeps actual state owned by the coordinator. It does not use `controller.value.isRunning` as a lock:

```dart
bool _cameraRunning = false;

Future<void> reconcile() => enqueue(() async {
  if (disposed) return;

  if (!shouldRun) {
    if (_cameraRunning) {
      await driver.stop();
      _cameraRunning = false;
    }
    setPhase(ScannerPhase.suspended);
    return;
  }

  if (_cameraRunning) {
    setPhase(ScannerPhase.scanning);
    return;
  }

  setPhase(ScannerPhase.starting);
  try {
    await driver.start();
    _cameraRunning = true;
  } catch (error, stackTrace) {
    setFailure(mapScannerError(error, stackTrace));
    rethrow;
  }

  if (!shouldRun) {
    await driver.stop();
    _cameraRunning = false;
    setPhase(ScannerPhase.suspended);
    return;
  }

  setPhase(ScannerPhase.scanning);
});
```

Checking `shouldRun` again after `await start()` matters. While native code opens the camera, the app can enter the background, the route can lose visibility, or the user can open the picker.

### Lock one processing cycle before any `await`

The live capture handler checks its phase, sets `processing = true`, and reconciles so the camera stops before parsing:

```dart
Future<void> handleLiveCapture(ScanCapture capture) async {
  if (phase != ScannerPhase.scanning || processing || disposed) return;

  processing = true;
  setPhase(ScannerPhase.processing);

  try {
    await reconcile();
    final result = await parseCapture(capture);
    emit(result);
  } catch (error, stackTrace) {
    setFailure(mapScannerError(error, stackTrace));
  } finally {
    processing = false;
    await reconcile();
  }
}
```

The parser returns a typed result instead of navigating by itself:

```dart
sealed class ScanResult<T> {
  const ScanResult();
}

final class ScanAccepted<T> extends ScanResult<T> {
  const ScanAccepted(this.value);
  final T value;
}

final class ScanRejected<T> extends ScanResult<T> {
  const ScanRejected(this.reason);
  final ScanRejection reason;
}

enum ScanRejection {
  empty,
  unsupportedFormat,
  multipleCodes,
  malformedPayload,
}
```

The coordinator manages the resource. The parser owns the protocol. The page decides whether to show an error, return the result, or open the next route.

### Use one rect for the overlay and decoder

I compute the scan window from the camera preview's constraints rather than from the full screen:

```dart
LayoutBuilder(
  builder: (context, constraints) {
    final size = constraints.biggest;
    final side = (size.shortestSide * 0.68)
        .clamp(200.0, 320.0)
        .toDouble();
    final window = Rect.fromCenter(
      center: size.center(Offset.zero),
      width: side,
      height: side,
    );

    return Stack(
      fit: StackFit.expand,
      children: [
        MobileScanner(
          controller: driver.controller,
          scanWindow: window,
          useAppLifecycleState: false,
        ),
        IgnorePointer(
          child: CustomPaint(
            painter: ScanWindowPainter(window),
          ),
        ),
      ],
    );
  },
)
```

`useAppLifecycleState: false` leaves lifecycle ownership with the coordinator. If the widget or plugin handles lifecycle while a separate observer does the same, the app has two camera owners.

`scanWindow` depends on preview size, `BoxFit`, and the package's coordinate transform. I still test small screens, landscape, safe areas, and multiple codes inside and outside the frame. Constraint-driven layout is covered separately in:

{% content-ref url="/pages/CTKWji67N7nUi8hjGxHc" %}
[Responsive](/flutter/my-flutter/ui-media/responsive.md)
{% endcontent-ref %}

### Send gallery images through the same pipeline

Choosing one image does not require reading the entire photo library. I stop the camera before opening the system picker, then analyze the image with the same allowlist:

```dart
Future<void> pickAndAnalyzeImage() async {
  await coordinator.setPickerOpen(true);

  try {
    final file = await picker.pickImage(
      source: ImageSource.gallery,
      requestFullMetadata: false,
    );
    if (file == null) return;

    await coordinator.processImage(file.path);
  } finally {
    await coordinator.setPickerOpen(false);
  }
}
```

Inside `processImage`, do not leave formats at their default:

```dart
final capture = await driver.analyzeImage(
  path,
  formats: policy.allowedFormats,
);
```

The UI distinguishes at least six outcomes: user cancellation, no code, a disallowed format, an invalid payload, an unsupported image, and a platform exception. “No QR code found” is not the right message for every failure.

On Android, `image_picker` requires `retrieveLostData()` handling when the Activity is destroyed under memory pressure. Feed a lost result back into the same gallery pipeline, but first verify that the session and page are still eligible to process it.

### Map permission status into domain state

The UI does not read `PermissionStatus` directly:

```dart
enum CameraAccess {
  unknown,
  granted,
  denied,
  permanentlyDenied,
  restricted,
}

final class CameraPermissionGateway {
  Future<CameraAccess> check() async {
    return _map(await Permission.camera.status);
  }

  Future<CameraAccess> request() async {
    return _map(await Permission.camera.request());
  }

  Future<bool> openSettings() => openAppSettings();

  CameraAccess _map(PermissionStatus status) {
    if (status.isGranted) return CameraAccess.granted;
    if (status.isPermanentlyDenied) {
      return CameraAccess.permanentlyDenied;
    }
    if (status.isRestricted) return CameraAccess.restricted;
    return CameraAccess.denied;
  }
}
```

I request camera access when the user deliberately enters the scan feature, not at app startup. The rationale explains which capability is lost after denial and still offers a cancel or fallback action.

For `permanentlyDenied`, the CTA can call `openAppSettings()`. When the user returns, the app waits for lifecycle `resumed` and calls `check()` again. It does not use `Future.delayed` to guess how long the user spends in Settings. For `restricted`, the UI explains the limitation without displaying a CTA that promises a fix.

### Keep exactly one lifecycle owner

`AppLifecycleListener` updates facts; the coordinator reconciles the resource:

```dart
late final AppLifecycleListener lifecycle;

void initLifecycle() {
  lifecycle = AppLifecycleListener(
    onResume: () => unawaited(resumeScanner()),
    onInactive: () => unawaited(
      coordinator.setAppResumed(false),
    ),
    onPause: () => unawaited(
      coordinator.setAppResumed(false),
    ),
    onDetach: () => unawaited(coordinator.close()),
  );
}

Future<void> resumeScanner() async {
  await coordinator.setAppResumed(true);
  final access = await permissionGateway.check();
  await coordinator.setPermission(access);
}
```

Permission dialogs and the image picker can emit lifecycle events before the controller is ready. The callback therefore updates state instead of blindly starting or stopping the camera. `close()` must be idempotent because `onDetach` and widget `dispose()` can both call it.

Before opening a dialog, route, or picker, the page changes ownership and waits for the camera to stop or pause:

```
set pageVisible/pickerOpen/processing
→ await reconcile
→ open external UI
→ after return and mounted, clear fact
→ recheck permission if returning from Settings
→ reconcile
```

Flutter's `State.dispose()` cannot be awaited. I call async `close()` before popping the route when the flow ends deliberately, then keep a terminal `disposed` or generation guard so stale completions cannot publish state. Widget `dispose()` remains the final safety net.

### Configure Android for the real capability

A minimal public manifest is:

```xml
<uses-permission android:name="android.permission.CAMERA" />

<uses-feature
    android:name="android.hardware.camera.any"
    android:required="false" />
<uses-feature
    android:name="android.hardware.camera"
    android:required="false" />
<uses-feature
    android:name="android.hardware.camera.autofocus"
    android:required="false" />
```

If a camera is an absolute requirement, the product can mark the appropriate feature as `required="true"` and accept Play filtering. When gallery or manual input is a fallback, `false` is usually more appropriate; runtime code must still handle a device without a usable camera.

A plugin can merge the CAMERA permission or hardware feature into the manifest. I inspect the artifact's merged manifest through Android Studio or the matching Gradle task instead of reading only `android/app/src/main/AndroidManifest.xml`.

Do not declare `READ_MEDIA_IMAGES`, `READ_EXTERNAL_STORAGE`, or broad storage permission just so the user can choose one image. Android Photo Picker needs no runtime media permission and grants temporary access only to the selected item.

### Configure iOS for the scan context

`Info.plist` needs usage descriptions that match the feature:

```xml
<key>NSCameraUsageDescription</key>
<string>Camera is used to scan QR codes and barcodes.</string>

<key>NSPhotoLibraryUsageDescription</key>
<string>Photo access is used to scan a code from an image you choose.</string>
```

Production strings need localization. Do not copy a camera description from another feature.

With `permission_handler` 12.0.1 through CocoaPods, enable only the macros you need:

```ruby
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [
  '$(inherited)',
  'PERMISSION_CAMERA=1',
  'PERMISSION_PHOTOS=0',
]
```

The sample gallery uses `image_picker` without preflighting `Permission.photos`. `requestFullMetadata: false` avoids a metadata permission request, but the `image_picker` 1.2.3 documentation still requires `NSPhotoLibraryUsageDescription` for App Store policy. If you use SPM or another package version, follow the setup for the pinned version instead of copying this Podfile snippet.

`restricted` on iOS differs from `denied`. PHPicker also has a documented HEIC limitation on the iOS Simulator; a physical device remains a test gate.

### Keep accessibility and telemetry in the release gate

A camera preview has no accessible meaning by itself. The UI needs:

* instruction text instead of relying only on a colored frame;
* labels and states for image selection, retry, Settings, close, and torch;
* errors announced through a live region when the phase changes;
* sufficiently large touch targets and a layout that survives text scaling;
* a usable fallback when the camera is unavailable.

Labels, permission explanations, and errors must pass through localization. Translation organization is covered in:

{% content-ref url="/pages/4HPxogOT3HRVQ8Hwj6mG" %}
[Multi-Language](/flutter/my-flutter/ui-media/multi-language.md)
{% endcontent-ref %}

Telemetry accepts an allowlisted field set:

```dart
enum ScanInputSource { camera, gallery }

enum ScanOutcomeClass { accepted, rejected, unavailable, failed }

void trackScanOutcome({
  required ScanInputSource source,
  required ScanFormat format,
  required ScanOutcomeClass outcome,
  required int durationBucketMs,
});
```

Do not pass `rawValue`, raw bytes, an image, a path, a parsed URL, an account, contact or Wi-Fi data, or a raw platform exception. In the reference source, a raw scan string was passed into a tracking call; the public pattern deliberately removes that behavior.

A scanner is only one stage in a larger identity flow. For boundaries between QR bootstrap, camera capture, MRZ or NFC, provider SDKs, and backend verification, see:

{% content-ref url="/pages/qCO67hUmb5IbFezVsRGr" %}
[Secure Multi-Provider eKYC in Flutter](/flutter/my-flutter/security-observability/secure-multi-provider-ekyc-flutter.md)
{% endcontent-ref %}

### Test the coordinator with a fake driver

The fake driver records operations so ordering can be tested without a camera:

```dart
final class FakeScannerDriver implements ScannerDriver {
  final operations = <String>[];
  int activeOperations = 0;
  int maxConcurrentOperations = 0;
  Set<ScanFormat>? analyzedFormats;

  @override
  Stream<ScanCapture> get captures => const Stream.empty();

  @override
  Future<void> start() => _record('start');

  @override
  Future<void> pause() => _record('pause');

  @override
  Future<void> stop() => _record('stop');

  @override
  Future<ScanCapture?> analyzeImage(
    String path, {
    required Set<ScanFormat> formats,
  }) async {
    analyzedFormats = formats;
    return const ScanCapture([
      DecodedCode(
        format: ScanFormat.qrCode,
        rawValue: 'example:demo-123',
      ),
    ]);
  }

  @override
  Future<void> dispose() async {}

  Future<void> _record(String operation) async {
    operations.add(operation);
    activeOperations += 1;
    maxConcurrentOperations = max(
      maxConcurrentOperations,
      activeOperations,
    );
    await Future<void>.delayed(Duration.zero);
    activeOperations -= 1;
  }
}
```

This block requires `dart:async` and `dart:math`. In the real race test, the fake holds the first `start()` with a `Completer`; the test sends `stop()` while start remains in flight, then releases the gate.

The sample's two minimum tests assert:

```dart
expect(driver.operations, ['start', 'stop', 'start']);
expect(driver.maxConcurrentOperations, 1);
expect(driver.analyzedFormats, {ScanFormat.qrCode});
```

The full feature uses this test pyramid:

| Layer                | Coverage                                                            |
| -------------------- | ------------------------------------------------------------------- |
| Permission unit      | granted, denied, permanent, restricted, and Settings return         |
| Coordinator unit     | `shouldRun`, operation order, stale completion, terminal dispose    |
| Capture unit         | allowlist, raw value, duplicate, multiple code, parser rejection    |
| Gallery unit         | cancel, no code, same formats, unsupported, exception, lost data    |
| Widget               | phase UI, retry or Settings action, shared scan rect, semantics     |
| Navigation/lifecycle | pause before route or picker, conditional resume                    |
| Native config        | merged Android manifest, iOS usage descriptions and macros          |
| Device               | camera, one-time grant, revoke, torch, focus, background and resume |

A golden test fits the shell, overlay, and permission panel after viewport, fonts, theme, and animation are fixed. It does not prove camera decoding or a system permission dialog. The widget, golden, and device-test boundary is covered in:

{% content-ref url="/pages/OXzEEJ7gphdUcYxkfusW" %}
[Widget Tests and Golden Regression](/flutter/my-flutter/quality-delivery/widget-test-golden-regression.md)
{% endcontent-ref %}

### Troubleshoot common failures

| Symptom                                                 | Likely cause                                 | Fix                                                               |
| ------------------------------------------------------- | -------------------------------------------- | ----------------------------------------------------------------- |
| Black preview after resume                              | Start and stop overlap                       | Use one coordinator and queue; recheck desired state after awaits |
| One code submits twice                                  | Relies only on plugin dedupe                 | Set processing before awaits, stop the camera, then parse         |
| A code outside the frame is scanned                     | Overlay is not connected to `scanWindow`     | Use one rect for decoder and painter                              |
| Camera accepts only QR but gallery accepts another type | `analyzeImage` receives no formats           | Pass `policy.allowedFormats` through both paths                   |
| Parser loses information                                | Uses `displayValue`                          | Use `rawValue` or `rawDecodedBytes` and validate                  |
| Every denial opens Settings                             | Permission states are collapsed              | Separate denied, permanent, and restricted                        |
| Status remains denied after Settings                    | Uses stale status                            | Wait for resume, then call `check()` again                        |
| Camera runs beneath the picker                          | Lifecycle callback starts directly           | Gate with `pickerOpen`, `processing`, and `pageVisible`           |
| Gallery cancellation shows an error                     | Cancel, no-code, and exception are collapsed | Give each outcome a type                                          |
| Android app is filtered from Play                       | CAMERA implies hardware features             | Declare `uses-feature` according to real capability               |
| iOS terminates when camera opens                        | Usage description is missing                 | Add `Info.plist` keys and check each build configuration          |
| Camera survives route pop                               | Async disposal is ignored                    | Await `close()` before pop and keep a terminal guard              |
| Analytics contains sensitive data                       | Raw payload or error is logged               | Keep only coarse categories and allowlisted reasons               |

### Verified evidence and scope

I reviewed runtime source at a fixed commit using Flutter 3.41.2, Dart 3.11.0, Android min SDK 24 and target 36, and iOS deployment target 15.0. The source evidence confirms a QR-only live format, no-duplicates detection, `autoStart: false`, serialized operations, pausing before processing or gallery selection, and stopping before navigation.

The research also found an overlay not connected to `scanWindow`, a gallery path without the same format allowlist, a parser using `displayValue`, collapsed permission states, and a raw payload passed to a tracking call. This article keeps the useful patterns while correcting those gaps in the public sample.

Public sample results:

```
flutter analyze lib/main.dart test/scanner_coordinator_test.dart
No issues found!

flutter test test/scanner_coordinator_test.dart
00:00 +2: All tests passed!

JAVA_HOME=<jdk-17> ./gradlew assembleDebug
BUILD SUCCESSFUL
```

The Android debug build used JDK 17 and completed both manifest merging and plugin compilation. The iOS Simulator build stopped before compilation because CocoaPods was unavailable on the research machine; I do not record an iOS build pass.

The focused widget test in the reference source did not reach assertions. Its first run was blocked while resolving a private Git dependency; the `--no-pub` run lacked hosted and private packages plus generated source. This is an environment blocker, not a test pass or failure.

I have not verified these behaviors on a device:

* camera permission dialogs on a fresh install;
* one denial, permanent denial, restriction, and Settings recovery;
* Android one-time permission and auto-reset;
* backgrounding while native start, parsing, or the picker is active;
* scan-window coordinates with preview cropping;
* several QR codes in one frame;
* HEIC, rotated, and large images;
* torch, focus, and camera unavailable states;
* TalkBack, VoiceOver, and large text;
* native disposal after repeated route transitions.

Official documentation used to verify APIs and platform behavior:

* [Flutter `AppLifecycleListener`](https://api.flutter.dev/flutter/widgets/AppLifecycleListener-class.html)
* [`mobile_scanner` 7.4.0](https://pub.dev/packages/mobile_scanner)
* [`MobileScanner` and `scanWindow`](https://pub.dev/documentation/mobile_scanner/latest/mobile_scanner/MobileScanner-class.html)
* [`MobileScannerController`](https://pub.dev/documentation/mobile_scanner/latest/mobile_scanner/MobileScannerController-class.html)
* [`Barcode.rawValue` and `displayValue`](https://pub.dev/documentation/mobile_scanner/latest/mobile_scanner/Barcode-class.html)
* [`permission_handler` 12.0.1](https://pub.dev/packages/permission_handler/versions/12.0.1)
* [`permission_handler` changelog](https://pub.dev/packages/permission_handler/changelog)
* [`image_picker` 1.2.3](https://pub.dev/packages/image_picker)
* [Android runtime permissions](https://developer.android.com/training/permissions/requesting)
* [Android permission minimization and Photo Picker](https://developer.android.com/privacy-and-security/minimize-permission-requests)
* [Android `<uses-feature>`](https://developer.android.com/guide/topics/manifest/uses-feature-element)
* [Apple camera authorization](https://developer.apple.com/documentation/AVFoundation/requesting-authorization-to-capture-and-save-media)
* [Apple `NSCameraUsageDescription`](https://developer.apple.com/documentation/bundleresources/information-property-list/nscamerausagedescription)

## Conclusion

Once the camera becomes an owned resource, a scanner is no longer `MobileScanner(onDetect:)` plus an overlay. Permission, lifecycle, picker, processing, and navigation become state-machine facts; start and stop pass through one queue; camera and gallery share one scan policy.

I treat `rawValue` as untrusted input, parse it into a typed result, and keep it out of logs. The overlay uses the real `scanWindow`, permission denials are classified, and native configuration is checked per platform.

This design fits a scan result that leads to a side effect or an app that must survive backgrounding, Settings, and an image picker. A prototype that only displays a local string can simplify the UI, but it should still keep a format allowlist, single-flight processing, and a rule against automatically opening actions from unvalidated QR values. Device testing remains the last condition before calling the scanner production-ready.

[Buy Me a Coffee](https://buymeacoffee.com/ducmng12g) | [Support Me on Ko-fi](https://ko-fi.com/I2I81AEJG8)
