Introduction
Flutter is amazing—until it isn’t.
Most of the time, you can build entire apps without ever touching native code. But the moment you hit a platform limitation, things get… interesting.
This blog isn’t just about Method Channels.
It’s about:
- When Flutter abstractions break down
- How to take control using native Android
- And how to think about Flutter ↔ Native communication like a system, not a hack
Funny enough, this blog comes from a real bug I hit while building my app Anahad — a saadhna app that plays mantra chants.
And yeah… it broke in a very weird way.
The Real Problem (Why This Blog Exists)
I was building a saadhna app that plays mantra chants on loop.
Simple requirement:
- Play audio
- Loop it continuously
- Run in the background (screen off)
I was using:
audio_service
just_audio
just_audio_background
Everything worked perfectly…
Until it didn’t.
The Bug
After ~940–960 loops on Android (specifically Android 12+):
- Playback would stop silently
- No crash
- No exception
- Only when the screen was off
Even after:
- Disabling battery optimization
- Proper background setup
Still broke.
The Weird Part
It consistently failed around the same loop range (~940–960).
Not 100. Not 500. Not random.
That kind of consistency usually means:
Something deeper is going on — not just app-level logic.
At This Point, Two Choices
- Keep debugging blindly inside Flutter
- Drop down to native Android and take control
I chose option 2, cz i never interacted with native code and wanted to explore it
Fun part, i had an android 11 device which wasn't having this bug at all, tested on other android 11 devices as well
Why Flutter Isn’t Always Enough
Flutter is great, but it abstracts away platform details.
That’s usually good—until you need:
- Deep lifecycle control
- System-level integrations
- Debug visibility into platform behavior
Common cases where native is needed:
- Background services
- Media playback control
- Hardware interaction
- Platform-specific APIs
- Performance-critical logic
In my case:
I needed more control over how Android handles long-running audio playback. Just to be absolutely sure that it was not the issue of the package that i was using
Understanding Method Channels (The Right Mental Model)
Before code, get this clear:
Dart is the client. Native Android is the server.
When you write:
platform.invokeMethod("play")
````
You’re essentially doing:
* Dart → sends request
* Android → receives it
* Executes native logic
* Returns response
Think of it like:
> **An internal API call inside your app**
No HTTP. No network. Just a binary bridge.
Once you understand this, Method Channels stop feeling “magical”.
---
## Architecture Overview
Flutter (Dart)
├── UI Layer
├── Service Layer (PlaybackService.dart)
└── State Management
↓↓↓ Method Channel ↓↓↓
Android (Kotlin)
├── MethodChannel Handler
├── PlaybackService (MediaSessionService)
└── ExoPlayer
---
### Flow (Step-by-Step)
1. User taps "Play"
2. Dart calls `invokeMethod("play")`
3. MethodChannel sends message
4. Kotlin receives it
5. Native service executes logic
6. Result is sent back
---
## The Native Side: Android (Kotlin)
### Why MediaSessionService?
Instead of a basic service, we use:
> `MediaSessionService`
Because it:
* Integrates with system controls (lock screen, notifications)
* Handles background playback more reliably
* Works with audio focus and lifecycle events
* Plays nicely with the Android system
---
### PlaybackService.kt
```kotlin
class PlaybackService : MediaSessionService() {
private var mediaSession: MediaSession? = null
override fun onCreate() {
super.onCreate()
val audioAttributes =
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.build()
val player =
ExoPlayer.Builder(this)
.setAudioAttributes(audioAttributes, true)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK)
.build()
player.addListener(object : Player.Listener {
override fun onPlayerError(error: PlaybackException) {
Log.e("PlaybackService", "Error: ${error.message}", error)
}
})
val callback = object : MediaSession.Callback {}
mediaSession = MediaSession.Builder(this, player)
.setCallback(callback)
.build()
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
mediaSession = null
}
super.onDestroy()
}
}
The Flutter Side: Dart
Service Wrapper
class PlaybackService {
static const platform =
MethodChannel('com.theahambrahmasmi.anahad/playback');
static Future<void> play(String url) async {
await platform.invokeMethod('play', {'url': url});
}
static Future<void> pause() async {
await platform.invokeMethod('pause');
}
static Future<Map<String, dynamic>> getStatus() async {
final result = await platform.invokeMethod('getStatus');
return Map<String, dynamic>.from(result);
}
}
Kotlin: Method Channel Handler
class PlaybackMethodHandler(private val context: Context) {
fun setupChannel(flutterEngine: FlutterEngine) {
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
"com.theahambrahmasmi.anahad/playback"
).setMethodCallHandler { call, result ->
when (call.method) {
"play" -> {
val url = call.argument<String>("url")
val intent = Intent(context, PlaybackService::class.java)
intent.putExtra("audio_url", url)
context.startService(intent)
result.success(null)
}
"pause" -> {
val intent = Intent(context, PlaybackService::class.java)
context.stopService(intent)
result.success(null)
}
else -> result.notImplemented()
}
}
}
}
Real-World Insight: The Looping Problem
If you're building:
- Meditation apps
- Chanting apps
- Ambient loop apps
Be careful.
Android may silently stop long-running playback after extended loops.
Even with:
- Battery optimization disabled
- Background services configured
Test aggressively:
- 500+ loops
- Screen-off playback
- Different OEM devices
This is not just Flutter—it’s often Android system behavior.
What About The Root Cause?
Short answer:
I don’t fully know yet.
And yeah — that’s intentional honesty.
What I do know:
- It only happens on Android 12+
- It triggers after long-running background playback
- It happens silently (no crash, no logs)
- It correlates with a high loop count (~950)
Current Hypotheses:
- Doze / background execution limits
- Audio focus or media session interruption
- ExoPlayer internal behavior after prolonged looping
- OEM-level process killing
- Resource exhaustion over time
We’ll Be Updating This
Once I dig deeper and find the exact root cause, I’ll update this blog with:
- What exactly broke
- Why Android 12+ behaves this way
- The proper fix (not just experimental ones)
- What you should do in production
Because honestly?
Debugging something that breaks at the OS level is messy… and kinda fun.
Final Thoughts
I eventually moved back to just_audio because:
- The problematic flow was no longer needed
So yeah…
This experimental fix wasn’t used in production.
But it gave me something way more valuable:
A deeper understanding of how Flutter actually works under the hood.
And honestly?
I’m not perfect. I didn’t fully solve it (yet).
But now I know one more thing than I did before.
And that’s what matters.
TL;DR
- Flutter is great, but not enough for everything
- Method Channels = internal RPC bridge
- Native gives you control Flutter abstracts away
- Android background behavior can be unpredictable
- Sometimes… you won’t have all the answers immediately
If you're building something serious:
Don’t just rely on abstractions. Understand what’s underneath.
That’s where real engineering begins.