The crash report had one line that mattered: “works fine unfolded, freezes on fold.” Kompete’s live-scoring screen called a native MethodChannel to pull cached match state on resume, and on a folded-to-unfolded transition on the Pixel Fold, roughly one in five attempts, the call just never returned. No exception, no native crash log, nothing in logcat past the invoke. The Dart side sat there awaiting a Future that was never going to complete.

That “roughly one in five” is the tell. Deterministic bugs get fixed in the first session. Bugs that show up on one device family at a fraction of attempts are almost always a race, and races only surface when something changes the timing envelope enough to expose them. Foldables change the timing envelope more than most people expect, because a fold/unfold isn’t just a resize event — on API 30+ it can tear down and recreate the Activity, and on multi-window or multi-resume configurations it can do that while a previous Activity instance is still mid-teardown.

Where the race actually lived

Flutter’s platform channels are bound to a `FlutterEngine`, and that engine is bound to a Dart isolate running on the UI thread of whichever `Activity`/`FlutterActivity` currently owns it. The Kompete app used a single cached engine (the standard pre-warm-for-startup-speed pattern via `FlutterEngineCache`), attached to a new `Activity` on each resume. The MethodChannel itself was a thin static wrapper — one `MethodChannel` instance, constructed once, held as a singleton on the Dart side.

The failure sequence, once we instrumented `onAttachedToEngine`/`onDetachedFromEngine` with timestamps, looked like this:

  • Fold event triggers Android to tear down the current Activity and stand up a new one against the same cached engine.
  • The old Activity’s `onDetachedFromEngine` hadn’t finished unregistering its `MethodChannel.MethodCallHandler` before the new Activity’s `onAttachedToEngine` registered a new one on the same channel name.
  • The Dart-side `invokeMethod` call landed in the window between detach and attach — dispatched to a handler reference that Android had already begun disposing, on a plugin binding that was in the process of being torn down.
  • Because the native handler was mid-teardown, it neither called `result.success()` nor `result.error()`. It just dropped the call silently. No exception to surface, because nothing threw — the callback simply never fired.

This is the sharp edge of the multi-engine-instance-per-Activity model that most Flutter platform channel guides gloss over: MethodChannel has no notion of “this call belongs to Activity generation N.” It only knows the channel name and whatever handler is currently registered. On a device that never recreates the Activity mid-session, you’ll never see this. On a foldable that recreates it on every hinge event, you’ll see it constantly if a call happens to be in flight at the wrong microsecond.

The dispatcher pattern that fixed it

The fix wasn’t a retry loop or a timeout — those treat the symptom and leave a silent-drop window that will eventually eat a real user action, not just a background refresh. The actual fix was making the channel’s method call handler generation-aware, so a call from a stale isolate/engine binding can be rejected explicitly instead of silently swallowed, and so the Dart side never awaits a call whose handler is already gone.

Concretely, on the native side we replaced the direct handler registration with a small dispatcher that tracks the current attachment generation:

  • Each `onAttachedToEngine` increments a generation counter and registers the handler for that generation only.
  • `onDetachedFromEngine` immediately nulls out the active handler reference and completes any in-flight `Result` objects it’s holding with an explicit error code (`”engine_detached”`) rather than leaving them unresolved — Android’s `MethodChannel` will silently drop a `Result` that’s never completed, so this step alone closes most of the gap.
  • The Dart-side wrapper catches that specific error code and retries once against the freshly attached channel instance, rather than retrying blind against a channel reference that might still be pointing at the torn-down handler.

The key invariant is: every `Result` object handed to native code either resolves or errors, on every code path, including teardown. That sounds obvious, but the default Flutter plugin template doesn’t enforce it — it’s easy to write a handler that assumes it’ll always get to call `result.success()` and never audits the disposal path. On single-Activity-lifetime devices that gap is invisible for years. On a device that treats folding as an Activity recreation event, it’s a five-minute repro.

Why this only shows up on real hardware

None of this reproduced in the emulator’s foldable posture simulation, because the emulator doesn’t recreate the Activity the same way — it resizes the window and fires configuration-changed callbacks without tearing down and reattaching the Flutter engine binding. Kompete’s team had foldable configs in their test matrix and still missed it, because “test on a foldable” and “test the actual multi-window teardown/reattach sequence” are different test plans. The bug only surfaced once we scripted repeated fold/unfold cycles against a live network call, on the physical device, specifically timed to land mid-request.

This is the class of bug that architecture-review-from-the-outside tends to catch and code-audit-from-the-inside tends to miss: nothing about the singleton MethodChannel pattern looks wrong in a code review, and nothing about it is wrong on 95% of Android hardware. It only breaks when you understand the isolate/engine lifecycle contract closely enough to ask what happens to in-flight calls during a teardown that most test matrices don’t exercise.

This is representative of the kind of debugging orithLabs does on both sides of the engagement — building the platform channel layer correctly the first time on apps like Kompete and Crumb Count, and going in as outside engineers to find exactly this shape of bug in codebases we didn’t write. If you’ve got a Flutter app with native channel calls that fail in a way nobody can reproduce on demand, that’s a conversation worth having with us directly.