Adding native Android code to a Flutter app
By Charlin Joe · 27 April 2025
Sometimes Flutter's platform channels need Kotlin/Java on Android to access a native API. This guide shows how to add Android native code to a Flutter plugin using method channels.
When you need native code
- Platform APIs not exposed by any Flutter package (e.g., custom hardware, specific system APIs)
- Platform-specific capabilities that existing packages don't cover or don't fit your needs
- Performance-critical code that must run natively
- Integrating a vendor Android SDK
Method channel basics
A MethodChannel is a bidirectional communication pipe between Flutter (Dart) and the native platform. You invoke methods from Dart; Android handles them in Kotlin.
Dart side:
class BatteryService {
static const _channel = MethodChannel('com.example.myapp/battery');
Future<int> getBatteryLevel() async {
try {
final level = await _channel.invokeMethod<int>('getBatteryLevel');
return level ?? -1;
} on PlatformException catch (e) {
throw BatteryException('Failed to get battery level: ${e.message}');
}
}
// Send arguments
Future<bool> setSaverMode(bool enabled) async {
return await _channel.invokeMethod<bool>(
'setSaverMode',
{'enabled': enabled},
) ?? false;
}
}
Kotlin side (android/app/src/main/kotlin/com/example/myapp/MainActivity.kt):
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.os.BatteryManager
class MainActivity : FlutterActivity() {
private val CHANNEL = "com.example.myapp/battery"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
.setMethodCallHandler { call, result ->
when (call.method) {
"getBatteryLevel" -> {
val level = getBatteryLevel()
if (level != -1) result.success(level)
else result.error("UNAVAILABLE", "Battery level not available", null)
}
"setSaverMode" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
result.success(setSaverMode(enabled))
}
else -> result.notImplemented()
}
}
}
private fun getBatteryLevel(): Int {
val batteryStatus: Intent? = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { filter ->
applicationContext.registerReceiver(null, filter)
}
val level = batteryStatus?.getIntExtra(BatteryManager.EXTRA_LEVEL, -1) ?: -1
val scale = batteryStatus?.getIntExtra(BatteryManager.EXTRA_SCALE, -1) ?: -1
return if (level != -1 && scale != -1) (level * 100 / scale.toFloat()).toInt() else -1
}
private fun setSaverMode(enabled: Boolean): Boolean {
// Implementation
return true
}
}
Event channels (native → Flutter streams)
For continuous data from Android (sensor data, location updates, connectivity changes):
Kotlin:
class MainActivity : FlutterActivity() {
private val EVENT_CHANNEL = "com.example.myapp/connectivity"
private var eventSink: EventChannel.EventSink? = null
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
eventSink = events
registerConnectivityReceiver()
}
override fun onCancel(arguments: Any?) {
unregisterConnectivityReceiver()
eventSink = null
}
})
}
private fun onConnectivityChanged(isConnected: Boolean) {
activity?.runOnUiThread {
eventSink?.success(isConnected)
}
}
}
Dart:
class ConnectivityService {
static const _eventChannel = EventChannel('com.example.myapp/connectivity');
Stream<bool> get connectivityStream =>
_eventChannel.receiveBroadcastStream()
.map((event) => event as bool);
}
// Usage:
StreamBuilder<bool>(
stream: connectivityService.connectivityStream,
builder: (context, snapshot) {
if (snapshot.data == false) return const OfflineBanner();
return const SizedBox.shrink();
},
)
Adding a third-party Android SDK
- Add the dependency in
android/app/build.gradle.kts:
dependencies {
implementation("com.vendorname:their-sdk:1.2.3")
}
- Initialize the SDK in
MainActivity.onCreateor the Application class:
class MyApplication : FlutterApplication() {
override fun onCreate() {
super.onCreate()
VendorSdk.initialize(this, apiKey = BuildConfig.VENDOR_API_KEY)
}
}
- Register
MyApplicationinAndroidManifest.xml:
<application
android:name=".MyApplication"
...>
Running code on a background thread
Never block the main thread from native code:
"heavyCompute" -> {
// Run off main thread
Thread {
try {
val result = doExpensiveWork()
// Post back to main thread for result.success()
Handler(Looper.getMainLooper()).post {
result.success(result)
}
} catch (e: Exception) {
Handler(Looper.getMainLooper()).post {
result.error("COMPUTE_FAILED", e.message, null)
}
}
}.start()
}
Common pitfalls
Calling result.success() more than once. Each method call gets exactly one response. Calling result.success() or result.error() twice throws an exception. Ensure only one path calls result in each branch.
Channel name mismatch. The channel name string in Dart and Kotlin must be identical, including capitalization. A mismatch results in MissingPluginException with no obvious error message.
Memory leaks from registered receivers. If you register a BroadcastReceiver or listener in onListen, you must unregister in onCancel and also in onDetachedFromActivityForConfigChanges. Forgetting this leaks the activity.
Sign in to like, dislike, or report.