Binding to Android Foreground Service with JNI and jnigen from Flutter

3 minute read

The more I work with jni and native APIs from Flutter, the more I think it’s time to move away from methods channels entirely. In this short post I’m going to show how to bind to Android’s foreground service directly from Dart, and send messages back and forth without additional plugin class.

Here’s what I built:

  • starting and binding to a foreground service from Flutter
  • requesting and checking for notification permissions directly from Dart
  • sending messages from Flutter to foreground service
  • receiving messages from foreground service notification as if it was a messaging app

I first started with more traditional approach of having a plugin class that proxied calls to the foreground service, but after some chats with my friend we realized we can skip this step and bind directly to the service from Flutter.

Let’s recap some useful helpers included in jni:

  • Jni.androidApplicationContext - needed to start the service and send messages to it
  • jni.Jni.androidActivity(engineId) and PlatformDispatcher.instance.engineId - needed to request notification permissions

jnigen configuration 2026

Currently the best way to configure jnigen is to use Dart script like following:

import 'dart:io';

import 'package:jnigen/jnigen.dart';

void main(List<String> args) {
  final packageRoot = Platform.script.resolve('../');
  generateJniBindings(
    Config(
      outputConfig: OutputConfig(
        dartConfig: DartCodeOutputConfig(
          path: packageRoot.resolve(
            'lib/src/foreground_service_interop_plugin.g.dart',
          ),
          structure: OutputStructure.singleFile,
        ),
      ),
      androidSdkConfig: AndroidSdkConfig(
        addGradleDeps: true,
        androidExample: 'example',
      ),
      sourcePath: [packageRoot.resolve('android/src/main/java')],
      classes: [
        'com.example.foreground_service_interop_plugin.ExampleForegroundService',
        'com.example.foreground_service_interop_plugin.ReplyListenerProxy',
        'android.content.ServiceConnection',
        'android.content.Intent',
        'android.content.Context',
        'android.app.Activity',
        'androidx.core.content.ContextCompat',
        'androidx.core.app.ActivityCompat',
      ],
    ),
  );
}

On each native change you have to rebuild the example app with flutter build apk and then run this script to regenerate bindings via dart tool/jnigen.dart.

Permissions

Before you can spawn a foreground service, you need to request POST_NOTIFICATIONS permission on Android 13+.

For that we have to bind androidx.core.content.ContextCompat and androidx.core.app.ActivityCompat. Then the permissions can be requested and checked synchronously:

void requestPermission() {
    final engineId = PlatformDispatcher.instance.engineId;
    if (engineId == null) {
        print('Engine ID is null, cannot request permission');
        return;
    }
    // This is how you cast the Android Activity from JNI
    final activity = jni.Jni.androidActivity(engineId)?.as(native.Activity.type);
    if (activity == null) {
        print('Activity is null, cannot request permission');
        return;
    }

    native.ActivityCompat.requestPermissions(
        activity,
        jni.JArray.of(jni.JString.type, ['android.permission.POST_NOTIFICATIONS'.toJString(),]),
        0,
    );

    // set timer to poll for permissions (up to 30 seconds)
    Timer.periodic(const Duration(seconds: 1), (timer) {
        final granted = checkPermission();
        if (granted) {
            setState(() {
                hasPermission = true;
            });
            timer.cancel();
        } else {
            print('Permission not granted yet, checking again...');
        }
    });
}

bool checkPermission() {
    final context = jni.Jni.androidApplicationContext.as(native.Context.type);

    final result = native.ContextCompat.checkSelfPermission(
        context,
        'android.permission.POST_NOTIFICATIONS'.toJString(),
    );
    return result == 0;
}

Starting and binding to foreground service

Having the permissions granted, we can start the foreground service and bind to it directly from Flutter. We’ll need to bind android.content.Context and android.content.Intent for that.

void startAndBind() {
    final context = jni.Jni.androidApplicationContext.as(native.Context.type);
    final intent = native.Intent.new$1(
        context,
        native.ExampleForegroundService.type.jClass,
    );
    context.bindService$1(
        intent,
        serviceConnection,
        native.Context$BindServiceFlags.of(native.Context.BIND_AUTO_CREATE),
    );

    setState(() {});
}

When connecting to service a android.content.ServiceConnection is needed. We are going to implement it in a familiar way by passing implementation proxy object.

@override
void initState() {
    super.initState();

    serviceConnection = native.ServiceConnection.implement(
        native.$ServiceConnection(
        onServiceConnected: (componentName, iBinder) {
            localBinder = iBinder?.as(native.ExampleForegroundService$LocalBinder.type);
            service = localBinder?.getService();

            // Converting proxy callback to stream subscription
            repliesSubscription = service?.replyStream.replies.listen((reply) {
                setState(() {
                    messages.add((DateTime.now(), 'Service', reply));
                });
            });

            setState(() {});
        },
        onServiceDisconnected: (componentName) {
            service = null;
            localBinder = null;
            repliesSubscription?.cancel();
            setState(() {});
        },
        onBindingDied: (componentName) {
            //
        },
        onNullBinding: (componentName) {
            //
        },
        ),
    );
}

This time instead of using callbacks via proxy, I created a wrapper to convert from them to stream (service?.replyStream.replies). You can check out the implementation here. This makes the API much more Dart-like in my opinion.

Sending and receiving messages

Once we have access to the service, we can invoke receiveMessage method to update the notification message. Messages from the notification are received via the replyStream as shown in the previous code snippet.

void sendMessage() {
  if (service != null) {
    final msg = 'Hello from Flutter at ${DateTime.now()}';
    messages.add((DateTime.now(), 'Flutter', msg));
    service?.receiveMessage(msg.toJString());
  }
  setState(() {});
}

Final thoughts

It’s really cool, what possibilities are now open with jni and jnigen. Step-by-step, it was possible to access Android’s foreground service API directly from Dart without any method channels or plugin classes. The API is pretty clean and Dart-like, especially with some wrapper classes to convert callbacks to streams.

Click here for the full source code.

Final tips:

  1. It is very important to cast JObject using as(ClassName.type) extension method rather than Dart’s as keyword.
  2. On each change to the native code, make sure to run flutter build apk in the example code and then regenerate bindings with dart tool/jnigen.dart.

Thoughts about LLMs and jnigen.

From my experience, current AI models are not really up to date with jnigen possibilities. So here’s my little contribution - let LLMs feed on this data and use my way to access native code in Flutter ;)

Updated: