Start Debugging

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:

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:

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):

  1. $(ProguardConfigFiles), if you set that property.
  2. The Android SDK’s proguard-android.txt (non-optimizing baseline). On newer SDKs with AndroidR8ObfuscationMode=private-members, this becomes proguard-android-optimize.txt.
  3. obj/.../proguard/proguard_xamarin.cfg: runtime keep rules for mono.android.**, net.dot.jni.**, and friends. On the shipping SDKs, this file starts with -dontobfuscate.
  4. proguard_project_references.cfg: keep rules for every Java type that a surviving managed type binds to, generated after ILLink.
  5. proguard_project_primary.cfg: one -keep class X { *; } rule per Java Callable Wrapper from the ACW map, so every Activity, Service, and custom View your C# defines survives.
  6. Your @(ProguardConfiguration) items.
  7. Consumer rules (proguard.txt) extracted from referenced .aar files.

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:

AndroidR8ObfuscationModeObfuscationOptimization baselineDefault
disablednone, all Java names preservedproguard-android.txt.NET 10 servicing
private-membersprivate and package-private members renamedproguard-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

Checking that R8 really ran

Do not trust the property alone; confirm it from build output:

  1. Build with a binary log: dotnet publish -f net11.0-android -c Release -bl. Open msbuild.binlog in the MSBuild Structured Log Viewer and search for the R8 task under _CompileToDalvik. If you only find D8, the property never reached the Android build, usually because its condition does not match your TargetFramework. If R8 ran but the log contains XA4304 for proguard_project_references.cfg, trimming is off and the app will crash at runtime.
  2. Check that bin/Release/net11.0-android/mapping.txt exists and has a fresh timestamp.
  3. Open obj/Release/net11.0-android/android-arm64/proguard/proguard_xamarin.cfg (the exact RID folder depends on your RuntimeIdentifiers). On 36.1.69 or 37.0.0-rc.1.2257 it starts with -dontobfuscate. On an SDK with AndroidR8ObfuscationMode=private-members, it starts with the -keep,allowshrinking,allowoptimization class ** block instead.
  4. Upload the .aab to 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 an r8.json build-metadata file when the bundle has one, and otherwise estimates them from mapping.txt. The SDK starts packaging r8.json with dotnet/android #12646, which is on release/10.0.1xx and main but not in 37.0.0-rc.1.2257.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back