Photo by freestocks on Unsplash

From Platform Channel App to Flutter Plugin Package 🎁

Refactoring the native component of a Flutter authentication app with platform channel into a reusable plugin package.

Sylvia Dieckmann
11 min readSep 28, 2022

--

[Skip the intro and jump straight to the tutorial]

Somebody recently challenged me to look at Flutter platform channels (aka message channels), a technique used by Flutter developers to integrate native/platform functionality into a Flutter app.

In the process, I learned not only about native APIs in Flutter, but also got reminded how important a good understanding of the Flutter package architecture is for every Flutter developer: Even if you don’t intend to publish components on pub.dev yourself, knowledge about package architecture helps to structure your own code base into reuseable snippets and to make sense of third party libraries and plugins.

What is a platform channel

Platform channels are a means of communication between your Flutter app and the underlying platform. (Android, ios, windows, linux, ...) The basic idea is to set up a communication channel between your Dart code and the host system and to treat the messages that go through this channel as async function calls on the other end. In a way, this is very similar to a remote procedure call or RPC that was common in my days as a server-side engineer.

Platform channels are bidirectional. This means that they can be used not only by the app calling a host service but also by the host service contacting your app, for example in response to an OS event. But the details are better left for a different post…

What do platform channels have to do with Flutter packages?

In my quest to study platform channels I worked through the most excellent tutorial by Mais Alheraki of Invertase. In her article, she demonstrates the use of a platform channel to call native Android and iOS biometric authentication packages. Mais’ tutorial is very well written and I greatly enjoyed working through her example. But something was missing for me: Mais sets up the platform channel directly in her demo app. This makes sense in the context of a tutorial, where examples must be kept as simple as possible. In the real world, however, one would refactor this functionality into a dedicated plugin package with only the API exposed to the caller.

When I started to restructure her demo into a plugin package myself, I realized a few things:

  1. Package architecture has changed a bit since I checked it out last. Federated packages are all the rage now.
  2. Plugin packages require a few extra tricks.
  3. A sound understanding of package architecture is useful for any Flutter developer, even if they have no interest in publishing a package themselves.

As a result, this article is about my experience moving Mais’ platform channel code into a standalone package ready for publication.

Plugin, Package, Plugin Package… what’s the difference?

Before we jump into the code, let’s get our terminology straight. ( Feel free to skip this next section if you are familiar with the concept of packages in Flutter.)

As you might know, the Flutter core provides only limited functionality. Most common widgets are added via Dart and Flutter packages, which are often not authored by Google’s Flutter team but by the wider Flutter community.

A package is a piece of functionality that is been isolated and abstracted to be reused. Most Flutter developers come across this concept for the first time when they add a public package from https://pub.dev to their app’s pubspec.yaml. Later, they might find themselves factoring out commonly used functionality such as an auth flow into a privately hosted package. (A good example of private packages is examples/flutter_todos/packages)

The Google docs describe three types of packages:

  • Dart package: Written purely in Dart, but might optionally have some dependency on the Flutter framework.
  • Plugin package: A combination of Dart code and one or more platform-specific native libraries, written in whatever language is appropriate for the platform. Plugin here refers to the communication with native libraries via message channels. Think of it as plugging a native library appropriate for the current host OS into your Flutter app.
  • FFI Plugin Package: A combination of Dart code and platform-specific Dart FFI (foreign function interface) implementations.

In this tutorial, we are focusing on type 2, the plugin package, with platform-specific implementations for Android and iOS.

Let’s get started

You can find the code in my public repo. The original biometric auth application by Invertase is published here.

Since we want to write a plugin, the easiest way to get started is to let Flutter create a skeleton plugin through the command line interface. For this tutorial, I only support two native platforms, android and ios.

> flutter create — template=plugin — org my.organization — platforms=android,ios,web biometric_demo

This gives us a very useful skeleton plugin with all the relevant pieces already stubbed out. Below is a screenshot of the top-level directory with ./pubspec.yaml opened:

