Using Android Intent via native interop with jnigen

5 minute read

In this post I’ll show how to access some of the Android APIs directly in Dart/Flutter. The final result will be a plugin allowing to send some basic Intents to the Android system, like opening the e-mail client or selecting a contact.

We wanted to open user’s default e-mail client to send a properly formatted message, but the typical package:url_launcher approach of creating a mailto: was too flaky. Android allows to provide a specific intent of type ACTION_SENDTO that will query the system for the best app to handle it. I created bindings for this Intent call with jnigen and here’s a bit of overview.

Getting started

Create new Flutter plugin and modify the pubspec.yaml and jnigen.yaml to match the example in the jnigen repository. This will only work on Android, hence:

flutter create --template plugin --platforms android android_intent
cd android_intent/example
flutter build apk --release

After cleaning up the code and dependencies make sure to build your Android example app in release. You can also open it in Android Studio to check if everything is correct.

This setup assumes following package versions and works as of July 2025:

jnigen: ^0.14.0
jni: ^0.14.2

jnigen setup

The basic setup of jnigen plugin works fine for me although it results with over 20k generated lines of Dart code. To be able to access Android APIs you need to specify all the classes (and their dependencies e.g. android.net.Uri) that would normally be imported in Java/Kotlin code. The following jnigen.yaml file worked for me:

android_sdk_config:
  add_gradle_deps: true
  android_example: "example/"

source_path:
  - "android/src/main/java"
classes:
  - "android.content.Intent"
  - "android.content.Context"
  - "android.app.Activity"
  - "android.net.Uri"

output:
  dart:
    path: "lib/src/bindings.dart"
    structure: "single_file"

Once you run the jnigen code generator, you should be able to use the generated bindings directly in Dart code.

flutter pub run jnigen --config jnigen.yaml

Usage in Dart

To send a simple Intent to open the e-mail client, you can use the following Dart code:

void sendEmail() {
  try {
    final intent = Intent.new$2(Intent.ACTION_SENDTO);
    intent.setData(Uri.parse("mailto:".toJString()));

    intent.putExtra$21(
      Intent.EXTRA_EMAIL,
      JArray.of<JString>(JString.type, ["example@example.com".toJString()]),
    );
    intent.putExtra$8(Intent.EXTRA_SUBJECT, "Subject".toJString());
    intent.putExtra$8(Intent.EXTRA_TEXT, "Body text".toJString());
    intent.putExtra$21(
      Intent.EXTRA_CC,
      JArray.of<JString>(JString.type, ["cc@example.com".toJString()]),
    );
    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
    Context contextInstance = Context.fromReference(Jni.getCachedApplicationContext());
    contextInstance.startActivity(intent);
  } catch (e) {
    print('Error opening email: $e');
  }
}

What is quite cool it follows 1:1 the code from the Android documentation:

fun composeEmail(addresses: Array<String>, subject: String) {
    val intent = Intent(Intent.ACTION_SENDTO).apply {
        data = Uri.parse("mailto:") // Only email apps handle this.
        putExtra(Intent.EXTRA_EMAIL, addresses)
        putExtra(Intent.EXTRA_SUBJECT, subject)
    }
    if (intent.resolveActivity(packageManager) != null) {
        startActivity(intent)
    }
}

Obviously there are several slightly annoying differences:

  • You have to cast Dart types to Jni types, e.g. toJString().
  • You have to use JArray.of<JString>() to create an array of strings
  • You have to use putExtra$21 and putExtra$8 methods as Dart does not support method overloading

On the flipside, the package:jni comes with few convenience methods:

  • Jni.getCachedApplicationContext() to get the application context which I can cast to Context and use it to start the intent.
  • not shown here Jni.getCurrentActivity()

Sending Intents with results

It isn’t directly possible to receive Intent results via Activity.onActivityResult(), so I had to come up with a workaround. The main inspiration was package:receive_intent, and my previous attempt of using proxy with a listener interface. There may be other ways in the future to achieve this – see this issue for details.

Anyway, to receive intent results like this, you need to override the onActivityResult of your main Activity.

const val REQUEST_SELECT_CONTACT = 1

