Skip to main content

Command Palette

Search for a command to run...

How to Open-Source Your Flutter Plugin and Still Hide the Code That Pays You

The build-time binary trick that lets you ship a working feature while keeping the real logic unreadable.

Updated
15 min readView as Markdown
How to Open-Source Your Flutter Plugin and Still Hide the Code That Pays You

Open any package on pub.dev and you can read every line. That is the deal with open source, and most of the time it is wonderful. But it raises an awkward question for anyone trying to build a business: if your best work is just sitting there in plain Dart, what stops the next developer from copying it in an afternoon?

Some plugins solve this in a way that looks like magic. You install them, they work perfectly, and yet the actual clever logic is nowhere to be found in the source. People assume the code must be downloaded secretly at runtime and run off some server. That guess is wrong, and getting it wrong matters, because if you ever want to protect your own code the same way, you would build the wrong thing entirely.

Here is what is really going on, and how you can use the exact same pattern to ship a paid feature without handing over your source.

The myth, killed quickly

The valuable code is already sitting inside your APK before the app even launches. It gets baked in when you build the app, not while a user is running it. Build time, not run time. The secret sauce ships with the app, just in a form that is hard to read.

This distinction is not just trivia. If a plugin really did download and run fresh code at runtime, Google Play would flag it. The Device and Network Abuse policy does not allow apps to fetch and execute code (a DEX file or a native library) from outside the app after install. So even if you wanted to do the runtime download thing, you should not. The build-time approach is the safe, standard one, and it is what these plugins actually do.

Source code vs binary, in plain language

When you write Dart, Kotlin, or Swift, that is source code. Anyone can open the file and read exactly what it does.

Before your app ships, a compiler chews through that source and spits out a binary. A binary is a packed file full of low-level instructions. A person can still pry it open with the right tools, but it is slow, messy, and most people will not bother. Think of source code as a recipe written out step by step, and a binary as that same recipe blended into a smoothie. Everything is still in there. Good luck reading it back out.

So a package author has two choices:

  1. Ship the source. This is what almost every pub.dev package does. Open the folder, read every line, copy it freely.

  2. Ship only the binary and keep the source private.

That second option is the whole trick. Nothing fancier than that.

What is actually inside a plugin like this

A plugin built this way is three layers stacked together. Only one of them is open.

Layer What it is File Open or closed
Dart wrapper The friendly API you call from Flutter, like .start() and .stop() lib/ folder Fully open
Android engine The real logic for Android a .aar binary Closed
iOS engine The real logic for iOS a .xcframework binary Closed

The Dart layer is a thin remote control. It holds none of the clever logic. It just forwards your calls down to the native binary through Flutter platform channels. All the brains live in the .aar and the .xcframework. That is exactly why an author can leave the Dart part open on GitHub and lose nothing. The valuable part is not there.

A quick aside that trips people up: an AAR is not the same as a JAR. A JAR is just compiled Java or Kotlin classes. An AAR is an Android Archive, so it can also bundle resources, a manifest, and native .so files. That extra packaging is why a full Android engine can ship as a single dependency.

How the binary lands inside your app

Here is the line that does the heavy lifting. The plugin ships a small Gradle file that your app applies, and inside it there is basically this:

dependencies {
    // Pull the closed-source engine
    api(group: 'com.yourcompany', name: 'yourengine', version: '1.0.0')
}

When you run flutter build apk, Gradle reads that line, reaches out to a Maven repository, downloads the AAR, and bundles it straight into your APK. After that the binary is part of your app, sitting on the phone like any other code. No network call when the app runs.

The AAR can sit on a fully public Maven repo, and that is fine, because the file is useless to anyone without a valid license. We will get to that in a second.

The user never downloads the engine separately. It travelled inside the APK the entire time.

Tip: you will often see a version written as '1.+', which means "give me the latest 1.x". Convenient, but it quietly breaks reproducible builds, because two people building the same commit on different days can pull different binaries. For anything you ship to production, pin the exact version and bump it on purpose. Future you, staring at a bug that only shows up on one machine, will be grateful.

The one thing that runs at runtime: the license check

If the binary is free to download, how does anyone make money from it? A license gate baked inside the binary.

You drop a license key into your AndroidManifest.xml:

<meta-data
  android:name="com.yourcompany.engine.license"
  android:value="YOUR_LICENSE_KEY_HERE" />

