How to enable R8 shrinking and obfuscation for a .NET MAUI Android release build
Set AndroidLinkTool to r8, keep trimming on, and add a ProguardConfiguration file. Why .NET 10 and .NET 11 RC 1 still ship un-obfuscated Java, how the new AndroidR8ObfuscationMode property changes that, and how to verify R8 actually ran.
Short answer: add <AndroidLinkTool>r8</AndroidLinkTool> to a Release-only PropertyGroup in your MAUI .csproj, leave trimming on (it is on by default in Release), and put any keep rules in a proguard.cfg file with the ProguardConfiguration build action. That turns on R8 shrinking and optimization of the Java side of your app. It does not obfuscate anything on the SDKs shipping today: .NET for Android 36.1.69 (.NET 10) and 37.0.0-rc.1.2257 (.NET 11 RC 1) both inject -dontobfuscate into the R8 configuration. Real obfuscation arrives with the new AndroidR8ObfuscationMode property, which defaults to private-members in .NET 11 after RC 1 and is an opt-in backport for the next .NET 10 servicing release.
That last detail matters more than it used to. Google announced on August 26, 2026 that from February 2027, app bundles on Google Play need at least 25% optimization, shrinking, and obfuscation coverage of their DEX code (Android vitals only alerts once a bundle carries 10 MB of DEX for apps, 50 MB for games). A MAUI app pulls in a lot of AndroidX and Google Play services Java, so the DEX side is not small.
Everything below was traced through the dotnet/android source at the release tags named above, so you can check each claim against the MSBuild targets yourself.
What R8 touches in a MAUI app, and what it does not
A MAUI Android package carries two kinds of code, and they are shrunk by different tools:
- Managed code (your C#, MAUI, the BCL) is trimmed by ILLink when
PublishTrimmedistrue. R8 never sees it. Obfuscating C# is a separate problem that R8 cannot solve. - Java bytecode (AndroidX, Material, Google Play services, Firebase, any
.aaryou bind, plus the Java Callable Wrappers the build generates for every managed type that extends a Java type) is turned intoclasses.dex. By default the D8 compiler does that with no shrinking. WithAndroidLinkTool=r8, R8 dexes and shrinks in one pass.
Google Play’s percentages are measured on the DEX, which is exactly the R8 half. So when people say “enable R8 in MAUI”, they mean making that Java half smaller and, eventually, renamed.
The shrinking alone is worth having. In dotnet/android #12535, a developer measured a .NET 10 app on 36.1.69 at 20.18 MB of uncompressed DEX with D8 and 11.43 MB with R8 and the default SDK rules. That is close to half the Java code gone, with no keep rules written by hand.
The minimal project change
This is the whole configuration for a MAUI app targeting .NET 10 and .NET 11:
<!-- MyApp.csproj, .NET 10 (Microsoft.Android.Sdk 36.1.x) and .NET 11 RC 1 (37.0.0-rc.1) -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net11.0-android;net11.0-ios</TargetFrameworks>
<OutputType>Exe</OutputType>
<UseMaui>true</UseMaui>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)' == 'Release' And $(TargetFramework.Contains('-android'))">
<AndroidLinkTool>r8</AndroidLinkTool>
<!-- Default in Release already. Written out because R8 without trimming strips Java types your C# still uses. -->
<PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>
<ItemGroup Condition="$(TargetFramework.Contains('-android'))">
<ProguardConfiguration Include="Platforms/Android/proguard.cfg" />
</ItemGroup>
</Project>
Then publish as usual:
dotnet publish -f net11.0-android -c Release
The proguard.cfg can start empty. You only add rules to it when R8 removes something that is reached by reflection, which is covered further down.
What the SDK does when you set AndroidLinkTool
AndroidLinkTool is the only switch you need, because Xamarin.Android.Common.targets derives the rest from it. Condensed from the .NET 10 and .NET 11 targets:
<!-- Xamarin.Android.Common.targets (dotnet/android 36.1.69 and 37.0.0-rc.1.2257), condensed -->
<AndroidDexTool Condition=" '$(AndroidLinkTool)' == 'r8' ">d8</AndroidDexTool>
<AndroidLinkTool Condition=" '$(AndroidLinkTool)' == 'proguard' And '$(AndroidEnableDesugar)' == 'True' ">r8</AndroidLinkTool>
<AndroidEnableProguard Condition=" '$(AndroidLinkTool)' != '' ">True</AndroidEnableProguard>
<AndroidCreateProguardMappingFile Condition="'$(AndroidCreateProguardMappingFile)' == '' And '$(AndroidLinkTool)' == 'r8'">True</AndroidCreateProguardMappingFile>
<AndroidProguardMappingFile Condition=" '$(AndroidLinkTool)' == 'r8' And '$(AndroidCreateProguardMappingFile)' == 'True' ">$(OutputPath)mapping.txt</AndroidProguardMappingFile>
A few consequences fall out of that:
AndroidLinkTool=proguardis quietly upgraded tor8, because desugaring is on by default with D8. The standalone ProGuard tool is not used by modern .NET for Android.- The old Xamarin-era
AndroidEnableProguard=true/EnableProguard=truestill works, but produces warning XA1028 (or XA1027) and defaults the link tool toproguard, which then becomesr8. SetAndroidLinkTooldirectly and skip the warning. - A
mapping.txtis produced in$(OutputPath)by default (for examplebin/Release/net11.0-android/mapping.txt), anddotnet publishcopies it to the publish folder. When you build an.aab, the mapping file is also embedded in the bundle metadata ascom.android.tools.build.obfuscation/proguard.map, so Play Console picks it up without a manual upload. For a sideloaded.apk, you upload it yourself.
Why R8 needs trimming turned on
R8 cannot work out by itself which Java types your C# still uses. That list comes from the .NET trimmer: after ILLink runs, a custom step writes proguard_project_references.cfg with a keep rule for every Java type that a surviving managed type binds to. The target that generates it is conditioned on trimming:
<!-- Microsoft.Android.Sdk.TypeMap.LlvmIr.targets, 37.0.0-rc.1.2257 -->
<Target Name="_GenerateProguardConfiguration"
AfterTargets="_PrepareLinkedAssembliesForProguard"
Condition=" '$(PublishTrimmed)' == 'true' and '$(_ProguardProjectConfiguration)' != '' "
The decision to run R8 does not check trimming, though. Xamarin.Android.D8.targets only needs the path property to be set, and _ResolveAssemblies sets it for every build where AndroidLinkTool is non-empty:
<!-- Xamarin.Android.D8.targets, same in 36.1.69 and 37.0.0-rc.1.2257 -->
<_UseR8 Condition=" ('$(AndroidLinkTool)' == 'r8' And '$(_ProguardProjectConfiguration)' != '') Or '$(AndroidEnableMultiDex)' == 'True' ">True</_UseR8>
So with PublishTrimmed=false (or AndroidLinkMode=None in Release, a common workaround for reflection problems), R8 still runs, but without the file that protects your bindings. The build only logs XA4304 (“ProGuard configuration file ‘…proguard_project_references.cfg’ was not found”), and the app then dies with java.lang.ClassNotFoundException the first time it touches a Java type R8 removed. That exact sequence is dotnet/android #6612, where the maintainers confirmed that R8 depends on the .NET linker being enabled.
That is also why the Release condition in the project file is not cosmetic. Debug builds do not trim, so an unconditional AndroidLinkTool=r8 makes Debug run R8 without the references file as well, and with fast deployment on you also get XA0119: “Using fast deployment and a code shrinker at the same time is not recommended”.
Which configuration files R8 actually receives
When R8 runs, the SDK assembles its --pg-conf inputs in this order (_ProguardConfiguration items in Xamarin.Android.Common.targets):
$(ProguardConfigFiles), if you set that property.- The Android SDK’s
proguard-android.txt(non-optimizing baseline). On newer SDKs withAndroidR8ObfuscationMode=private-members, this becomesproguard-android-optimize.txt. obj/.../proguard/proguard_xamarin.cfg: runtime keep rules formono.android.**,net.dot.jni.**, and friends. On the shipping SDKs, this file starts with-dontobfuscate.proguard_project_references.cfg: keep rules for every Java type that a surviving managed type binds to, generated after ILLink.proguard_project_primary.cfg: one-keep class X { *; }rule per Java Callable Wrapper from the ACW map, so everyActivity,Service, and customViewyour C# defines survives.- Your
@(ProguardConfiguration)items. - Consumer rules (
proguard.txt) extracted from referenced.aarfiles.
Items 4 and 5 are why a MAUI app rarely needs hand-written keep rules for its own types: the build already knows which Java classes the managed side can reach. What it cannot know is what Java code reaches by reflection.
Why you get shrinking but no obfuscation today
ProGuard options are global. If any configuration file says -dontobfuscate, obfuscation is off for the entire R8 run, and there is no opposite flag you can add in your own proguard.cfg to turn it back on. Because proguard_xamarin.cfg in 36.1.69 and 37.0.0-rc.1.2257 contains that line, an R8-enabled MAUI build on either SDK shrinks and optimizes but keeps every Java name intact. The mapping.txt it writes still records removed members and line-number changes, but it will not show renames.
The measurements in #12535 match: 34 of 15,235 classes renamed in one app’s mapping.txt (0.2%), and Play Console reporting 1% obfuscation for another. Setting AndroidCreateProguardMappingFile=true, which some answers suggest, changes nothing here; it only controls whether the mapping file is written.
The blanket -dontobfuscate was the safe choice. JNI binds managed peers to Java classes by name, so renaming a Java Callable Wrapper or a bound AndroidX method would break JNIEnv lookups at runtime. The same thread found that removing the line by hand is not enough either: the generated keep rules did not protect fields that bindings read by name through JNI, so apps crashed at startup. Wait for the supported switch below instead of patching the SDK’s configuration.
Turning on real obfuscation with AndroidR8ObfuscationMode
dotnet/android #12668, merged on September 10, 2026, replaces the blanket rule with a selective one and adds a public property:
AndroidR8ObfuscationMode | Obfuscation | Optimization baseline | Default |
|---|---|---|---|
disabled | none, all Java names preserved | proguard-android.txt | .NET 10 servicing |
private-members | private and package-private members renamed | proguard-android-optimize.txt | .NET 11 after RC 1 |
The same day, #12752 backported it to release/10.0.1xx with disabled as the default, so a servicing update does not change existing apps. No released tag contains it yet (36.1.69 predates it, and release/11.0.1xx-rc1 was cut before the merge), so expect it in .NET 11 RC 2 and the next .NET 10 servicing update.
In private-members mode, the R8 task writes these rules in place of -dontobfuscate:
# Generated by the R8 task in dotnet/android main (post .NET 11 RC 1)
-keep,allowshrinking,allowoptimization class **
-keepclassmembers,allowshrinking,allowoptimization class ** {
public protected *;
}
-keep,allowoptimization interface ** {
public protected *;
}
-keep,allowshrinking class * implements **
Read that as: every class keeps its name, every public and protected member keeps its name, and anything private or package-private may be renamed. Unused code can still be removed. The interface rules exist because managed proxy selection calls Class.getInterfaces(), which R8 cannot see; without them, class merging could drop an interface relationship and hand managed code the wrong proxy.
To opt in on .NET 10 once the servicing release lands, or to opt out on .NET 11:
<!-- .NET 10 servicing (opt in) or .NET 11 (opt out with "disabled") -->
<PropertyGroup Condition="'$(Configuration)' == 'Release' And $(TargetFramework.Contains('-android'))">
<AndroidLinkTool>r8</AndroidLinkTool>
<AndroidR8ObfuscationMode>private-members</AndroidR8ObfuscationMode>
</PropertyGroup>
Any other value fails the build with XA1050: “The ‘AndroidR8ObfuscationMode’ MSBuild property has an invalid value”. The PR also removes the undocumented _AndroidR8DontObfuscate and _AndroidR8DontOptimize switches, so drop them from your project if you copied them from an issue thread.
Keep your expectations calibrated. The PR reports that Google Play measured a dotnet new maui -sc template at 62% optimization, 65% shrinking, and 28% obfuscation. That clears the 25% bar, but obfuscation is the tight one, because JNI-visible names cannot be renamed. Treat private-members as “enough for the Play requirement”, not as protection for your C# logic.
Writing keep rules that actually matter
R8 only removes Java code it can prove is unreachable. The code you need rules for is code reached in ways R8 cannot see:
# Platforms/Android/proguard.cfg (.NET 10 / .NET 11, R8 via AndroidLinkTool=r8)
# A Java SDK that loads its own classes with Class.forName and ships no consumer rules
-keep class com.example.vendorsdk.** { *; }
# Classes you look up by string from C#, e.g. Java.Lang.Class.ForName("com.example.Probe")
-keep class com.example.Probe { *; }
# JSON models serialized by a Java library (Gson, Moshi) that uses reflection
-keepattributes Signature,*Annotation*
-keep class com.example.api.models.** { <fields>; }
# Silence a known-harmless missing optional class instead of ignoring all warnings
-dontwarn androidx.window.extensions.**
Two diagnostic rules are worth adding temporarily while you tune this, because your own ProguardConfiguration file is treated as application config and may use global options:
# Temporary: dump what R8 removed and the fully merged configuration
-printusage r8-usage.txt
-printconfiguration r8-merged.txt
R8 resolves those relative paths against the folder of the config file, so both land next to proguard.cfg. Search r8-merged.txt for -dontobfuscate to confirm which obfuscation behavior your SDK applied, and search r8-usage.txt for a class name to prove R8 removed it before you write a rule for it. Remove both lines before you commit, because they add time to every Release build.
Gotchas that bite in practice
- Missing class warnings are hidden by default.
AndroidR8IgnoreWarningsdefaults toTrue, which adds-ignorewarningsand (since .NET 8) passes--map-diagnostics warning info, so R8’s “Missing class” messages show up as info lines in a detailed build log. That is why issues like dotnet/maui #10901 (androidx.window.extensions.WindowExtensions) usually do not fail the build. Setting it toFalseis stricter and can turn a missing class into a build error. Fix it with a targeted-dontwarn, not by flipping the global switch back. - A misspelled path is only a warning. If the
ProguardConfigurationpath does not exist, you get XA4304 (“ProGuard configuration file ’…’ was not found”) and R8 runs without your rules. Treat XA4304 as an error in CI with<WarningsAsErrors>XA4304</WarningsAsErrors>. EnableR8andAndroidLinkMode=r8do nothing. Neither exists as an R8 switch. MSBuild silently accepts unknown properties, andAndroidLinkModeonly drives the managed trimmer (None,SdkOnly,Full). OnlyAndroidLinkTool=r8turns R8 on.- Library rules with global options are skipped. Starting with .NET 11 Preview 7, a
proguard.txtinside an.aarthat contains-dontobfuscate,-dontoptimize,-printmapping, or similar is dropped with XA4322, which is the same restriction AGP 9 introduced. If a vendor library suddenly crashes after the upgrade, check the build log for XA4322 and copy its keep rules (minus the global option) into your ownproguard.cfg. - The MS Learn build-item page is out of date. It says
ProguardConfigurationfiles are ignored unlessEnableProguardisTrue. WithAndroidLinkTool=r8,AndroidEnableProguardis forced toTruefor you, so the items are used. - Obfuscated stack traces need the mapping file. Once
private-membersis on, private Java frames in a crash report read likea.b.c. Keepmapping.txtfrom every Release build you ship (the.aabcarries it to Play, crash reporters like Firebase Crashlytics need it uploaded separately). - R8 costs build time. Expect Release builds to take noticeably longer, since R8 does whole-program analysis over all the AndroidX and Play services bytecode. Keep it in Release only.
Checking that R8 really ran
Do not trust the property alone; confirm it from build output:
- Build with a binary log:
dotnet publish -f net11.0-android -c Release -bl. Openmsbuild.binlogin the MSBuild Structured Log Viewer and search for theR8task under_CompileToDalvik. If you only findD8, the property never reached the Android build, usually because its condition does not match yourTargetFramework. If R8 ran but the log contains XA4304 forproguard_project_references.cfg, trimming is off and the app will crash at runtime. - Check that
bin/Release/net11.0-android/mapping.txtexists and has a fresh timestamp. - Open
obj/Release/net11.0-android/android-arm64/proguard/proguard_xamarin.cfg(the exact RID folder depends on yourRuntimeIdentifiers). On 36.1.69 or 37.0.0-rc.1.2257 it starts with-dontobfuscate. On an SDK withAndroidR8ObfuscationMode=private-members, it starts with the-keep,allowshrinking,allowoptimization class **block instead. - Upload the
.aabto an internal testing track and read the optimization, shrinking, and obfuscation percentages in Play Console’s app bundle explorer. That is the number Google enforces, so it is the one to watch. Play reads the percentages from anr8.jsonbuild-metadata file when the bundle has one, and otherwise estimates them frommapping.txt. The SDK starts packagingr8.jsonwith dotnet/android #12646, which is onrelease/10.0.1xxandmainbut not in 37.0.0-rc.1.2257.
Related reading
- If Play also rejected your bundle for native library alignment, the fix is covered in Google Play rejecting a MAUI app for 16 KB page size.
- Switching the runtime changes the APK contents R8 works around, see migrating a MAUI Android app from Mono to CoreCLR in .NET 11.
- The other Play requirement due this cycle is handled in targeting Android API level 36 from .NET MAUI.
- When a Release build fails inside the Java toolchain rather than silently, start with Gradle build failed to produce an .apk file in MAUI Android.
- The
-printusagetechnique above is the same one used to rule R8 out in Firebase Auth sign-in not persisting in a Flutter Android release build.
Sources
- .NET for Android build properties (
AndroidLinkTool,AndroidCreateProguardMappingFile,AndroidProguardMappingFile,AndroidR8IgnoreWarnings) - .NET for Android build items (
ProguardConfiguration,AndroidAppBundleMetaDataFile) - D8 and R8 integration spec in dotnet/android
Xamarin.Android.D8.targetsandproguard_xamarin.cfgat 37.0.0-rc.1.2257- dotnet/android #6612: R8 without the .NET linker and #12535: unconditional -dontobfuscate vs the Play requirement
- dotnet/android #12668: configurable private-member obfuscation and optimization and the .NET 10 backport #12752
- Android Developers Blog: reducing memory usage and improving device migration (February 2027 DEX optimization requirement)
- DEX code optimization in Android vitals (10 MB / 50 MB DEX thresholds)
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.