Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ public sealed record JavaPeerInfo
/// Types with component attributes ([Activity], [Service], etc.),
/// custom views from layout XML, or manifest-declared components
/// are unconditionally preserved (not trimmable).
/// May be set after scanning when the manifest references a type
/// that the scanner did not mark as unconditional.
/// </summary>
public bool IsUnconditional { get; init; }
public bool IsUnconditional { get; set; }

/// <summary>
/// True for Application and Instrumentation types. These types cannot call
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,19 @@
using System.IO;
using System.Linq;
using System.Reflection.PortableExecutable;
using System.Xml.Linq;

namespace Microsoft.Android.Sdk.TrimmableTypeMap;

public class TrimmableTypeMapGenerator
{
readonly Action<string> log;
readonly Action<string>? warn;

public TrimmableTypeMapGenerator (Action<string> log)
public TrimmableTypeMapGenerator (Action<string> log, Action<string>? warn = null)
{
this.log = log ?? throw new ArgumentNullException (nameof (log));
this.warn = warn;
}

/// <summary>
Expand All @@ -38,6 +41,8 @@ public TrimmableTypeMapResult Execute (
return new TrimmableTypeMapResult ([], [], allPeers);
}

RootManifestReferencedTypes (allPeers, manifestTemplatePath);

var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion);
var jcwPeers = allPeers.Where (p =>
!frameworkAssemblyNames.Contains (p.AssemblyName)
Expand All @@ -57,7 +62,10 @@ public TrimmableTypeMapResult Execute (

// Write merged acw-map.txt if requested
if (!acwMapOutputPath.IsNullOrEmpty ()) {
Directory.CreateDirectory (Path.GetDirectoryName (acwMapOutputPath));
var acwDirectory = Path.GetDirectoryName (acwMapOutputPath);
if (!acwDirectory.IsNullOrEmpty ()) {
Directory.CreateDirectory (acwDirectory);
}
using (var writer = new StreamWriter (acwMapOutputPath)) {
AcwMapWriter.Write (writer, allPeers);
}
Expand Down Expand Up @@ -99,7 +107,12 @@ IList<string> GenerateManifest (List<JavaPeerInfo> allPeers, AssemblyManifestInf
ApplicationJavaClass = config.ApplicationJavaClass,
};

return generator.Generate (manifestTemplatePath, allPeers, assemblyManifestInfo, mergedManifestOutputPath);
XDocument? manifestTemplateDoc = null;
if (!manifestTemplatePath.IsNullOrEmpty () && File.Exists (manifestTemplatePath)) {
manifestTemplateDoc = XDocument.Load (manifestTemplatePath);
}

return generator.Generate (manifestTemplateDoc, allPeers, assemblyManifestInfo, mergedManifestOutputPath);
}

(List<JavaPeerInfo> peers, AssemblyManifestInfo manifestInfo) ScanAssemblies (IReadOnlyList<(string Name, PEReader Reader)> assemblies)
Expand Down Expand Up @@ -144,4 +157,94 @@ List<GeneratedJavaSource> GenerateJcwJavaSources (List<JavaPeerInfo> allPeers)
log ($"Generated {sources.Count} JCW Java source files.");
return sources.ToList ();
}

void RootManifestReferencedTypes (List<JavaPeerInfo> allPeers, string? manifestTemplatePath)
{
if (manifestTemplatePath.IsNullOrEmpty () || !File.Exists (manifestTemplatePath)) {
return;
}

XDocument doc;
try {
doc = XDocument.Load (manifestTemplatePath);
} catch (Exception ex) {
warn?.Invoke ($"Failed to parse ManifestTemplate '{manifestTemplatePath}': {ex.Message}");
return;
}

RootManifestReferencedTypes (allPeers, doc);
}

internal void RootManifestReferencedTypes (List<JavaPeerInfo> allPeers, XDocument doc)
{
var root = doc.Root;
if (root is null) {
return;
}

XNamespace androidNs = "http://schemas.android.com/apk/res/android";
XName attName = androidNs + "name";
var packageName = (string?) root.Attribute ("package") ?? "";

var componentNames = new HashSet<string> (StringComparer.Ordinal);
foreach (var element in root.Descendants ()) {
switch (element.Name.LocalName) {
case "activity":
case "service":
case "receiver":
case "provider":
var name = (string?) element.Attribute (attName);
if (name is not null) {
componentNames.Add (ResolveManifestClassName (name, packageName));
}
break;
}
}

if (componentNames.Count == 0) {
return;
}

// Build lookup by dot-name, keeping '$' for nested types (manifests use '$' too).
var peersByDotName = new Dictionary<string, List<JavaPeerInfo>> (StringComparer.Ordinal);
foreach (var peer in allPeers) {
var dotName = peer.JavaName.Replace ('/', '.');
if (!peersByDotName.TryGetValue (dotName, out var list)) {
list = [];
peersByDotName [dotName] = list;
}
list.Add (peer);
}
Comment on lines +209 to +217
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

RootManifestReferencedTypes builds its lookup key by converting peer.JavaName with .Replace ('$', '.'), but Android manifest class names for nested types use $ (e.g., com.example.Outer$Inner), so those will never match and won't be rooted. Also, manifest android:name commonly supports relative names (e.g., .MyActivity or MyActivity), which won't match the current dictionary keys either. Normalize android:name values (resolve relative names using the manifest package attribute) and avoid converting $ to . for manifest matching.

Copilot uses AI. Check for mistakes.

foreach (var name in componentNames) {
if (peersByDotName.TryGetValue (name, out var peers)) {
foreach (var peer in peers) {
if (!peer.IsUnconditional) {
peer.IsUnconditional = true;
log ($"Rooting manifest-referenced type '{name}' ({peer.ManagedTypeName}) as unconditional.");
}
}
} else {
warn?.Invoke ($"Manifest-referenced type '{name}' was not found in any scanned assembly. It may be a framework type.");
}
}
}

/// <summary>
/// Resolves an android:name value to a fully-qualified class name.
/// Names starting with '.' are relative to the package. Names with no '.' at all
/// are also treated as relative (Android tooling convention).
/// </summary>
static string ResolveManifestClassName (string name, string packageName)
{
if (name.StartsWith (".", StringComparison.Ordinal)) {
return packageName + name;
}

if (name.IndexOf ('.') < 0 && !packageName.IsNullOrEmpty ()) {
return packageName + "." + name;
}

return name;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,9 @@ public override bool RunTask ()
ApplicationJavaClass: ApplicationJavaClass);
}

