Topic 13 · Shipping
Android vendor SDK integration
Gradle templates, AGP, native lib collisions and SDK ports.
Porting a mobile vendor SDK (ads, tournaments, attribution) onto a modern Unity: Gradle templates, AGP, build hooks, native lib collisions, and stripping the SDK back out for a store build.
- VENDOR-SDK-GRADLE-UNITY6-PORT — porting a mobile SDK (Skillz, ad/attribution/tournament SDKs) to Unity 6 is THREE separate layers, and fixing only the Gradle templates gets you a build that still fails. Vendors ship 2019-era templates plus editor build hooks; Unity 6.5 bundles AGP 9.0.0. Everything below was measured on Unity 6000.5.0f1 + Skillz 2025.0.19, which went from "cannot build at all" to a green APK.
Layer 1 — the templates. Diff the vendor's copies against
<UnityInstall>/Editor/Data/PlaybackEngines/AndroidPlayer/Tools/GradleTemplates/, never against
memory. What changed by Unity 6.5: the substitution tokens **MINAPI** and a shared
**APIVERSION** for targetSdk became **MINSDK** / **TARGETSDK** (**APIVERSION** survives
for compileSdk only); baseProjectTemplate moved from allprojects { buildscript { classpath
'com.android.tools.build:gradle:X' } } to plugins { id 'com.android.application' version '9.0.0'
apply false }; namespace is mandatory (AGP 8+); dexOptions was removed in AGP 8.0;
compileOptions moved to Java 17; and both main and launcher now expect
apply from: '../shared/common.gradle'. An unrecognised token is NOT an error — it is copied
through literally into build.gradle and dies as a syntax error, so a stale template reads as
a mystery Groovy failure. Confirm which tokens a given editor actually supports by string-scanning
UnityEditor.Android.Extensions.dll / Unity.Android.Gradle.dll — they are .NET UTF-16, so strip
NULs first (tr -d '\0' < x.dll | grep -aoF '**MINSDK**'); a plain ASCII grep silently finds
nothing and looks like proof of absence.
Layer 2 — the editor build hooks, which no template edit can reach. Vendors implement
IPostGenerateGradleAndroidProject and write directly into the generated project, downstream
of every template. Skillz appends android.enableR8=true, an option removed in AGP 7.0, and
AGP hard-fails: "Failed to apply plugin 'com.android.internal.application'. The option
'android.enableR8' is deprecated. It was removed in version 7.0." You usually cannot order a
cleanup hook after the vendor's, because vendors habitually declare
callbackOrder => int.MaxValue. So the fix is to patch the vendor C# — which an SDK upgrade then
silently reverts. Guard it (#if UNITY_2021_2_OR_NEWER return;), leave a greppable marker, and
record it in a canonical folder as a re-apply step. Watch for the same hooks appending a second
unityStreamingAssets= key: Java properties take the LAST one, so the vendor value silently
replaces Unity's substituted noCompress list.
Layer 3 — native library collisions. Any SDK carrying React Native (Skillz, several
attribution SDKs) ships its own libc++_shared.so, colliding with Unity's NDK copy at
:launcher:mergeDebugNativeLibs ("2 files found with path 'lib/arm64-v8a/libc++_shared.so'").
Fix in the launcher module — that is where merging happens — with
packaging { jniLibs { pickFirsts += ['**/libc++_shared.so', '**/libjsc.so', '**/libfbjni.so'] } }.
unityLibrary is the first merge input, so pickFirst keeps the copy matching Unity's NDK.
Two traps that waste hours on their own:
- useCustomLauncherGradleManifest is the flag for the launcher GRADLE TEMPLATE. Unity's field
says "Manifest"; there is no useCustomLauncherGradleTemplate. Grepping ProjectSettings for the
name you expect returns nothing and makes an active template look inert — so its dexOptions
gets left in place as "dead weight" and keeps failing the build.
- settingsTemplate.gradle sets RepositoriesMode.PREFER_SETTINGS, which makes every repository
declared in baseProjectTemplate's allprojects { repositories { … } } silently ignored. A
vendor that adds its Maven/JitPack repo there resolves nothing. Move repos into
dependencyResolutionManagement, and place them outside the // Android Resolver Repos
markers or EDM4U's next Force Resolve drops them.
Diagnose from the generated project, never the templates:
Library/Bee/Android/Prj/IL2CPP/Gradle/ holds the real gradle.properties (unity.agpVersion,
unity.compileSdkVersion, resolved minSdk/targetSdk) and the real build.gradle. Grepping it for
a leftover \*\*[A-Z_]+\*\* is a one-command proof that substitution succeeded. And note the
officially-supported-Unity-version list is not a verdict: Skillz documents 2021.3/2022.3 LTS only,
yet the SDK's C# compiled clean on 6.5 untouched — the incompatibility was entirely in the Android
build layer.
- VENDOR-SDK-STRIP-IS-SIX-LAYERS — shipping a store build of a game that also ships a vendor-SDK
build is NOT a scripting define, and the two layers that bite are invisible in the project. A
define covers your own call sites; it covers nothing the vendor owns. Measured on
a tic-tac-toe game (Unity 6000.5.0f1 + Skillz 2026.1.1), taking a store APK from "cannot build" to
play-verified on device. The layers, in the order they bit:
1. Your call sites — the define.
2. The SDK's own C# — vendors routinely ship no asmdef, so all 135 files compile into
Assembly-CSharpunconditionally. An asmdefdefineConstraintsyou add is reverted by the next SDK import, so removal is the only durable answer. 3. ItsIPostGenerateGradleAndroidProjecthooks, gated on nothing butUNITY_ANDROID. Skillz rewrites the launcher activity tocom.skillz.activity.UnityGameActivity, a class that exists only inside the.aar. Drop the Gradle dependency while leaving the C# and the store build compiles, signs, installs, then dies at launch withClassNotFoundExceptionbefore Unity ever starts — no Unity log, just a black screen. 4.Assets/<Sdk>/Resources/+ the Gradle dependency (the size: 240 MB vs 108 MB here). 5.Assets/StreamingAssets/— bundles live OUTSIDE the SDK folder, so moving it misses them, and StreamingAssets is force-packed likeResources/. The first "clean" APK still carriedassets/skillz-assets+assets/skillz-scenes. Grep the built APK, not the project. 6. The generated Gradle project (Library/Bee/Android/Prj/IL2CPP/Gradle/). It is incremental — Unity never wipes it — and vendor hooks write directly into it, so residue CROSSES build configurations. Skillz'sconsumer-rules.pro(declaring-dontobfuscate, which AGP 9 rejects in a consumer config) survived the SDK's removal and failed the store build at:unityLibrary:mergeReleaseConsumerProguardFiles. Delete the whole tree when switching configuration; a denylist of the three known filenames goes stale the moment a vendor writes a fourth, and the failure mode is silent cross-config contamination.
The clean mechanism for 2-6 is one action: move the SDK out of Assets/. Unity ignores any
directory whose name ends in ~, so a <Sdk>SDK~/ vault is invisible to the importer — no
compile, no Resources, no build hooks, on every platform at once. Move it with its .meta
files so asset GUIDs survive the round trip and prefab references still bind.
Two traps in implementing the switch itself. (a) Derive current state by reading every layer
back off disk, never from an EditorPrefs "flavor" pref — a pref that disagrees with reality is
exactly how a store build ships with a vendor launcher activity in it; show the layers separately
so a half-applied switch reads as MIXED STATE rather than a single lying boolean. (b) Guard the
switch on EditorApplication.isCompiling and Play mode — it moves folders Unity has open — and
surface the refusal message, or a chained switch silently no-ops.
-
VENDOR-TEMPLATE-PORT-DIFF-STOCK — when porting a vendor's Gradle templates to a new Unity, diff against the ENGINE's stock template, not just against the build error. A vendor template contains two different things: scaffolding that needs new-AGP syntax fixes, and content the vendor added. Fixing only what the compiler complains about silently keeps the vendor's additions, which then break the moment you try to build without that vendor. Concretely: Unity 6.5 stock declares
consumerProguardFiles 'proguard-unity.txt'; Skillz's adds, 'consumer-rules.pro'. Carrying that into a no-vendor template makes Gradle demand a file only the vendor's hook writes, failing withSupplied consumer proguard configuration does not exist— the mirror image of the residue error above, and reached by fixing the first one. Stock templates live at<UnityInstall>/Editor/Data/PlaybackEngines/AndroidPlayer/Tools/GradleTemplates/; diff comment- and blank-stripped, and justify every remaining line (EDM4U's generated repo/exclusion blocks are legitimately shared with other SDKs and stay). -
UNITY6-GAMEACTIVITY-BREAKS-VENDOR-ACTIVITIES (HARD) — a vendor SDK that supplies its own Android launcher Activity is almost certainly subclassing
com.unity3d.player.UnityPlayerActivity, which Unity 6 does not ship by default. The APK builds, installs, and dies instantly. Unity 6's default Application Entry Point is GameActivity, which shipsUnityPlayerGameActivityinstead of the legacyUnityPlayerActivity. Any vendor activity extending the legacy class then fails to load, and the logcat names the WRONG class first:Unable to instantiate activity ComponentInfo{...com.skillz.activity.UnityGameActivity}->ClassNotFoundException: com.skillz.activity.UnityGameActivity->Caused by: ClassNotFoundException: com.unity3d.player.UnityPlayerActivity. Read the NESTED cause: the vendor class is present in the dex, its superclass is not, so "the vendor class is missing" is a red herring that sends you hunting proguard rules and merge order. Fix:PlayerSettings.Android.applicationEntry = AndroidApplicationEntry.Activity(Player Settings -> Android -> Configuration -> Application Entry Point). It is a flags enum, so both can be included, but a vendor that rewrites the launcher activity in the manifest wants the legacy one selected. Keep GameActivity for builds without that SDK — it is the better default (input handling, modern lifecycle), so scope the setting per build configuration rather than flipping it globally. This is invisible to every build-time check — Gradle, manifest merge, and dex all succeed; only launching on a device catches it. Verified on Unity 6000.5.0f1 + Skillz 2026.1.1: with GameActivity the app crashed before Unity started; with the legacy entry point the vendor shell booted and rendered.
ONE-SOURCE-OF-TRUTH-FOR-VENDOR-DEPS — EDM4U's generated list fights a transitive vendor POM
A vendor SDK whose .aar POM already declares its full dependency set gives you two ways to get
those dependencies, and running both breaks the build:
implementation("com.vendor:sdk:<version>") { transitive = true }— the POM supplies the set.- EDM4U resolving
VendorDependencies.xmlinto the// Android Resolver Dependenciesblock.
Do one. Doing both ships libraries under two coordinates — the POM's and the vendor XML's — and
the build dies late, at :launcher:checkReleaseDuplicateClasses or the manifest merger, on messages
that name a library nobody declared twice:
Duplicate class com.chauthai.swipereveallayout.SwipeRevealLayout found in modules
SwipeRevealLayout-1.4.0.aar (com.github.chthai64:SwipeRevealLayout:1.4.0)
swipe-reveal-layout-1.4.0.aar (com.chauthai.swipereveallayout:swipe-reveal-layout:1.4.0)
The generated list also imports the vendor's publishing mistakes. One measured case: a
gesturehandler POM on the vendor's own nexus declared a transitive on
Example:react-native-reanimated:unspecified — a group and version in no repository — which fails
resolution before a line is compiled. Pulled transitively from the SDK aar the same artifact resolves
fine; only the generated list reproduced the broken coordinate.
Keep the choice enforced, not remembered. EDM4U repopulates the block whenever it resolves, so the decision has to live in settings, not in a comment someone reads later:
<!-- ProjectSettings/GvhProjectSettings.xml -->
<projectSetting name="GooglePlayServices.AutoResolverEnabled" value="False" />
<projectSetting name="GooglePlayServices.AutoResolveOnBuild" value="False" />
<projectSetting name="GooglePlayServices.PromptBeforeAutoResolution" value="False" />
<projectSetting name="GooglePlayServices.AutoResolutionDisabledWarning" value="False" />
The last two matter as much as the first: with resolution merely off, EDM4U still raises a modal
asking to turn it back on — see EDITOR-MODAL-FROM-INSIDE-A-BUILD-DEADLOCKS in the agent compile page.
And when a fresh project inherits templates from a donor, the donor's GvhProjectSettings.xml is
part of the template set — copying Assets/Plugins/Android/*.gradle without it leaves the resolver
free to undo the very arrangement the templates document.