DEV Community

Cover image for Making a vendor's closed-source binary SDK optional at build time
saimskywalker
saimskywalker

Posted on

Making a vendor's closed-source binary SDK optional at build time

You have an app to ship, and it depends on a vendor's SDK. The vendor sends you a .xcframework for iOS and a bare .aar for Android, both prebuilt, both proprietary, often issued per customer, and both too large and too licensed to go in git.

So the first question is where the binary lives, and every answer is bad:

  • Commit it, and you have a licensed binary in your history forever, plus a repository that clones slowly for everyone.
  • Do not commit it, and a fresh checkout cannot build. Neither can CI, which has no vendor credentials and should not have them.
  • Put it behind a fetch script, and now the build only works on machines that ran the script — which is exactly the class of failure that shows up as "works on my machine" three weeks later.

Then the iOS half splits in two, depending on a decision you may have already made. If the app is on CocoaPods, the vendor's podspec drops in. If the app is on Swift Package Manager, it does not — SPM and CocoaPods have no shared resolver, so if the vendor pod and a pub.dev plugin both depend on the same underlying SDK, nothing can negotiate the version between them. The usual advice ("just wrap it in a pod") quietly means turn SPM off for the whole app, and that trade gets worse every month: CocoaPods trunk goes permanently read-only on 2026-12-02.