var generator = new TrimmableTypeMapGenerator (msg => Log.LogMessage (MessageImportance.Low, msg));
var generator = new TrimmableTypeMapGenerator (
msg => Log.LogMessage (MessageImportance.Low, msg),
msg => Log.LogWarning (msg));
result = generator.Execute (
assemblies,
systemRuntimeVersion,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,178 @@ public void Execute_JavaSourcesHaveCorrectStructure ()

TrimmableTypeMapGenerator CreateGenerator () => new (msg => logMessages.Add (msg));

TrimmableTypeMapGenerator CreateGenerator (List<string> warnings) =>
new (msg => logMessages.Add (msg), msg => warnings.Add (msg));

[Fact]
public void RootManifestReferencedTypes_RootsMatchingPeers ()
{
var peers = new List<JavaPeerInfo> {
new JavaPeerInfo {
JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity",
ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity",
AssemblyName = "MyApp", IsUnconditional = false,
},
new JavaPeerInfo {
JavaName = "com/example/MyService", CompatJniName = "com.example.MyService",
ManagedTypeName = "MyApp.MyService", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyService",
AssemblyName = "MyApp", IsUnconditional = false,
},
};

var doc = System.Xml.Linq.XDocument.Parse ("""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">
<application>
<activity android:name="com.example.MyActivity" />
</application>
</manifest>
""");

var generator = CreateGenerator ();
generator.RootManifestReferencedTypes (peers, doc);

Assert.True (peers [0].IsUnconditional, "MyActivity should be rooted as unconditional.");
Assert.False (peers [1].IsUnconditional, "MyService should remain conditional.");
Assert.Contains (logMessages, m => m.Contains ("Rooting manifest-referenced type"));
}

[Fact]
public void RootManifestReferencedTypes_WarnsForUnresolvedTypes ()
{
var peers = new List<JavaPeerInfo> {
new JavaPeerInfo {
JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity",
ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity",
AssemblyName = "MyApp",
},
};

var doc = System.Xml.Linq.XDocument.Parse ("""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">
<application>
<service android:name="com.example.NonExistentService" />
</application>
</manifest>
""");

var warnings = new List<string> ();
var generator = CreateGenerator (warnings);
generator.RootManifestReferencedTypes (peers, doc);

Assert.Contains (warnings, w => w.Contains ("com.example.NonExistentService"));
}

[Fact]
public void RootManifestReferencedTypes_SkipsAlreadyUnconditional ()
{
var peers = new List<JavaPeerInfo> {
new JavaPeerInfo {
JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity",
ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity",
AssemblyName = "MyApp", IsUnconditional = true,
},
};

var doc = System.Xml.Linq.XDocument.Parse ("""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">
<application>
<activity android:name="com.example.MyActivity" />
</application>
</manifest>
""");

var generator = CreateGenerator ();
generator.RootManifestReferencedTypes (peers, doc);

Assert.True (peers [0].IsUnconditional);
Assert.DoesNotContain (logMessages, m => m.Contains ("Rooting manifest-referenced type"));
}

[Fact]
public void RootManifestReferencedTypes_EmptyManifest_NoChanges ()
{
var peers = new List<JavaPeerInfo> {
new JavaPeerInfo {
JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity",
ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity",
AssemblyName = "MyApp",
},
};

var doc = System.Xml.Linq.XDocument.Parse ("""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">
</manifest>
""");

var generator = CreateGenerator ();
generator.RootManifestReferencedTypes (peers, doc);

Assert.False (peers [0].IsUnconditional);
}

[Fact]
public void RootManifestReferencedTypes_ResolvesRelativeNames ()
{
var peers = new List<JavaPeerInfo> {
new JavaPeerInfo {
JavaName = "com/example/MyActivity", CompatJniName = "com.example.MyActivity",
ManagedTypeName = "MyApp.MyActivity", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyActivity",
AssemblyName = "MyApp", IsUnconditional = false,
},
new JavaPeerInfo {
JavaName = "com/example/MyService", CompatJniName = "com.example.MyService",
ManagedTypeName = "MyApp.MyService", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "MyService",
AssemblyName = "MyApp", IsUnconditional = false,
},
};

var doc = System.Xml.Linq.XDocument.Parse ("""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">
<application>
<activity android:name=".MyActivity" />
<service android:name="MyService" />
</application>
</manifest>
""");

var generator = CreateGenerator ();
generator.RootManifestReferencedTypes (peers, doc);

Assert.True (peers [0].IsUnconditional, "Dot-relative name '.MyActivity' should resolve to com.example.MyActivity.");
Assert.True (peers [1].IsUnconditional, "Simple name 'MyService' should resolve to com.example.MyService.");
}

[Fact]
public void RootManifestReferencedTypes_MatchesNestedTypes ()
{
var peers = new List<JavaPeerInfo> {
new JavaPeerInfo {
JavaName = "com/example/Outer$Inner", CompatJniName = "com.example.Outer$Inner",
ManagedTypeName = "MyApp.Outer.Inner", ManagedTypeNamespace = "MyApp", ManagedTypeShortName = "Inner",
AssemblyName = "MyApp", IsUnconditional = false,
},
};

var doc = System.Xml.Linq.XDocument.Parse ("""
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example">
<application>
<activity android:name="com.example.Outer$Inner" />
</application>
</manifest>
""");

var generator = CreateGenerator ();
generator.RootManifestReferencedTypes (peers, doc);

Assert.True (peers [0].IsUnconditional, "Nested type 'Outer$Inner' should be matched using '$' separator.");
}

static PEReader CreateTestFixturePEReader ()
{
var dir = Path.GetDirectoryName (typeof (FixtureTestBase).Assembly.Location)
Expand Down