Understanding Flutter Method Channels Through a Real Production Bug

Leader posted 5 min read

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

  1. Keep debugging blindly inside Flutter
  2. 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.

2 Comments

2 votes
1

More Posts

React Native Quote Audit - USA

kajolshah - Mar 2

Building OneRule: A Technical Deep Dive into an Offline Password Manager with Flutter, SQLCipher, and AES-GCM

Fatih İlhan - Apr 13

Building “R U Ready” – My Flutter Dating App Development Journey

Dilip Kumar - Nov 4, 2025

Why I Started Creating Models in My Flutter Project

yogirahul - Aug 20, 2025

Build Reliable Local Notifications in Flutter (Step-by-Step)

Mxolisi Masuku - Apr 20
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

5 comments
2 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!