How to Package Dart for Use as a Native Android Library
We cover creating a fat-aar, adding-in dynamic link libraries (dlls), and publishing to a Maven Repository. The goal is to create a dependency an Android developer can drop into their existing project
This post builds on the previous post about how to package a Dart library using Flutter. In this blog we cover creating a fat-aar, adding-in dynamic link libraries (dlls), and publishing to a Maven Repository. The goal is to create a dependency an Android developer can drop into their existing project via a single-line standard implementation call.
implementation 'com.mytiki:tiki_sdk_android:0.0.3'
Android Project
We start by creating a new empty Android project. Inside the project’s app/src/main/kotlin we add our Android Flutter Channel code to map our Dart requests and responses to native APIs.
FlutterChannel.kt
...
import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result
import java.util.*
class FlutterChannel : FlutterPlugin, MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "YOUR_CHANNEL")
channel.setMethodCallHandler(this)
}
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
...
}
override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
}
fun invokeMethod(method: String, arguments: MutableMap<String,Any?> {
channel.invokeMethod(method, arguments)
}
}
In our native SDK API, we initialize the Flutter Channel and wire up our methods.
sdk.kt
class Sdk(context: Context) {
private var flutterChannel: FlutterChannel
init {
val loader = FlutterLoader()
loader.startInitialization(context)
loader.ensureInitializationComplete(context, null)
val flutterEngine = FlutterEngine(context)
flutterEngine.dartExecutor.executeDartEntrypoint(DartExecutor.DartEntrypoint.createDefault())
GeneratedPluginRegistrant.registerWith(flutterEngine)
flutterChannel = FlutterChannel()
flutterEngine.plugins.add(flutterChannel)
}
private fun hello() {
tikiSdkFlutterChannel.invokeMethod(
"hello", mutableMapOf(
"message" to message
)
)
}
}
We use callbacks with requestIds to map responses to API calls. There are a number of ways to implement this from within onMethodCall —but if you want to take inspiration from our design, the source code can be seen here.
Fat AAR
Next, you’re going to need a Fat AAR. The standard AAR generated by ./gradlew assemble only includes your Android source, but for it to run as a single import, we also need to include our Dart code and any Flutter plugins you may have added along the way.
We’re going to use the Gradle plugin fat-arr from kenzong, to add in our hosted dependencies we created in the previous blog. To do this we first add (or amend) a buildscript block to the top of our build.gradle file inside the app directory.
buildscript {
repositories {
mavenCentral()
}
dependencies {
classpath 'com.github.kezong:fat-aar:1.3.8'
}
}
Now, in our dependency block, we can call embed instead of implementation for the dependencies we want to include in our AAR. Don’t forget to apply the plugin before the block. Feel free to use ours as an example.
apply plugin: 'com.kezong.fat-aar'
dependencies {
...
debugEmbed "com.your_company.flutter_sdk:flutter_debug:VERSION"
releaseEmbed "ccom.your_company.flutter_sdk:flutter_release:VERSION"
}
Lastly, we need to pull in the Flutter embeddings; without it, your Platform Channel won’t be able to resolve its dependencies. First, we add to the same dependency block:
debugEmbed "io.flutter:flutter_embedding_debug:ENGINE_VERSION"
releaseEmbed "io.flutter:flutter_embedding_release:ENGINE_VERSION"
Where ENGINE_VERSION is the Flutter Engine version, you intend to use. For example, when this blog was written, we were using 1.0.0-857bd6b74c5eb56151bfafe91e7fa6a82b6fee25. You can find the corresponding engine version on Flutter’s Github.
Then in our settings.gradle file (project root), we need to tell Gradle where to fetch the embedding AARs.
dependencyResolutionManagement {
...
maven {
url 'https://storage.googleapis.com/download.flutter.io'
}
}
}
AAR, done. Now for the DLLs.
Adding DDLs
AARs can optionally include DLLs via the jniLibs folder; this is great because the Flutter Engine is pre-compiled per-platform (arm64, armeabi, etc.). Similarly, it may not be the only DLL your project relies on. For example, we use SQLite in our project, and so we need to include its corresponding DLL too.
You can manually fetch the libflutter.so binaries from the same URL as the embedding libraries and place them in the standard jniLibs folder structure like this:
app
src
main
jniLibs
arm64-v8a
libflutter.so
armeabi-v7a
libflutter.so
x86_64
libflutter.so
debub
jniLibs
arm64-v8a
libflutter.so
armeabi-v7a
libflutter.so
x86
libflutter.so
x86_64
libflutter.so
Or, fetch and load them automatically via a Gradle script by adding a couple more lines to the bottom of your build.gradle file.
def groovyShell = new GroovyShell()
def addDll = groovyShell.parse(new File("$rootDir/app/add-dll.gradle"))
addDll.clean(rootDir.toString())
addDll.flutter(rootDir.toString(), flutterEngine)
You can find our add-dll.gradle script here. It exposes functions to fetch and load just about any DLL, plus a couple nifty flutter and SQLite specific functions. Some folks choose to commit their jniLibs; we don’t since we fetch them automatically on Gradle Syncs.
Publish to Maven
Our project is open source, so for this example, we’ll use the Maven Central repository provided by Sonatype (follow the instructions here to set up your own account) to host our final AAR. If you don’t want to release your AAR publicly, check out Artifactory or GitHub Packages.
First, we add signing and publishing to the end of our app’s build.gradle file.
...
apply plugin: 'maven-publish'
apply plugin: 'signing'
signing {
def signingKey = System.getenv("PGP_PRIVATE_KEY")
def signingPassword = System.getenv("PGP_PASSPHRASE")
useInMemoryPgpKeys(signingKey, signingPassword)
sign publishing.publications
}
afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
groupId = 'com.your_company'
artifactId = 'sdk_android'
version = android.getDefaultConfig().getVersionName()
}
}
repositories {
maven {
name = "OSSRH"
url = "https://s01.oss.sonatype.org/service/local/staging/deploy/maven2/"
credentials {
username = System.getenv('OSSRH_USER')
password = System.getenv('OSSRH_TOKEN')
}
}
}
}
}
We’re using environment variables to pass our tokens and signing keys via Github Secrets in Actions, but if you’re going to release from a local machine, you can use findProperty(“NAME“) and keep your secrets in your gradle.properties file (your .gradle root folder, not your projects).
The Sonatype instructions show you how to use GPG to create a PGP key-pair and upload (required) the public key to the Ubuntu Keyserver. Once you have the key pair generated, you simply copy your private key and upload it to GitHub Secrets.
gpg --armor --export-secret-key USERNAME | pbcopy
We add to GitHub Secrets the private key passphrase and sonatype OSSRH token username and password (don’t use your plaintext password in build pipelines). Last but not least, we add a Github Action to publish.
...
jobs:
publish:
...
steps:
...
- name: Validate Gradle Wrapper
uses: gradle/wrapper-validation-action@v1
- name: Assemble AAR
uses: gradle/gradle-build-action@v2
with:
arguments: assemble
env:
GITHUB_USER: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: "Publish AAR"
uses: gradle/gradle-build-action@v2
with:
arguments: publish
env:
OSSRH_USER: ${{ secrets.OSSRH_USER }}
OSSRH_TOKEN: ${{ secrets.OSSRH_TOKEN }}
GITHUB_USER: ${{ github.actor }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PGP_PASSPHRASE: ${{ secrets.PGP_PASSPHRASE }}
PGP_PRIVATE_KEY: ${{ secrets.PGP_PRIVATE_KEY }}
Done! Now, all a developer has to do to add your library to their project is:
implementation 'com.your_company:sdk_android:VERSION'
Yes, it’s a bit of a pain to get the entire build chain set up, but if you want to build killer cross-platform SDKs, Dart + Flutter makes for a powerful combo. Key business logic can live inside a single project, dramatically reducing overhead. Platform-specific projects handle wrapping APIs for native calls, dependency management, and automated deployment.
To see our project end-to-end, check out the following: tiki-sdk-dart, tiki-sdk-flutter, tiki-sdk-android