When the app starts, the native binary reads that key and checks it against your app's package name. The key is generated for one specific package id, tied to a buyer account. If the key is missing or wrong, the engine refuses to run in a release build. In debug builds it works without any key, so a developer can try before they buy.

The check is offline friendly. The key works like a signed certificate that says "this package id is allowed," and the binary verifies it locally. It does not phone home on every launch.

Hard-won gotcha: because debug builds skip the license entirely, everything works beautifully in development, and then your first release build silently kills the feature. People lose hours to this. Always smoke-test a real signed release build before you assume licensing is wired up correctly.

So, two clean phases. Build time bakes in the binary. Run time verifies a key. Neither phase downloads code over the network.

How to hide your own code the same way

Say you build something genuinely valuable, like a custom anti-spoofing module or a pricing engine, and you want to license it to other companies without handing over the source. Here is the full recipe.

Step 1: Pull the secret logic into a native module

Do not leave the valuable logic in Dart. Dart in a plugin is readable. Move it down into native code.

On Android, create an Android Library module and write the logic in Kotlin or Java. For the most sensitive parts, drop into C or C++ with the NDK, which compiles to a .so file that is far harder to reverse than Kotlin bytecode. On iOS, create a Framework target and write it in Swift or Objective-C.

Step 2: Compile it into a binary

On Android, run the assembleRelease task to get a .aar. Turn on R8 or ProGuard with minifyEnabled true so the bytecode gets obfuscated and your method names collapse into a, b, c. On iOS, set BUILD_LIBRARY_FOR_DISTRIBUTION = YES, archive, and build an .xcframework, then zip it.

Two tips most tutorials skip. First, obfuscation will happily rename the methods your platform channel calls by reflection, and then everything breaks at runtime with no compile error. Add explicit keep rules (or the @Keep annotation) for your public API and any entry points. Second, after you build native .so files, run strip on them to remove debug symbols. Smaller file, and you stop handing a reverse engineer a map with all the street names on it.

Step 3: Wrap it in a thin Flutter plugin

Create a normal Flutter plugin package. The Dart side is just the remote control. It exposes friendly methods and forwards calls to your binary over a MethodChannel. This Dart code can stay open, because it holds nothing worth stealing. Your Gradle file and podspec point at the binary instead of bundling source.

Step 4: Add your own license gate

Inside the binary, before doing the real work, check something the buyer cannot fake. Read the app's package id and compare it against a signed key you generated for them. Verify that key with public-key cryptography, so a buyer cannot forge their own. They would need your private key, which never ships. Optionally do a light online activation the first time the app runs.

Keep the check inside the native binary, never in Dart, otherwise anyone can simply delete it.

The sharpest version of this: do not write the gate as a plain if (!valid) return;. That is one line for someone to NOP out in a patched binary. Instead, make the license key actually do work the feature needs. Use it to derive a value, or to decrypt a config blob the engine cannot function without. Now patching out the check does not unlock the feature, it just breaks it. You have tied the lock to the engine instead of bolting it onto the door.

Step 5: Publish the binary and reference it from Gradle

Upload your AAR to a Maven repo, then your plugin's Gradle does the same move:

dependencies {
    api(group: 'com.yourcompany', name: 'yourengine', version: '1.0.0')
}

That is the entire pattern. Dart stays open and friendly, the brains stay locked in a binary, and a license key controls who gets to run it.

Where to host your binary

For Android the binary lives in a Maven repository. For iOS it lives wherever CocoaPods or Swift Package Manager can reach a zipped xcframework, usually a plain file URL. Here are the real options, from laziest to most controlled.

JitPack. You push a library to a GitHub repo and JitPack compiles and serves it as a Maven dependency automatically. Public repos are free, great for moving fast. The catch is that JitPack builds from source, so for a closed product your source has to live somewhere JitPack can read it, which usually means a paid private repo.

Maven Central (Sonatype Central Portal). The industry-standard home, and where most serious Android libraries live. Free to publish, and every Android project can pull from it with zero config. The trade-off is that the binary becomes fully public, so all your security has to come from the license gate, not from hiding the file. Setup is a one-time chore (verify a namespace, sign artifacts with GPG), but smooth after that.

GitHub Packages. A Maven repo attached to your GitHub account. Free for public packages. Private packages work too, but consumers need a token in their Gradle to authenticate, which is annoying to hand out to external buyers. Good for internal use where your own team holds the tokens.

