Flutter Android

Flutter Platform Channels:
Calling Native Kotlin APIs

By Ankit Kumar, Founder of Kyvronix Technologies · 15 min read

Flutter is incredible for building UIs, but there are times when you need access to deep OS-level APIs that pub.dev packages don't cover—or don't cover well enough. When building PhoneGuard, an enterprise-grade anti-theft app, we needed to lock the device screen programmatically using the Device Admin API, intercept SMS using ContentObserver, and capture stealth photos using CameraX. None of this can be done purely in Dart.

In this article, I'll show you exactly how to use Flutter Platform Channels (specifically MethodChannel) to bridge the gap between Dart and Native Kotlin.

1. How MethodChannels Work

A MethodChannel provides an asynchronous messaging tunnel between the Flutter engine (Dart) and the Host OS (Kotlin/Swift). You send a message with a string identifier (the "method name") and optional arguments. The Host OS listens, executes native code, and returns a result back to Dart.

2. Setting Up the Dart Side

Let's define a service class in Flutter that will talk to our native Android layer.

// lib/services/native_api_service.dart
import 'package:flutter/services.dart';

class NativeApiService {
  // The channel name must match exactly on both Dart and Kotlin sides.
  static const MethodChannel _channel = MethodChannel('com.kyvronix.phoneguard/native');

  Future<bool> lockDevice() async {
    try {
      final bool result = await _channel.invokeMethod('lockDevice');
      return result;
    } on PlatformException catch (e) {
      print("Failed to lock device: '${e.message}'.");
      return false;
    }
  }

  Future<void> sendEmergencySms(String number, String message) async {
    try {
      await _channel.invokeMethod('sendSms', {
        'number': number,
        'message': message,
      });
    } on PlatformException catch (e) {
      print("SMS failed: '${e.message}'.");
    }
  }
}
  

3. Setting Up the Kotlin Side (Android)

Open android/app/src/main/kotlin/.../MainActivity.kt. We'll set up the MethodChannel inside the configureFlutterEngine method.

package com.kyvronix.phoneguard

import android.app.admin.DevicePolicyManager
import android.content.Context
import android.telephony.SmsManager
import androidx.annotation.NonNull
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.kyvronix.phoneguard/native"

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            when (call.method) {
                "lockDevice" -> {
                    val success = lockDevice()
                    if (success) {
                        result.success(true)
                    } else {
                        result.error("UNAVAILABLE", "Device Admin not enabled.", null)
                    }
                }
                "sendSms" -> {
                    val number = call.argument<String>("number")
                    val message = call.argument<String>("message")
                    if (number != null && message != null) {
                        sendSms(number, message)
                        result.success(null)
                    } else {
                        result.error("INVALID_ARGS", "Number or message is null", null)
                    }
                }
                else -> {
                    result.notImplemented()
                }
            }
        }
    }

    private fun lockDevice(): Boolean {
        val dpm = getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
        // In a real app, you'd check if your ComponentName is active first
        return try {
            dpm.lockNow()
            true
        } catch (e: SecurityException) {
            false
        }
    }

    private fun sendSms(number: String, message: String) {
        val smsManager = getSystemService(SmsManager::class.java)
        smsManager.sendTextMessage(number, null, message, null, null)
    }
}
  

4. Handling Async Background Tasks

What if your native Kotlin code takes a long time? For example, initializing a CameraX session to take a stealth selfie.

In this case, you cannot block the main thread. MethodChannel.Result must be called on the main thread, but your heavy lifting should be pushed to a coroutine or background thread.

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

// Inside MainActivity.kt
"takeStealthPhoto" -> {
    // Launch background task
    CoroutineScope(Dispatchers.IO).launch {
        val base64Image = capturePhotoSilently()
        
        // Return result on Main Thread
        runOnUiThread {
            result.success(base64Image)
        }
    }
}
  

5. Event Channels for Streams

If you need continuous updates (like GPS coordinates flowing from Native to Dart), a MethodChannel isn't enough. You need an EventChannel. Event channels allow you to open a stream on the Dart side and push data continuously from the Kotlin side using an EventSink.

Conclusion

Platform channels are the superpower that breaks Flutter out of its sandbox. By understanding how to write Kotlin bindings, you can tap into the full power of the Android SDK without waiting for someone else to write a plugin.

— Ankit Kumar