Version constraints satisfied: three green checkmarks. Runtime crash on 40% of test devices within an hour of install. That was the state of a Flutter build we were brought in to unblock — a camera capture flow that worked flawlessly on the founders’ Pixel 7s and failed silently on a stack of Samsung and Xiaomi devices running Android 13. `flutter pub get` had exited cleanly. `flutter pub deps` showed no conflicts. Pub’s solver had done exactly what it promises: found a set of package versions whose declared constraints are mutually satisfiable. It never promised those versions would behave correctly together at runtime, and that distinction is where this bug lived.

The setup: three plugins, one shared platform channel

The app used `camera: ^0.10.5`, `permission_handler: ^10.4.3`, and a third-party QR overlay plugin pinned to a specific patch version because of a UI regression in later releases. Individually, every one of these resolved fine. The problem was that `camera` 0.10.5 and the QR plugin both wrapped Android’s `CameraX` lifecycle, and each assumed it owned the `Activity`’s permission callback registration. `permission_handler` sat in between, brokering the actual OS-level permission request.

Pub’s constraint solver operates purely on semver ranges declared in `pubspec.yaml`. It has no concept of “these two Android embeddings both call `registerActivityResultLauncher` and the second registration silently no-ops the first.” That’s not a version conflict in the Dart sense — both packages declare compatible Flutter SDK constraints, both compile, both pass their own unit tests. It’s a runtime contract violation between two native Android implementations, and nothing in the pub ecosystem is positioned to catch it, because that information doesn’t exist in a `pubspec.yaml` file. It exists in each plugin’s `android/src/main/kotlin` directory, if it exists in writing anywhere at all.

The failure mode was specific: on API 33 devices, when the QR plugin initialized first (a timing difference tied to how those OEMs schedule widget build order), its permission callback registration won the race. `permission_handler`’s subsequent request for `CAMERA` returned `PermissionStatus.denied` even when the user had tapped “Allow” in the system dialog — because the granted-callback fired against the wrong listener. The app then showed a permission-denied screen to a user who had, from their perspective, just granted permission. No exception thrown. No log line pointing at the cause. Just a state mismatch between what Android’s `PackageManager` reported and what the plugin’s Dart-side stream said.

Diagnosing it: bypass Dart, read the native layer

The Dart stack trace was useless here because there wasn’t one — this was a logic bug in event ordering, not a crash. The diagnostic process that actually worked:

  • Reproduced on a physical device (not an emulator — emulators run a single CameraX provider path that doesn’t trigger the race) with `adb logcat` filtered to the app’s PID, watching for duplicate `onRequestPermissionsResult` registrations rather than errors.
  • Decompiled the QR plugin’s AAR to confirm it called `ActivityCompat.requestPermissions` directly instead of delegating to the host activity’s registered launcher — a shortcut that works in isolation and breaks the moment another plugin expects to own that channel.
  • Pinned `camera` down two minor versions and up one, bisecting to confirm the interaction was present across the whole 0.10.x line, not a regression in one release — this ruled out “just upgrade” as a fix and confirmed it was an architectural incompatibility, not a bug fixed upstream.
  • Wrote a minimal reproduction app with just the two conflicting plugins and no app logic, to get a clean report to file upstream and to have a fast, deterministic test case rather than relying on the flaky full-app repro.

The fix was to drop the QR plugin entirely and implement the overlay against `camera`’s own image stream with a barcode-scanning package that doesn’t touch the Android permission lifecycle at all — `google_mlkit_barcode_scanning` operating on `CameraImage` frames rather than owning its own camera session. More native surface area touching the same OS lifecycle hooks is a liability, not a convenience, even when each package’s version constraints look compatible on paper.

What this changes about how we vet dependencies

The practical takeaway isn’t “read more changelogs.” It’s that for any plugin doing native platform integration — camera, permissions, background execution, biometrics — the Dart-level API surface tells you almost nothing about lifecycle ownership conflicts. Before adding a second plugin that touches the same native subsystem, we now check whether it registers its own `ActivityResultLauncher` / delegate versus routing through the host activity, which is usually visible in five minutes of reading the plugin’s `android` source folder. We also test permission flows specifically on mid-range Android devices at the current and previous major API level, since timing-dependent races like this one often don’t reproduce on flagship hardware or emulators at all.

This is the kind of failure that doesn’t show up in a demo and doesn’t show up in CI — it shows up in production, on the devices you didn’t test on, weeks after “it works on my machine.” It’s also a fairly representative example of what a code audit at orithLabs looks for: not just whether the app compiles and the happy path works, but whether the dependency graph has invisible contracts between packages that only break under specific runtime conditions. We’ve run into version-skew bugs like this while building Crumb Count and Kompete, and the fix is rarely “upgrade” — it’s usually “read the native code you’re depending on before you trust the version number.”