[prototype] Two-pass R8 JNI name rewriting before ILLink and ILC - #12575
Draft
simonrozsival wants to merge 5 commits into
Draft
[prototype] Two-pass R8 JNI name rewriting before ILLink and ILC#12575simonrozsival wants to merge 5 commits into
simonrozsival wants to merge 5 commits into
Conversation
Rebuild managed PE metadata and IL with obfuscated JNI class, method, field, descriptor, RegisterNatives, and FieldRVA string data. Preserve compression descriptor ordering when rewritten assembly sizes change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Run a naming-only R8 pass before managed optimization, stage rewritten inputs for ILLink and ILC, and reuse the mapping during final Java shrinking. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Use descriptor-aware reverse member mappings and regenerate the post-link ACW reachability map while retaining the original Java input set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Context
Advances #12535.
.NET for Android currently prevents R8 name obfuscation because managed bindings embed Java/JNI class, method, field, and descriptor names. If R8 renames the Java side while those managed values remain unchanged, direct JNI lookup and native registration fail.
This draft prototypes a build-time solution with no runtime lookup table: R8 first establishes the Java names, managed assemblies are rewritten to those names before ILLink or NativeAOT ILC, and final R8 shrinking reuses the same mapping.
Mono.Cecil is intentionally not used. Managed PE rewriting is implemented with
System.Reflection.Metadata/System.Reflection.Metadata.Ecma335.How the design evolved
The initial investigation considered generating short names ourselves and supplying them to R8, or carrying an original-to-obfuscated lookup table into the app. A runtime table would support already-compiled bindings but would add packaged data, startup work, and lookup overhead to JNI operations. Letting R8 remain the naming authority also handles its actual collision and optimization behavior.
The first working prototype ran after final R8 and rewrote assemblies immediately before packaging. It proved the PE rewriter and produced a runnable MAUI app with a 2.40% signed-APK reduction, but it was CoreCLR-specific and had to repair compressed-assembly size metadata after rewriting.
The design in this PR now follows the backend-neutral pipeline discussed in #12535:
mapping.txt.allowobfuscation.-applymapping.Moving the rewrite before ILLink/ILC removes the old post-R8 compressed-assembly workaround and lets typemap generation and both managed backends consume the same obfuscated JNI names.
Implementation
Naming-only R8 pass
R8now supports a seed-mapping mode that:-dontobfuscaterule when JNI rewriting is enabled.The naming pass uses a dedicated
Javacinvocation. Reusing_CompileJavacaused a target-cycle through RID-specific_ResolveAssemblies, so the prototype compiles the generated Java sources and application JAR inputs without entering that graph.Managed assembly staging
RewriteJniNamesForR8can rewrite an item set into a destination directory while preserving MSBuild metadata. It uses content-aware output writes so unchanged staged files retain timestamps.For CoreCLR, staged assemblies replace both
ManagedAssemblyToLinkand the correspondingResolvedFileToPublishentries before_RunILLink. Replacing both is required because ILLink removes publish inputs by item identity; retaining the originals causes duplicate publish outputs.For NativeAOT, staged assemblies replace
ManagedBinary,IlcCompileInput, andIlcReferencebefore the ILC response file is written. Multi-RID inner builds are serialized while this prototype is enabled so they share one seed mapping safely.Mapping-aware final keep rules
GenerateProguardConfigurationreceives the seed mapping and reverses rewritten Java class/member names back to their original names. It emits-keep,allowobfuscationand-keepclassmembers,allowobfuscation, allowing final R8 to retain managed-reachable members without overriding-applymapping.Constructor handling accepts both managed
.ctorand rewritten<init>spellings and emits valid<init>(...);syntax.R8Mappingnow provides O(1) reverse class lookup and descriptor-aware reverse method lookup for overloaded methods.NativeAOT's prebuilt host is one deliberate exception to full remapping: it looks up
mono.android.Runtimeand its static fields by fixed JNI names from native code. Those literals cannot be rewritten in managed IL, so the small runtime bridge class and its members remain preserved while application and binding symbols are still obfuscated.PE rewrite coverage
The existing rewriter handles:
RegisterAttributetype/member names;JniPeerMembersidentifiers;RegisterNativesdata;Rewritten strong-named assemblies are currently left delay-signed because the build step does not have a signing key. That remains productization work.
Validation
CoreCLR
A Release
dotnet new maui --sample-contentapp was built with:net11.0-android;android-arm64;The signed APK built successfully, installed on an API 35 arm64 emulator, cold-launched its generated activity, rendered the sample-content UI, remained alive, and produced no JNI, managed, Java, or native fatal errors.
Seed/final mapping comparison found no surviving ordinary class-name disagreements. The remaining class differences are R8 removed/synthetic optimization artifacts; managed-reachable keep rules reuse the seed names.
Focused host tests pass: 45/45 across
R8Tests,R8MappingTests, andRewriteJniNamesForR8Tests.Measured CoreCLR results:
The first no-op build after a rebuild can still rerun expensive work (38.15 s observed); complete target
Inputs/Outputsremain follow-up work.NativeAOT
NativeAOT was validated end-to-end with the same MAUI sample,
android-arm64, Release, trimmable typemap, R8, andPublishAot=true. The local Android workload was tested with the available matching preview-7 .NET SDK/ILCompiler/Android NativeAOT framework pack because the checkout's RC compiler packs were not available on the configured feed.Both baseline and two-pass APKs built from clean
obj/bintrees, installed on an API 35 arm64 emulator, cold-launched, and remained alive after 12 seconds. The two-pass process remained alive as PID 3625 with no fatal, JNI lookup,NoSuchMethodError,NoSuchFieldError, or native loader errors.The first two-pass runtime attempt found a real NativeAOT-specific issue: R8 renamed
mono.android.Runtime.mono_android_GCUserPeer, while the prebuilt native host still requested that literal field name. Preserving the runtime bridge class fixed the failure; the clean rebuilt APK then launched successfully.Binary-log task timings (elapsed task time, not additive wall-clock time because MSBuild work can be nested or overlap):
The naming pass is the dominant added cost. This prototype favors maximum compression and correctness over Release build speed, as intended.
Managed IL and typemap size analysis
The size investigation exposed and fixed a correctness gap in the original prototype. FieldRVA typemap strings were already rewritten, but assembly-level
TypeMapAttribute<T>keys andJavaPeerAliasesAttributearrays were not. NativeAOT consumes that metadata to construct its type map, so leaving the original Java names there was not merely a missed size optimization: it could make the NativeAOT type map disagree with R8-renamed Java classes. The rewriter now recognizes generic attribute constructors encoded through aTypeSpecification, rewrites indexed keys such asjava/util/Collection[0]while preserving their suffix, and rewrites peer-alias arrays.A direct production-task measurement covered all 86 generated typemap assemblies and 13,031 real
TypeMapAttribute<T>entries, with a representative short-name mapping for all 12,855 distinct Java classes:.textvirtual size_Mono.Android.TypeMap.dllalone became 514,048 bytes smaller. The representative names were generally longer than aggressive R8 names, so this demonstrates a measurable, conservative IL/metadata reduction rather than forecasting an exact final APK delta.Ordinary binding assemblies do not show a comparable PE-size reduction. Their JNI
#USheap shrank by 108 bytes in the measured set, while reconstructed custom-attribute/blob layout grew slightly. An apparent ~758 KB reduction was almost entirely the rewriter removing Authenticode certificate data; after excluding certificates and PE file-alignment effects, those PEs grew by roughly 2 KB overall. That certificate removal must not be credited to shorter JNI strings.NativeAOT does not package these assemblies as IL: ILC compiles them into the application native library. The previously measured 1.81% NativeAOT APK reduction came almost entirely from
classes.dex(-636,720bytes uncompressed,-355,248bytes compressed); the application native library grew by 416 bytes uncompressed. A fresh end-to-end APK measurement with the corrected typemap attributes is still pending because the prototype clean-build seed-Javac graph does not yet populate the correctly classified AAR/JAR inputs early enough. The focused R8/JNI suite, including a structurally faithful generic typemap fixture, passes 66/66.Current limitations / follow-up work
_ResolveAssemblies.Inputs/Outputsaround staged rewriting; task outputs already use content-aware writes.$(_AndroidEnableR8JniNameRewriting).