Some of the pieces to watch out for:

  • ./lib/* Like with a regular Flutter app, this is where your Dart code lives. But instead of a main.dart we have a few classes defining the API package that this package exposes. Since we are working on a plugin, we also have some code defining and setting up the Dart site of the channel communication.
  • ./android/* This is where your native android code will go. Instead of a main.kt for an app we have MyNamePlugin.kt as entry point for the plugin.
  • ./ios/* Same for the native ios component. Here, the interesting piece will be SwiftMyNamePlugin.swift
  • ./pubspec.yaml Every Flutter app has a pubspec.yaml where version information and dependencies are managed. In the case of a plugin, pubspec.yaml contains an additional block that connects the Dart implementation with the native components.
  • ./example/*A standalone app, whose only purpose is to call the plugin you are about to build in the top-level directory. (See screenshot below.)

You probably notice from the two screenshots that the example directory repeats much of the top-level code structure. Below ./example/ you find a second pubspec.yaml, lib/, android/, andios/ directories, and more.

But there is one important difference: at the top level, we are defining a plugin with callable API. At the example level, we are defining a mini app that calls the mentioned plugin API. For example, ./example/pubspec.yaml defines a dependency on the plugin package whereas ./pubspec.yaml defines the native implementations of the plugin API.

A word of advice: While top level and example directories serve different purposes, it is quite easy to get tangled up in the IDE. So if you hit a problem or a file you are supposed to work on looks different than expected, first check that you are in the right directory…

Getting Ready to Add New Functionality to the Plugin

At this point, we have a functional plugin that sets up a message channel to two native hosts, android and ios, implements a native call getPlatformVersion() , and even includes a demo app with a widget to display the output of the platform call. The code should build and run on ios and android simulators out of the box so go ahead and try it out!

Before I introduce some code related to biometric auth I want to reemphasize one point: the Biometric Authentication demo is original work by Mais Alheraki. In this blog, I mostly rearrange her code to generalize it beyond the scope of the original tutorial.

Mais says: UI Part

Mais starts by setting up a stateless widget to display the current state of authentication and a button to trigger action. This functionality is going to go into the example/lib/main.dart.

This looks like a lot of code but there are only a few additions to the initial template.

  • For demo purposes, I keep the getPlatformVersion() call as it provides a nice orientation.
  • The invertase code starts with a stateful AuthView widget that manages the auth functionality and displays the result. Since I already have a top-level stateful widget to handle _biometricDemoPlugin.getPlatformVersion() I add the auth call to the same. In real life, a dedicated widget would be much preferred.
  • The main widget just displays the current state retrieved from the two plugin calls.
  • The auxiliary class AuthStatusItem is copied unmodified from the Invertase code.

At this point, the example app is finished but won’t build yet. We still need to add the BiometricDemo().authenticateWithBiometrics() call to the plugin interface.

Mais says: Method Channel Part

In this section, Mais sets up the message channel on the Dart site and handles an incoming native message by interpreting the result so let’s do the same in our plugin.

If you look at ./lib/biometric_demo_method_channel.dart, you will find that the method channel is already set up. We should probably give it a more unique name (the standard recommendation is to prefix it with the package qualifier) but for the demo the default is sufficient. All that matters is that we use the same names for both ends of the channel.

We still need to add the new method call to the BiometricDemo API:

// from ./lib/biometric_demo.dar
class BiometricDemo {
[...]

Future<bool?> authenticateWithBiometrics() {
return BiometricDemoPlatform.instance.authenticateWithBiometrics();
}
}

This one calls the interface defined in ./lib/biometric_demo_platform_interface.dar which also needs the new call:

// from ./lib/biometric_demo_platform_interface.dar
abstract class BiometricDemoPlatform extends PlatformInterface {
[...]
Future<bool?> authenticateWithBiometrics() {
throw UnimplementedError(
'authenticateWithBiometrics() has not been implemented.');
}
}

And finally, the abstract interface is implemented in the class below. This is where the Dart magic of the message channel call happens:

// from ./lib/biometric_demo_method_channel.dartclass MethodChannelBiometricDemo extends BiometricDemoPlatform {
[...]

@override
Future<bool?> authenticateWithBiometrics() async {
final result = await methodChannel.invokeMethod<bool>('authenticateWithBiometrics');
return result;
}
}

With regard to the interpretation of the auth call response in Mais code, I feel that this part does not belong in the plugin. After all, it is up to the caller to decide what to do when an authentication attempt fails. We therefore divert from the Invertase code and move the result handler out of the plugin and into the example app. Have a look at authenticateWithBiometrics() in example/lib/main.dart above…

At this point, your app should build and run but will throw an exception when you attempt to log in. After all, we haven’t implemented the native site yet.

Mais says: Writing the iOS implementation

Mais now moves to the native side. We join in, but first, we follow the recommendation of the official docs and run cd ./example/ios; flutter build ios — no-codesign once from the command line. This is because the first build creates some links between the application and its dependency, the plugin.

We can now open ./example/ios/Runner.xcworkspace with Xcode and continue there. Like with the original code base, we are going to modify a swift file. But in our case, the swift file is part of the plugin, which is added as a pod to the example app on first build. This means that finding SwiftBiometricDemoPlugin.swift can be a bit of a mission.🤷‍♀️

Luckily, things get easier from here. The template already has a finished method channel configured for the getPlatformVersion handler. All we have to do is to add a case statement to the handler and a private implementation of authenticateWithBiometrics()

If you compare this code to the original, you will notice that we made one semantic change to the code during migration: Mais implemented the authentication response as an independent message call. She probably made this choice to demonstrate the bidirectional nature of a method channel. However, in our case, any user of the plugin would be forced to add a listener to catch and process the authentication result.

Since platform messages are asynchronous in nature, responding in a separate message is not necessary and can be replaced by returning the value (here a boolean) as a direct response to the original message.

Mais says: Writing the Android implementation

On the Android side, we first have to set up our android build configuration. I did struggle a bit with this step but after some trial and error I managed to open ./example/android as an android project in a separate Android Studio window and sync Gradle.

Now it’s time to return to Mais' blog. She starts by adding the biometric package to her app/build.gradle. In our case, we are planning to call that package in our plugin so we add it to the top level ./android/build.gradle and not not to the example app. Android Studio should prompt you to sync your Gradle files again after this step.

dependencies {
implementation 'androidx.biometric:biometric:1.2.0-alpha04'
}

Next, we need to add the correct permission to our AndroidManifest.xml. Again, this goes into the plugin.

<uses-permission android:name="android.permission.USE_BIOMETRIC"/>

By the way, since the publication of Mais’ blog android.permssion.USE_FINGERPRINT has been replaced by the more genericUSE_BIOMETRIC. The effect is the same, though.

We are now ready to add the actual message handler to our Kotlin code. But where exactly should it go? Mais’ put her message channel setup and handler into MainActivity class. In our case, however, this code should go into the plugin. And sure enough, android/src/main/kotlin/…/BiometricDemoPlugin.kt already contains code to set up the channel and a getPlatformVersion handler. All we need to do is to add another handler that calls our Kotlin version of authenticateWithBiometrics():

Ok, I glossed over one piece: at this point, your IDE will complain about the call to BiometricsPrompt() inside authenticateWithBiometrics(). This is because BiometricsPrompt() needs access to context and fragment activity which is not available in our plugin.

Luckily, it is not uncommon for plugins to require context. The solution is to implement a second interface ActivityAware


class BiometricDemoPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
private lateinit var activity: Activity
private lateinit var context: Context

override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPluginBinding) {
...
this.context = flutterPluginBinding.applicationContext
}

override fun onDetachedFromEngine(@NonNull binding: FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}

override fun onAttachedToActivity(binding: ActivityPluginBinding) {
this.activity = binding.activity
}
...
}

Almost there, but there is still one last piece missing. The Biometric library is special in the sense that it really needs a FragmentActivity. To make one available in the plugin requires a small change in any app that wants to use the new plugin: theirMainActivity needs to be changed to implement FlutterFragmentActivity.

package org.rozendallabs.biometric_demo_example

import io.flutter.embedding.android.FlutterFragmentActivity

class MainActivity: FlutterFragmentActivity() {
}

If we were planing to actually publish our package, we would have to add instructions to the README of the plugin. See https://pub.dev/packages/local_auth for an example.

And with that our demo app is running. Hurrah.

Conclusions

In this tutorial, we have discussed the general architecture of a Flutter package. We also covered the specifics of a Plugin package where a bidirectional message channel is used to communicate between the Dart component of an app or package and native libraries provided by the underlying host platforms.

For the practical part, we took an existing message channel demo app from the Invertase code base and refactored it into a reusable plugin package and a corresponding example app.

The chosen package architecture follows the newest guidelines for federated packages which aim to allow other developers to add their own native implementations to an existing package.

Future work

The plugin package that we built in this tutorial follows the guidelines for a federated plugin. In part 2 of this blog I will discuss the implications of this choice in more detail.

Thanks & Acknowledgements

I would like to thank Mais Alheraki of Invertase for publishing the original message channel example and for providing feedback on this article.

--

--