A Flutter app that implements every pattern for communicating between Dart and native platform code — MethodChannel, EventChannel, Pigeon, and dart:ffi — with working Android handlers, a full test suite, and CI.
I built this to demonstrate a skill that's fundamental to senior-level Flutter work: understanding how the framework boundary actually works, not just which package to install. It's one piece of a broader portfolio; each repo isolates a different area of mobile engineering depth.
| Pattern | Screen | Status | When you'd use it |
|---|---|---|---|
| MethodChannel | Battery level + state | Functional | One-shot native calls: battery, clipboard, device info |
| EventChannel | Live accelerometer | Functional | Continuous native streams: sensors, location, Bluetooth |
| Pigeon | Concept showcase | Display-only | Any channel call at scale — eliminates stringly-typed boilerplate |
| dart:ffi | Concept showcase | Display-only | C/C++ libraries: image processing, crypto, ML inference |
The two functional demos make real native calls through Android's BatteryManager and SensorManager. The two concept screens show annotated code walkthroughs and tradeoff comparisons — they exist to demonstrate awareness of the full landscape, not to pad the demo count.
The only dependencies are flutter, flutter_test, and flutter_lints. That's
deliberate. This project is about the platform boundary itself — adding packages
would obscure whether I understand the underlying mechanism or just know which
import to reach for. Every line of channel code, serialization handling, and
lifecycle management is visible in the repo.
I implemented MethodChannel and EventChannel end-to-end because they're the
patterns a Flutter developer actually writes by hand. Pigeon and FFI are shown as
annotated code walkthroughs with tradeoff tables because implementing them would
add build complexity (code generation, C compilation) without demonstrating
additional judgment — the interesting decision with Pigeon is when to adopt it,
not how to run dart run pigeon.
SensorStream.accelerometerEvents is a late final field, not a getter. This
matters: EventChannel.receiveBroadcastStream() creates a new native listener
subscription each time it's called. A getter that calls it on every access would
silently leak native sensor listeners. The late final pattern ensures one
native subscription per SensorStream instance, with onListen/onCancel
firing correctly on each Dart subscribe/unsubscribe cycle. I found this bug
during development — the fix is small but the failure mode is subtle.
MethodChannelScreen._fetchBatteryLevel() has four mounted checks — after the
service calls, in both catch blocks, and in finally. Verbose, but every async
gap in a StatefulWidget is a potential setState-after-dispose crash. I'd rather
be explicit than debug a race condition.
┌─────────────────────────────────────────────────────────────────┐
│ Flutter (Dart) │
│ │
│ MethodChannelScreen ──► BatteryService ──► MethodChannel ──────┤──►
│ (typed async) (binary codec) │
│ │ Native (Kotlin)
│ EventChannelScreen ──► SensorStream ──► EventChannel ──────────┤──►
│ (late final (broadcast stream) │
│ cached stream) │
│ │ MainActivity.kt
│ PigeonScreen ─────── display-only ─────────────────────────────┤ ├─ MethodCallHandler
│ FfiScreen ────────── display-only ─────────────────────────────┤ │ getBatteryLevel()
└─────────────────────────────────────────────────────────────────┘ │ getBatteryState()
│ getBatteryInfo()
│
└─ AccelerometerStreamHandler
onListen → registerListener
onCancel → unregisterListener
Each pattern is self-contained in its own directory under lib/. The service
layer (BatteryService, SensorStream) wraps the raw channel and exposes typed
Dart APIs — the UI never touches MethodChannel or EventChannel directly.
On the native side, MainActivity.kt registers both handlers in
configureFlutterEngine. The accelerometer handler implements proper lifecycle
management: sensor listener is registered in onListen and unregistered in
onCancel, with references nulled out to avoid leaks.
| MethodChannel | EventChannel | Pigeon | dart:ffi | |
|---|---|---|---|---|
| Data flow | Dart → Native → Dart | Native → Dart (stream) | Dart ↔ Native | Dart ↔ Native |
| Type safety | Runtime (Map) | Runtime (Map) | Compile-time (generated) | Compile-time (typedefs) |
| Serialization | StandardMethodCodec | StandardMethodCodec | StandardMethodCodec | None — direct ABI |
| Boilerplate | Manual per method | Manual handler | Code-generated | Manual typedefs |
| Error model | PlatformException | Stream onError | Typed exceptions | Native crashes / segfaults |
| Best for | One-shot calls | Continuous events | Scaling past 5+ methods | C/C++ library bindings |
Platform channels are awkward to test because the native side doesn't exist in
flutter test. The strategy here splits along that boundary:
Fully testable (100% coverage): BatteryService and MethodChannelScreen
are tested by intercepting the channel with
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler.
This covers the happy path, null fallbacks, PlatformException propagation,
loading states, and error display — everything on the Dart side of the boundary.
Partially testable (~40-47%): SensorStream and EventChannelScreen can
verify stream caching (identical(stream1, stream2)) and initial widget state,
but the stream callback paths require a real native sensor. That's not a coverage
gap I'd try to close with more mocks — it would test the mock, not the code.
30 tests total across 6 files. CI gates at 60% overall, which is honest given that half the screens are display-only and EventChannel callbacks need hardware.
iOS native handlers. The Dart side is platform-agnostic, but I scoped native implementation to Android. Adding iOS would mean Swift/CoreMotion for the accelerometer and UIDevice for battery — same patterns, different APIs. The architecture supports it without changes to the Dart layer.
Live Pigeon codegen or FFI calls. The concept screens show the code and explain the tradeoffs. Making them functional would require build-time code generation (Pigeon) and C compilation (FFI), adding toolchain complexity that doesn't demonstrate additional engineering judgment for this project's purpose.
State management, DI, networking. Deliberately excluded. This repo is about one thing. Those are covered in other portfolio projects.
lib/
├── main.dart Entry point
├── app.dart MaterialApp, Material 3 theming
├── home_screen.dart Navigation hub (4 demo cards)
├── method_channel/
│ ├── battery_service.dart Typed MethodChannel wrapper
│ └── method_channel_screen.dart Battery demo UI + state management
├── event_channel/
│ ├── sensor_stream.dart Typed EventChannel wrapper (late final cache)
│ └── event_channel_screen.dart Live accelerometer UI + start/stop lifecycle
├── pigeon/
│ └── pigeon_screen.dart Code walkthrough + comparison table
└── ffi/
└── ffi_screen.dart Code walkthrough + memory model diagram
android/app/src/main/kotlin/.../
└── MainActivity.kt BatteryManager + SensorManager handlers (99 lines)
test/ 30 tests across 6 files, mirroring lib/ structure
Requires Flutter stable channel (Dart SDK 3.8+, per pubspec.yaml) and an
Android device or emulator — the MethodChannel and EventChannel demos make real
native calls that won't work without one.
flutter pub get
flutter run # Needs a connected device
flutter test # 30 tests, no device needed
flutter analyze # Lint + type checksGitHub Actions runs on every push and PR to main: static analysis
(--fatal-infos), format enforcement (dart format --set-exit-if-changed),
tests with coverage, and a coverage gate that fails the build below 60%. The
gate uses raw awk on the lcov output rather than requiring the lcov CLI —
one less tool dependency in the pipeline.
- iOS parity — Swift handlers for battery (UIDevice) and accelerometer (CMMotionManager), same channel names, no Dart changes needed.
- Make Pigeon functional — run the codegen end-to-end to show the generated type-safe API replacing raw MethodChannel calls. This is the strongest upgrade because Pigeon is what you'd actually adopt in a production codebase scaling past a handful of channel methods.
- Bidirectional communication — add an example where native code initiates a call to Dart (e.g., push notifications or deep links), showing the reverse direction of the bridge.
This is one piece of a portfolio covering different aspects of senior mobile engineering:
| Repository | Focus area |
|---|---|
| compose-newsreader | Multi-module Android, Hilt, Room, offline-first |
| android-design-system | Compose component library, design tokens |
| compose-performance-lab | Compose performance anti-patterns and fixes |
| kmp-currency-converter | Kotlin Multiplatform, shared business logic |
| flutter-expense-tracker | Clean Architecture, Riverpod, Drift, Material 3 |
| flutter-ui-toolkit | Reusable widgets, shimmer, adaptive layouts |
| flutter-platform-bridge (this repo) | Platform channels, native interop |
| mobile-ci-cd-templates | CI/CD, release automation, infrastructure |
Copyright 2026 Hendra Marihot. Apache License 2.0 — see LICENSE for details.