This is the write-up of a small generator that came out of doing this integration once by hand: [binary-sdk-bridge (https://github.com/saimskywalker/binary-sdk-bridge). It emits an SPM package plus a Gradle module that wrap the vendor binary, with the binary kept out of version control and optional at build time.

The failures, in the order you hit them

These are the specific things that go wrong. If you searched your way here, one of them is probably in your terminal right now.

A missing binary target fails the whole graph, not just the target. A
.binaryTarget(path:) pointing at a file that is not there does not degrade —
it fails the resolve of the entire SPM package graph. One absent
.xcframework and nothing in the app builds, which is why "just gitignore it"
does not work on its own.

Gradle refuses a local .aar inside a library module. Add the vendor
binary with implementation(files("libs/AcmeSDK.aar")) in a library module and
bundleDebugAar fails with:

Direct local .aar file dependencies are not supported when building an AAR
Enter fullscreen mode Exit fullscreen mode

AGP refuses because those classes would be silently dropped from the published
artifact. If you are producing a Flutter plugin this bites the moment anyone
tries flutter build aar for add-to-app.

A bare .aar carries no transitive dependency metadata. Every SDK the
vendor binary expects at runtime has to be declared and version-managed by hand,
and a missing one is a ClassNotFoundException at call time rather than a build
error. If the host app minifies, R8 will also strip classes the vendor loads
reflectively by name, and the symptom of missing keep rules is a silent runtime
failure in release builds only.

The deployment target mismatch. Setting the package's platforms: above the
host app's own is what produces required a higher minimum deployment target at
link time.

And the worst one, which produces no error at all. SwiftPM caches manifest
evaluation by manifest content, not by filesystem state. So if your
Package.swift decides something by looking at the filesystem, dropping the
binary in afterwards does not flip that decision — the manifest text has not
changed. Worse, the cache lives in more than one place. Clearing only Flutter's
ephemeral directory leaves Xcode's cloned SourcePackages and SwiftPM's
global manifest cache intact. That produced three consecutive green builds
with no SDK linked and nothing in any log to say so.

The idea: probe the filesystem from the manifest

Package.swift is not a data file. It is a Swift program that SwiftPM runs, and
it can read the filesystem. So the binary target can be declared conditionally:

// swift-tools-version: 5.9
import Foundation
import PackageDescription

let sdkPath = Context.packageDirectory
    + "/Frameworks/AcmeSDK.xcframework"
let sdkPresent = FileManager.default.fileExists(atPath: sdkPath)

var targets: [Target] = []
var kitDependencies: [Target.Dependency] = []
var kitSwiftSettings: [SwiftSetting] = []

if sdkPresent {
    targets.append(
        .binaryTarget(
            name: "AcmeSDK",
            path: "Frameworks/AcmeSDK.xcframework"
        )
    )
    kitDependencies.append("AcmeSDK")
    kitSwiftSettings.append(.define("ACME_ADS_SDK"))
}

targets.append(
    .target(
        name: "AcmeAdsKit",
        dependencies: kitDependencies,
        swiftSettings: kitSwiftSettings
    )
)
Enter fullscreen mode Exit fullscreen mode

Two things fall out of that. The package resolves whether or not the binary is
present, so CI and a fresh checkout are fine. And ACME_ADS_SDK is a
compilation condition, so the vendor-facing code can sit behind #if and simply
not exist in a build without the binary:

public enum AcmeAdsKit {
    #if ACME_ADS_SDK
    public static func probe() -> AcmeAdsKitState {
        // the vendor's real API goes here
    }
    #else
    public static func probe() -> AcmeAdsKitState {
        .unavailable(reason: "sdk_not_bundled")
    }
    #endif
}
Enter fullscreen mode Exit fullscreen mode

Making the target conditional while leaving the product and the dependency
unconditional is a real trap, incidentally: you get a manifest referencing a
target that does not exist, which is a package that cannot resolve at all.
There is a test for that.

Android has no #if. The two options were Class.forName reflection or two
source sets declaring the same object, and the generator picks source sets —
reflection would compile fine without the SDK while throwing away every
compile-time check against the vendor API, which is precisely the surface least
worth leaving unchecked:

val vendorAar = file("libs/AcmeSDK.aar")
val sdkPresent = vendorAar.exists()

android {
    sourceSets {
        getByName("main") {
            java.directories.add(
                if (sdkPresent) "src/withSdk/kotlin" else "src/noSdk/kotlin"
            )
        }
    }
}

dependencies {
    if (sdkPresent) {
        runtimeOnly(files(vendorAar))
    }
}
Enter fullscreen mode Exit fullscreen mode

runtimeOnly, not implementation — that is the bundleDebugAar fix from
above, and it is also the honest declaration of what is actually required at
compile time. java.directories.add rather than java.srcDir, and a top-level
kotlin { compilerOptions { } } rather than kotlinOptions inside android { },
because AGP 9.1 rejects the deprecated forms at script compilation — failing
the module before a single source file is read.

The two VendorBridge.kt files must keep identical signatures or main
compiles in one configuration and not the other, a break nobody sees until the
binary lands. A generated test asserts that.

Running it

Not on pub.dev yet, so install it from git:

dart pub global activate --source git \
  https://github.com/saimskywalker/binary-sdk-bridge.git
Enter fullscreen mode Exit fullscreen mode

That puts binary-sdk-bridge on your PATH via ~/.pub-cache/bin. From a
clone, dart run bin/binary_sdk_bridge.dart works with no install at all.

Two flavours from one generator. A Flutter plugin:

binary-sdk-bridge \
  --name acme_ads --org com.example \
  --ios-framework AcmeSDK --android-aar AcmeSDK \
  --out packages
Enter fullscreen mode Exit fullscreen mode

Or native only — an SPM package and a Gradle module, no pubspec, no Dart, no
Flutter dependency anywhere:

binary-sdk-bridge --flavor native \
  --name acme_sdk --org com.example \
  --ios-framework AcmeSDK --android-aar AcmeSDK \
  --out vendor
Enter fullscreen mode Exit fullscreen mode

--dry-run lists the files without writing them. The generated package ships
tool/fetch_ios_sdk.sh and tool/fetch_android_sdk.sh, which take either a
local path or a URL pinned to a SHA-256 — they refuse an unpinned download
rather than trusting it on first use, since the artifact links into a shipping
app. The iOS script also clears all three SwiftPM manifest caches, for the
reason above, and prints the two checks that actually prove the binary linked:

swift package --package-path ios/acme_sdk \
  describe --type json | grep '"type" : "binary"'

ls build/ios/iphonesimulator/Runner.app/Frameworks/
Enter fullscreen mode Exit fullscreen mode

Use those rather than a green build. flutter build passes -quiet to
xcodebuild, which suppresses the #warning the generated bridge emits, so the
absence of that warning proves nothing at all.

What this does not do

It does not write the vendor's calls for you. No generator can — that API is
whatever the vendor shipped, and exactly one generated file carries a TODO
for it. Everything around that file is already decided.

It does not fix publishing. While a local .aar is in place the module cannot
be published as a standalone AAR; the real fix is a Maven coordinate from the
vendor, and that is the vendor's decision, not yours.

It does not invent transitive dependency metadata. If the vendor ships a bare
.aar, you are still declaring its runtime dependencies by hand.

It cannot get you a checksum. The fetch scripts refuse to pin what the vendor
will not tell you.

And sometimes there is nothing to call at all. One SDK this was built against
turned out to expose no initialisation surface whatsoever — its adapters were
instantiated by a host SDK from a server response. That is why the generated
bridge starts life as a runtime presence check: it is the one question worth
answering even when there is nothing else to ask.

If you want to poke at it

Repo: https://github.com/saimskywalker/binary-sdk-bridge (MIT). It is early —
the structure is running against a real vendor SDK in a production app, but the
generator itself has rough edges, and they are written down as issues rather
than hidden.

A few are tagged
good first issue:
handling a vendor SDK delivered as a .zip, making the generator work on
Windows, and covering the CLI with tests. Everything lands through a reviewed
pull request, including from forks. If your vendor's SDK does not fit the shape
above, that is worth an issue on its own — there is a template for it.

Top comments (0)