fun selectContact() {
    val intent = Intent(Intent.ACTION_PICK).apply {
        type = ContactsContract.Contacts.CONTENT_TYPE
    }
    if (intent.resolveActivity(packageManager) != null) {
        startActivityForResult(intent, REQUEST_SELECT_CONTACT)
    }
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent) {
    if (requestCode == REQUEST_SELECT_CONTACT && resultCode == RESULT_OK) {
        val contactUri: Uri = data.data
        // Do something with the selected contact at contactUri.
    }
}

FlutterPlugin comes with a ActivityAware interface that allows you to register a result listener via ActivityPluginBinding:

override fun onAttachedToActivity(binding: ActivityPluginBinding) {
    activity = WeakReference(binding.activity)
    binding.addActivityResultListener { res, req, intent ->
        Log.i("ARLP", "addActivityResultListener called with intent: $intent")
    }
}

So to make that work I had to implement a typical Flutter plugin class that would keep OnResultListener interface. This interface will later be implemented in Dart.

This also means that now my plugin includes a Kotlin class of its own so it needs to be registered in the pubspec.yaml file:

flutter:
  plugin:
    platforms:
      android:
        ffiPlugin: true
+        package: com.example.android_intent
+        pluginClass: ActivityResultListenerProxy

Next up I created a Kotlin class ActivityResultListenerProxy:

@Keep
class ActivityResultListenerProxy : FlutterPlugin, ActivityAware {
    private var activity: WeakReference<Activity>? = null;
    private var callback: OnResultListener? = null;

    public interface OnResultListener {
        fun onResult(requestCode: Int, resultCode: Int, result: String?)
    }

    @Keep
    public fun onResult(requestCode: Int, resultCode: Int, intent: Intent?) : Boolean {
        if (intent != null) {
            callback?.onResult(requestCode, resultCode, intent.dataString)
            return true
        } else {
            return false
        }
    }

    override fun onAttachedToActivity(binding: ActivityPluginBinding) {
        activity = WeakReference(binding.activity)
        binding.addActivityResultListener { res, req, intent ->
            Log.i("ARLP", "onAttachedToActivity called with intent: $intent")
            onResult(res, req, intent)
        }
    }

    override fun onDetachedFromActivityForConfigChanges() {
        activity?.clear()
        activity = null
    }

// other methods
}

One challange was to get hold of the plugin instance that would be created by Flutter. To make it work I made it a singleton and added a static companion object that would hold the instance. Plugin cannot be Kotlin’s object.

@Keep
class ActivityResultListenerProxy : FlutterPlugin, ActivityAware {
    private var callback: OnResultListener? = null;

    companion object {
        @Volatile
        private var instance: ActivityResultListenerProxy? = null

        fun getInstance(): ActivityResultListenerProxy {
            return instance ?: synchronized(this) {
                instance ?: ActivityResultListenerProxy().also { instance = it }
            }
        }
    }

    @Keep
    public fun setOnResultListener(listener: OnResultListener) {
        callback = listener
    }
// other methods
}

Then after updating my jnigen.yaml I was able to start intent with result.

android_sdk_config:
  add_gradle_deps: true
  android_example: "example/"

source_path:
  - "android/src/main/java"
classes:
  - "android.content.Intent"
  - "android.content.Context"
  - "android.app.Activity"
  - "android.net.Uri"
  - "android.provider.ContactsContract"
  - "com.example.android_intent.ActivityResultListenerProxy"

output:
  dart:
    path: "lib/src/bindings.dart"
    structure: "single_file"

This time I had to use Activity to start the intent and register the result listener.

void selectContact() {
  try {
    final intent = Intent.new$2(Intent.ACTION_PICK);
    intent.setType(ContactsContract$Contacts.CONTENT_TYPE);

    listener = ActivityResultListenerProxy.Companion.getInstance();
    listener!.setOnResultListener(
      ActivityResultListenerProxy$OnResultListener.implement(
        $ActivityResultListenerProxy$OnResultListener(
          onResult: (result, request, data) {
            print('ARLP Selected contact: $result, request: $request, data: $data');
          },
        ),
      ),
    );

    final activity = Activity.fromReference(Jni.getCurrentActivity());

    activity.startActivityForResult(intent, 1);
  } catch (e) {
    print('Error selecting contact: $e');
  }
}

I experimented a bit more with other Intents like selecting an image from camera and they all seemed feasible. It would take a bit more API design to make it work, but nonethelss it’s quite cool to use Android APIs directly in Dart code.

You can find the full source code in my native_interop repository.

Updated: