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,11 @@ 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 to <c>true</c> after scanning when the manifest references a type
/// that the scanner did not mark as unconditional. Should only ever be set
/// to <c>true</c>, never back to <c>false</c>.
/// </summary>
public bool IsUnconditional { get; init; }
public bool IsUnconditional { get; set; }
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

🤖 💡 API design — Changing IsUnconditional from init to set makes the record mutable after construction. The doc-comment update explains the "why" well. Consider adding a brief note in the doc-comment that callers should only set this to true (never back to false) to prevent accidental unrooting.

Rule: Document array mutability semantics (Postmortem #66)


/// <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 @@ -10,10 +10,12 @@ 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 +40,8 @@ public TrimmableTypeMapResult Execute (
return new TrimmableTypeMapResult ([], [], allPeers);
}

RootManifestReferencedTypes (allPeers, manifestTemplate);

var generatedAssemblies = GenerateTypeMapAssemblies (allPeers, systemRuntimeVersion);
var jcwPeers = allPeers.Where (p =>
!frameworkAssemblyNames.Contains (p.AssemblyName)
Expand Down Expand Up @@ -142,4 +146,76 @@ List<GeneratedJavaSource> GenerateJcwJavaSources (List<JavaPeerInfo> allPeers)
log ($"Generated {sources.Count} JCW Java source files.");
return sources.ToList ();
}

internal void RootManifestReferencedTypes (List<JavaPeerInfo> allPeers, XDocument? doc)
{
if (doc?.Root is not { } root) {
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 +180 to +188
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 @@ -97,7 +97,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));

XDocument? manifestTemplate = null;
if (!ManifestTemplate.IsNullOrEmpty () && File.Exists (ManifestTemplate)) {
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