Self-hosted on your own server. A Maven repo is literally a folder of files served over HTTPS in a specific directory layout, so any web server does the job. Drop the files behind nginx on a VPS you already pay for and point Gradle at https://maven.yourdomain.com. Cloudflare R2 is even better for downloads, since it speaks the S3 API and charges zero egress fees. Plain AWS S3 works, but you pay for every gigabyte downloaded, which can sting if the file gets popular.

For iOS, zip the xcframework and host that single file (R2, S3, a VPS, or a GitHub Release asset), then point your CocoaPods podspec or SPM binaryTarget at the URL.

SPM tip: a binaryTarget requires a checksum of the zip, and the build fails if it does not match. That is a feature, not a hassle. It means a buyer (or an attacker on the network) cannot quietly swap your binary for a tampered one. Generate it with swift package compute-checksum yourframework.zip and commit the value.

What it costs to host (2026 prices)

An AAR or xcframework is tiny, usually a few megabytes, and a niche product does not pull much download traffic. So at this scale almost everything here is effectively free.

Option Storage Download (egress) Realistic cost
Maven Central Free Free Free, but the binary is public
JitPack (public) Free Free Free
JitPack (private) Paid plan Included Monthly subscription, only if you need privacy
GitHub Packages (public) Free Free Free
GitHub Packages (private) Free tier, then usage Usage based Effectively free at small scale
Cloudflare R2 10 GB free, then ~0.015 USD per GB-month Always free Basically zero for a few MB
AWS S3 ~0.023 USD per GB-month ~0.09 USD per GB A few rupees, egress grows with popularity
Your own VPS Already paid Already paid No extra cost

The honest summary: hosting the file is the cheap part. R2 stands out because storage is almost nothing and downloads are genuinely free, so the bill never surprises you even if the product takes off. S3 is the one to watch, because egress is metered.

Which option to actually pick

Two situations, two answers.

If this is internal tooling that only your own apps use, self-host on a VPS or push to a private GitHub Packages repo. You control the tokens, nobody outside sees it, and it costs nothing extra. Keep the license gate simple, since you trust the consumers anyway.

If this becomes a real product you sell to other developers, go Maven Central for Android plus a zipped xcframework on R2 for iOS. Maven Central gives buyers the frictionless install they expect, and R2 keeps iOS downloads free. Then put your real effort into the license gate and key generation, because once the file is on Maven Central it is public, and the gate is your only lock.

The catch nobody likes to admit

A binary is harder to read, not impossible. Anyone can run a tool like jadx on an AAR and get back obfuscated bytecode, or pull the .so files out and study them. A determined person with time can work through it, and a simple license check can be patched out by someone skilled enough.

So be honest about what this actually buys you. It stops casual copy-paste. A regular developer cannot just lift your code. That is real and worth something. It does not stop a serious reverse engineer who has decided to crack it.

A couple of things that make their life harder, none of which make it impossible:

  • Strings in a binary are trivially greppable. Run strings on any compiled file and out come your URLs, error messages, and any secret you were naive enough to hardcode. Never assume a value is safe just because it is compiled. If it must stay secret, it does not belong on the device.

  • A native check of the app's signing certificate at startup raises the bar on repackaging. It is not unbeatable, but it filters out the low-effort attempts.

If the logic is truly your crown jewels, the strongest move is to keep the heavy lifting on a server you control and have the app call an API. The client only ever sees inputs and outputs, never the algorithm. The binary trick is the right middle ground when the logic has to run on-device, which is the case for things like location tracking or offline detection. For that on-device case, binary plus obfuscation plus a license gate is the sensible setup.

Quick checklist if you want to build your own

  1. Move the secret logic out of Dart into a native Android library and iOS framework.

  2. Compile to .aar and .xcframework with R8 or ProGuard obfuscation on, and keep rules for your public API.

  3. Strip debug symbols from native .so files.

  4. Write a thin open Dart wrapper that talks to the binary over a MethodChannel.

  5. Add a license gate inside the binary, verified with public-key crypto, tied to the package id, and woven into something the feature needs so it cannot be cleanly patched out.

  6. Publish the AAR (Maven Central or private GitHub Packages) and host the xcframework zip (R2 is ideal), with a pinned version and an SPM checksum.

  7. Remember the limit. This protects against casual copying, not a determined attacker. Keep the truly priceless stuff server-side.