Merge pull request #2 from resoftware/obfuscate-dex-with-r8

Obfuscate the Android DEX with R8
This commit is contained in:
Trevi Awater
2026-09-17 23:07:33 +02:00
committed by GitHub
4 changed files with 149 additions and 0 deletions

View File

@@ -50,6 +50,36 @@ dotnet test Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.Tests/
dotnet test Xamarin.Neo4j/Xamarin.Neo4j/Xamarin.Neo4j.IntegrationTests/
```
### Android DEX shrinking & obfuscation (R8)
Release builds of the Android head run R8 over the Java/DEX side
(`AndroidLinkTool=r8` in `Xamarin.Neo4j.Android.csproj`). Play Console's *DEX code
optimization* report scores obfuscation at ~1% on a stock .NET MAUI build: .NET for
Android runs no DEX shrinker by default, and even with R8 on, the ProGuard config the
SDK generates hardcodes `-dontobfuscate` — a flag nothing later in the config list can
undo. The `_AndroidObfuscateDex` target therefore drops that file from R8's `--pg-conf`
list and substitutes `Xamarin.Neo4j.Android/proguard_xamarin.cfg` (the same file minus
that line), plus a generated file carrying the `-printmapping`/`-keepattributes` tail
the SDK would have appended. Measured on the Release APK: **49.8% of DEX classes
renamed, up from ~0%**.
Only library-internal classes are renamed: the trimmer emits a `-keep` rule for every
Java type the managed bindings reference, aapt2 one for every class named in a layout
or the manifest, and each Android Callable Wrapper gets its own.
`Xamarin.Neo4j.Android/proguard.cfg` covers what those miss — things resolved by name
from native code. Add to it if a Release build dies with `ClassNotFoundException`,
`NoSuchFieldError` or `NoSuchMethodError` where a Debug build does not.
`mapping.txt` is embedded in the AAB
(`BUNDLE-METADATA/com.android.tools.build.obfuscation/proguard.map`), so Play Console
retraces obfuscated Java stacks by itself. Managed (C#) stack traces are unaffected —
R8 only touches DEX.
Local Release builds must be clean: aapt2's keep rules are registered as a `FileWrite`,
so an incremental build that skips the resource link has `IncrementalClean` delete them.
The target caches a copy outside `FileWrites` and errors out if even that is missing —
delete `Xamarin.Neo4j.Android/obj/Release` if it fires.
## Technology Stack
- .NET MAUI

View File

@@ -53,4 +53,56 @@
<ProjectReference Include="..\Xamarin.Neo4j\Xamarin.Neo4j.csproj" />
</ItemGroup>
<!-- Android Release: shrink and obfuscate the Java/DEX half of the app with
R8. .NET for Android runs no DEX shrinker by default, so Play Console's
"DEX code optimization" report scores obfuscation at ~1%. The trimmer emits
a keep rule for every Java type the managed bindings reference
(obj/.../proguard/proguard_project_references.cfg), aapt2 one for every
class named in a layout, and each Android Callable Wrapper gets its own,
so only library-internal classes are renamed. -->
<PropertyGroup Condition="'$(Configuration)' == 'Release'">
<AndroidLinkTool>r8</AndroidLinkTool>
</PropertyGroup>
<ItemGroup>
<ProguardConfiguration Include="proguard.cfg" />
</ItemGroup>
<!-- Swap the SDK's generated proguard_xamarin.cfg (which hardcodes
-dontobfuscate, a flag nothing later in the config list can undo) for our
copy without it, and regenerate the -printmapping/-keepattributes tail the
SDK appends to that same file so crash stacks stay retraceable. -->
<Target Name="_AndroidObfuscateDex" AfterTargets="_CalculateProguardConfigurationFiles" Condition="'$(AndroidLinkTool)' == 'r8'">
<PropertyGroup>
<_AndroidObfuscationProguardConfig>$(IntermediateOutputPath)proguard\proguard_obfuscate.cfg</_AndroidObfuscationProguardConfig>
<_AndroidAaptProguardRules>$(IntermediateOutputPath)proguard\aapt_rules.cached.txt</_AndroidAaptProguardRules>
</PropertyGroup>
<ItemGroup>
<_AndroidObfuscationProguardLines Include="-ignorewarnings" Condition="'$(AndroidR8IgnoreWarnings)' == 'True'" />
<_AndroidObfuscationProguardLines Include="-keepattributes SourceFile" />
<_AndroidObfuscationProguardLines Include="-keepattributes LineNumberTable" />
<!-- Absolute: R8 resolves a relative -printmapping against the directory
of the config file that declares it, not the working directory. -->
<_AndroidObfuscationProguardLines Include="-printmapping &quot;$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', '$(AndroidProguardMappingFile)'))&quot;" Condition="'$(AndroidProguardMappingFile)' != ''" />
</ItemGroup>
<MakeDir Directories="$(IntermediateOutputPath)proguard" />
<WriteLinesToFile File="$(_AndroidObfuscationProguardConfig)" Lines="@(_AndroidObfuscationProguardLines)" Overwrite="true" WriteOnlyWhenDifferent="true" />
<!-- aapt2 derives keep rules for every class named in a layout or the
manifest, but the SDK adds them to @(ProguardConfiguration) inside
_CreateBaseApkWithAapt2 and registers the file as a FileWrite, so an
incremental Release build that skips the resource link both loses the
item and has IncrementalClean delete the file, shipping an APK that
dies inflating androidx.appcompat.widget.FitWindowsFrameLayout. Keep a
copy outside FileWrites and feed R8 that one instead. -->
<Copy SourceFiles="$(IntermediateOutputPath)aapt_rules.txt" DestinationFiles="$(_AndroidAaptProguardRules)" SkipUnchangedFiles="true" Condition="Exists('$(IntermediateOutputPath)aapt_rules.txt')" />
<Error Condition="!Exists('$(_AndroidAaptProguardRules)')" Text="R8 is enabled but no aapt2 keep rules are available: delete obj\$(Configuration) and publish again to force a full resource link." />
<ItemGroup>
<_ProguardConfiguration Remove="$(IntermediateOutputPath)proguard\proguard_xamarin.cfg" />
<_ProguardConfiguration Remove="$(IntermediateOutputPath)aapt_rules.txt" />
<_ProguardConfiguration Include="$(MSBuildProjectDirectory)\proguard_xamarin.cfg" />
<_ProguardConfiguration Include="$(_AndroidObfuscationProguardConfig)" />
<_ProguardConfiguration Include="$(_AndroidAaptProguardRules)" />
</ItemGroup>
</Target>
</Project>

View File

@@ -0,0 +1,22 @@
# App-level R8 keep rules, on top of the ones .NET for Android generates
# (see proguard_xamarin.cfg and the _AndroidObfuscateDex target in the csproj).
# The .NET runtime resolves this one by name from native code -- no DEX
# reference points at it, so R8 renames it and startup dies with
# "ClassNotFoundException: net.dot.android.ApplicationRegistration".
# The SDK's own config only covers net.dot.jni.** and net.dot.android.crypto.**,
# because upstream never obfuscates.
-keep class net.dot.android.** { *; <init>(...); }
# The trimmer's generated keep rules (proguard_project_references.cfg) cover the
# Java *methods* the bindings call, but not their fields -- upstream never
# obfuscates, so the gap never showed. A bound property backed by a Java field
# reads it through JNI by name, and renaming breaks that:
# NoSuchFieldError: no "Landroidx/lifecycle/Lifecycle$State;" field "DESTROYED"
# NoSuchFieldError: no "I" field "left" in class "Landroidx/core/graphics/Insets;"
# Only the API surface a binding can reach needs it; private fields still get
# renamed, and class names -- what Play's report counts -- are untouched by this.
-keepclassmembers class * {
public <fields>;
protected <fields>;
}

View File

@@ -0,0 +1,45 @@
# Verbatim copy of the ProGuard configuration .NET for Android generates at
# obj/<Config>/<TFM>/proguard/proguard_xamarin.cfg, minus its hardcoded
# "-dontobfuscate". ProGuard has no switch that undoes that flag, so the only
# way to let R8 rename DEX classes is to drop the file that sets it from R8's
# --pg-conf list and substitute this one (see the _AndroidObfuscateDex target
# in the csproj).
#
# Taken from Microsoft.Android.Sdk 36.1.69 (.NET 10). When the Android SDK pack
# is updated, diff this against the generated file after a Release build --
# a keep rule added upstream and missed here means a runtime JNI failure.
# The trailing -ignorewarnings/-keepattributes/-printmapping lines the SDK
# appends to that file are regenerated by the csproj target instead.
-keep class android.support.multidex.MultiDexApplication { <init>(); }
-keep class net.dot.jni.** { *; <init>(); }
-keep class mono.MonoRuntimeProvider* { *; <init>(...); }
-keep class mono.MonoPackageManager { *; <init>(...); }
-keep class mono.MonoPackageManager_Resources { *; <init>(...); }
-keep class mono.android.** { *; <init>(...); }
-keep class mono.java.** { *; <init>(...); }
-keep class mono.javax.** { *; <init>(...); }
-keep class net.dot.jni.ManagedPeer { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_TrustManager { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_TrustManager_FakeSSLSession { *; <init>(...); }
-keep class xamarin.android.net.ServerCertificateCustomValidator_AlwaysAcceptingHostnameVerifier { *; <init>(...); }
-keep class android.runtime.** { <init>(...); }
-keep class assembly_mono_android.android.runtime.** { <init>(...); }
# hash for android.runtime and assembly_mono_android.android.runtime.
-keep class md52ce486a14f4bcd95899665e9d932190b.** { *; <init>(...); }
-keepclassmembers class md52ce486a14f4bcd95899665e9d932190b.** { *; <init>(...); }
# .NET runtime
-keep class net.dot.android.crypto.** { *; <init>(...); }
# Android's template misses fluent setters...
-keepclassmembers class * extends android.view.View {
*** set*(...);
}
# also misses those inflated custom layout stuff from xml...
-keepclassmembers class * extends android.view.View {
<init>(android.content.Context,android.util.AttributeSet);
<init>(android.content.Context,android.util.AttributeSet,int);
}