diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AndroidEnumConverter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AndroidEnumConverter.cs index c44573a2302..6c2c73b3009 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AndroidEnumConverter.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AndroidEnumConverter.cs @@ -17,6 +17,14 @@ static class AndroidEnumConverter _ => null, }; + public static string? RotationAnimationToString (int value) => value switch { + 0 => "rotate", + 1 => "crossfade", + 2 => "jumpcut", + 3 => "seamless", + _ => null, + }; + public static string? ScreenOrientationToString (int value, int targetSdkVersion = 0) => value switch { -1 => "unspecified", 0 => "landscape", @@ -126,8 +134,8 @@ static class AndroidEnumConverter public static string? ActivityPersistableModeToString (int value) => value switch { 0 => "persistRootOnly", - 1 => "persistAcrossReboots", - 2 => "persistNever", + 1 => "persistNever", + 2 => "persistAcrossReboots", _ => null, }; } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs index f09cba967c1..82b5651a25c 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs @@ -14,7 +14,7 @@ static class ComponentElementBuilder static readonly XNamespace AndroidNs = ManifestConstants.AndroidNs; static readonly XName AttName = ManifestConstants.AttName; - internal static XElement? CreateComponentElement (JavaPeerInfo peer, string jniName, int targetSdkVersion = 0) + internal static XElement? CreateComponentElement (JavaPeerInfo peer, string jniName, int targetSdkVersion = 0, IReadOnlyDictionary? managedToManifestNames = null) { var component = peer.ComponentAttribute; if (component is null) { @@ -34,11 +34,24 @@ static class ComponentElementBuilder // Map known properties to android: attributes PropertyMapper.MapComponentProperties (element, component, targetSdkVersion); + // android:parentActivityName comes from a [Activity (ParentActivity = typeof (...))] and is + // captured as the managed type name. Resolve it to the parent's Java/manifest name, matching + // the legacy ManifestDocument behavior (JavaNativeTypeManager.ToJniName). + ResolveParentActivityName (element, managedToManifestNames); + // Add intent filters foreach (var intentFilter in component.IntentFilters) { element.Add (CreateIntentFilterElement (intentFilter)); } + // Add element from a [Layout] attribute, if present + if (component.LayoutProperties is not null) { + var layout = CreateLayoutElement (component.LayoutProperties); + if (layout is not null) { + element.Add (layout); + } + } + // Handle MainLauncher for activities if (component.Kind == ComponentKind.Activity && component.Properties.TryGetValue ("MainLauncher", out var ml) && ml is bool b && b) { AddLauncherIntentFilter (element); @@ -49,9 +62,54 @@ static class ComponentElementBuilder element.Add (CreateMetaDataElement (meta)); } + // The legacy ManifestDocumentElement.ToElement sorts attributes alphabetically + // (specified.OrderBy (e => e)). Match that ordering so the generated manifest is + // byte-compatible with the legacy path when AndroidManifestMerger='legacy' (the + // manifestmerger.jar path re-sorts attributes itself, so this is also safe there). + SortAttributesAlphabetically (element); + return element; } + // Reorders an element's attributes alphabetically by local name (case-insensitive), + // matching the legacy manifest generator's attribute ordering. + static void SortAttributesAlphabetically (XElement element) + { + var sorted = element.Attributes () + .OrderBy (a => a.Name.LocalName, StringComparer.OrdinalIgnoreCase) + .ToList (); + if (sorted.Count < 2) { + return; + } + foreach (var attr in element.Attributes ().ToList ()) { + attr.Remove (); + } + foreach (var attr in sorted) { + element.Add (attr); + } + } + + static void ResolveParentActivityName (XElement element, IReadOnlyDictionary? managedToManifestNames) + { + if (managedToManifestNames is null) { + return; + } + + var attr = element.Attribute (AndroidNs + "parentActivityName"); + if (attr is null) { + return; + } + + // The value may be assembly-qualified ("Foo.Bar, Asm [Version=...]"); use the type name part. + var value = attr.Value; + int comma = value.IndexOf (','); + var typeName = (comma < 0 ? value : value.Substring (0, comma)).Trim (); + + if (managedToManifestNames.TryGetValue (typeName, out var manifestName)) { + attr.Value = manifestName; + } + } + internal static void AddLauncherIntentFilter (XElement activity) { // Check if there's already a launcher intent filter @@ -154,6 +212,30 @@ internal static XElement CreateMetaDataElement (MetaDataInfo meta) return element; } + // Maps [Layout] attribute properties to the element's android: attributes. + static readonly (string Property, string Attribute) [] LayoutMappings = [ + ("DefaultHeight", "defaultHeight"), + ("DefaultWidth", "defaultWidth"), + ("Gravity", "gravity"), + ("MinHeight", "minHeight"), + ("MinWidth", "minWidth"), + ]; + + internal static XElement? CreateLayoutElement (IReadOnlyDictionary layoutProperties) + { + var element = new XElement ("layout"); + bool hasAttribute = false; + + foreach (var (property, attribute) in LayoutMappings) { + if (layoutProperties.TryGetValue (property, out var value) && value is string s && !s.IsNullOrEmpty ()) { + element.SetAttributeValue (AndroidNs + attribute, s); + hasAttribute = true; + } + } + + return hasAttribute ? element : null; + } + internal static void UpdateApplicationElement (XElement app, JavaPeerInfo peer, int targetSdkVersion = 0) { string jniName = JniSignatureHelper.JniNameToJavaName (peer.JavaName); diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs index 2d69bd410fb..a323bd885b4 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using System.Xml.Linq; @@ -41,6 +42,27 @@ class ManifestGenerator public string? ManifestPlaceholders { get; set; } public string? ApplicationJavaClass { get; set; } public Action? Warn { get; set; } + public Action? WarnInvalidPlaceholder { get; set; } + + /// + /// Absolute paths to extracted library (.aar) manifests that must be merged into the + /// application manifest. Only used by the legacy manifest merger; manifestmerger.jar handles + /// this downstream in the _ManifestMerger target. Mirrors the legacy ManifestDocument merge. + /// + public IReadOnlyList LibraryManifests { get; set; } = []; + + static readonly XNamespace ToolsNs = "http://schemas.android.com/tools"; + + // Attributes whose values are component names that must be qualified with the library's + // own package when they are relative (start with '.'). Mirrors ManifestDocument.ManifestAttributeFixups. + static readonly Dictionary ManifestAttributeFixups = new (StringComparer.Ordinal) { + { "activity", ["name"] }, + { "application", ["backupAgent"] }, + { "instrumentation", ["name"] }, + { "provider", ["name"] }, + { "receiver", ["name"] }, + { "service", ["name"] }, + }; /// /// Generates the merged manifest from an optional pre-loaded template and writes it to . @@ -77,6 +99,15 @@ class ManifestGenerator .OfType (), StringComparer.Ordinal); + // Map managed type names to their Java/manifest names so component properties that reference + // other types (e.g. [Activity (ParentActivity = typeof (...))]) can be resolved. + var managedToManifestNames = new Dictionary (allPeers.Count, StringComparer.Ordinal); + foreach (var peer in allPeers) { + if (!string.IsNullOrEmpty (peer.ManagedTypeName)) { + managedToManifestNames [peer.ManagedTypeName] = JniSignatureHelper.JniNameToJavaName (peer.JavaName); + } + } + // Add components from scanned types foreach (var peer in allPeers) { if (peer.IsAbstract || peer.ComponentAttribute is null) { @@ -99,7 +130,7 @@ class ManifestGenerator continue; } - var element = ComponentElementBuilder.CreateComponentElement (peer, jniName, targetSdkVersionValue); + var element = ComponentElementBuilder.CreateComponentElement (peer, jniName, targetSdkVersionValue, managedToManifestNames); if (element is not null) { app.Add (element); } @@ -133,12 +164,114 @@ class ManifestGenerator AssemblyLevelElementBuilder.AddInternetPermission (manifest); } + // Merge extracted library (.aar) manifests (legacy merger only — manifestmerger.jar does + // this downstream). Mirrors ManifestDocument: merge, then dedup, then strip node="remove", + // all before placeholder substitution so ${applicationId} resolves in merged content. + MergeLibraryManifests (manifest); + RemoveDuplicateElements (doc); + RemoveNodes (doc); + // Apply manifest placeholders - ApplyPlaceholders (doc, ManifestPlaceholders); + ApplyPlaceholders (doc, ManifestPlaceholders, PackageName, WarnInvalidPlaceholder); return (doc, providerNames); } + /// + /// Merges each library manifest's top-level elements into the application manifest, mirroring + /// ManifestDocument.MergeLibraryManifest: elements with a matching android:name append their + /// children to the existing element, otherwise the element is added; relative component names + /// are qualified with the library's own package. + /// + void MergeLibraryManifests (XElement manifest) + { + foreach (var path in LibraryManifests) { + if (path.IsNullOrEmpty () || !File.Exists (path)) { + continue; + } + + XDocument libDoc; + try { + libDoc = XDocument.Load (path); + } catch (Exception ex) { + Warn?.Invoke ($"Unable to merge library manifest '{path}': {ex.Message}"); + continue; + } + + if (libDoc.Root is not { } libRoot) { + continue; + } + + var package = (string?) libRoot.Attribute ("package") ?? ""; + foreach (var top in libRoot.Elements ().ToList ()) { + var name = (string?) top.Attribute (AndroidNs + "name"); + XElement? existing = name is not null + ? manifest.Elements (top.Name).FirstOrDefault (e => (string?) e.Attribute (AndroidNs + "name") == name) + : manifest.Elements (top.Name).FirstOrDefault (); + + if (existing is not null) { + // Append the library element's children to the matching element. + existing.Add (FixupNameElements (package, top.Nodes ())); + } else { + manifest.Add (FixupNameElements (package, [top])); + } + } + } + } + + /// + /// Qualifies relative component names (those starting with '.') with the supplied package, + /// mirroring ManifestDocument.FixupNameElements. + /// + static IEnumerable FixupNameElements (string packageName, IEnumerable nodes) + { + var nodeList = nodes.ToList (); + foreach (var element in nodeList.OfType ().Where (x => ManifestAttributeFixups.ContainsKey (x.Name.LocalName))) { + var attributes = ManifestAttributeFixups [element.Name.LocalName]; + foreach (var attr in element.Attributes ().Where (x => attributes.Contains (x.Name.LocalName))) { + if (attr.Value.StartsWith (".", StringComparison.Ordinal)) { + attr.Value = packageName + attr.Value; + } + } + } + return nodeList; + } + + /// + /// Removes structurally-identical duplicate elements, mirroring ManifestDocument.RemoveDuplicateElements. + /// + static void RemoveDuplicateElements (XDocument doc) + { + foreach (var duplicate in ResolveDuplicates (doc.Elements ()).ToList ()) { + duplicate.Remove (); + } + } + + static IEnumerable ResolveDuplicates (IEnumerable elements) + { + var elementList = elements.ToList (); + foreach (var e in elementList) { + foreach (var d in ResolveDuplicates (e.Elements ())) { + yield return d; + } + } + foreach (var d in elementList.GroupBy (x => x.ToString (SaveOptions.DisableFormatting)).SelectMany (x => x.Skip (1))) { + yield return d; + } + } + + /// + /// Removes elements marked with tools:node="remove", mirroring ManifestDocument.RemoveNodes. + /// + static void RemoveNodes (XDocument doc) + { + foreach (var node in doc.Descendants ().ToList ()) { + if (node.Attribute (ToolsNs + "node")?.Value == "remove") { + node.Remove (); + } + } + } + XDocument CreateDefaultManifest () { return new XDocument ( @@ -204,7 +337,13 @@ void EnsureManifestAttributes (XElement manifest) { manifest.SetAttributeValue (XNamespace.Xmlns + "android", AndroidNs.NamespaceName); - if (string.IsNullOrEmpty ((string?)manifest.Attribute ("package"))) { + // Resolve the package: when the template uses a placeholder token such as "${PACKAGENAME}" + // (or has no package), write the resolved value provided by MSBuild ($(_AndroidPackage), + // produced by GetAndroidPackageName, which substitutes placeholders and canonicalizes the + // package). This matches the legacy GenerateMainAndroidManifest; a valid explicit package is + // preserved so compat-name resolution keeps using it. + var packageAttr = (string?) manifest.Attribute ("package") ?? ""; + if ((packageAttr.Length == 0 || packageAttr.Contains ("${")) && !PackageName.IsNullOrEmpty ()) { manifest.SetAttributeValue ("package", PackageName); } @@ -368,22 +507,34 @@ XElement CreateRuntimeProvider (string name, string? processName, int initOrder, /// Replaces ${key} placeholders in all attribute values throughout the document. /// Placeholder format: "key1=value1;key2=value2" /// - internal static void ApplyPlaceholders (XDocument doc, string? placeholders) + internal static void ApplyPlaceholders (XDocument doc, string? placeholders, string? packageName = null, Action? warnInvalidPlaceholder = null) { - if (placeholders.IsNullOrEmpty ()) { - return; - } - var replacements = new Dictionary (StringComparer.Ordinal); - foreach (var entry in placeholders.Split (PlaceholderSeparators, StringSplitOptions.RemoveEmptyEntries)) { - var eqIndex = entry.IndexOf ('='); - if (eqIndex > 0) { - var key = entry.Substring (0, eqIndex).Trim (); - var value = entry.Substring (eqIndex + 1).Trim (); - replacements ["${" + key + "}"] = value; + if (!placeholders.IsNullOrEmpty ()) { + foreach (var entry in placeholders.Split (PlaceholderSeparators, StringSplitOptions.RemoveEmptyEntries)) { + var eqIndex = entry.IndexOf ('='); + if (eqIndex > 0) { + var key = entry.Substring (0, eqIndex).Trim (); + var value = entry.Substring (eqIndex + 1).Trim (); + replacements ["${" + key + "}"] = value; + } else if (eqIndex < 0) { + // An entry without '=' is not a valid key=value pair. Mirror the legacy + // ManifestDocument.ReplacePlaceholders behavior and warn (XA1010). + warnInvalidPlaceholder?.Invoke (placeholders); + } } } + // ${applicationId} is a built-in placeholder that always resolves to the application + // package name (mirrors ManifestDocument.Save, which does + // s.Replace ("${applicationId}", PackageName) before applying the user placeholders). + // It is set last so it wins over any user-supplied "applicationId" entry, matching the + // legacy ordering, and is what substitutes merged library-manifest values such as + // "${applicationId}.permission.C2D_MESSAGE". + if (!packageName.IsNullOrEmpty ()) { + replacements ["${applicationId}"] = packageName; + } + if (replacements.Count == 0) { return; } diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PropertyMapper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PropertyMapper.cs index 232de27bd05..211997ceb6d 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PropertyMapper.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PropertyMapper.cs @@ -13,7 +13,7 @@ static class PropertyMapper { static readonly XNamespace AndroidNs = ManifestConstants.AndroidNs; - internal enum MappingKind { String, Bool, Enum } + internal enum MappingKind { String, Bool, Enum, Number } internal readonly struct PropertyMapping { @@ -52,8 +52,17 @@ public PropertyMapping (string propertyName, string xmlAttributeName, MappingKin new ("Theme", "theme"), new ("ParentActivity", "parentActivityName"), new ("TaskAffinity", "taskAffinity"), + new ("Banner", "banner"), + new ("ColorMode", "colorMode"), + new ("EnableVrMode", "enableVrMode"), + new ("LockTaskMode", "lockTaskMode"), + new ("Logo", "logo"), + new ("MaxAspectRatio", "maxAspectRatio", MappingKind.Number), + new ("MaxRecents", "maxRecents", MappingKind.Number), + new ("AllowEmbedded", "allowEmbedded", MappingKind.Bool), new ("AllowTaskReparenting", "allowTaskReparenting", MappingKind.Bool), new ("AlwaysRetainTaskState", "alwaysRetainTaskState", MappingKind.Bool), + new ("AutoRemoveFromRecents", "autoRemoveFromRecents", MappingKind.Bool), new ("ClearTaskOnLaunch", "clearTaskOnLaunch", MappingKind.Bool), new ("ExcludeFromRecents", "excludeFromRecents", MappingKind.Bool), new ("FinishOnCloseSystemDialogs", "finishOnCloseSystemDialogs", MappingKind.Bool), @@ -61,19 +70,27 @@ public PropertyMapping (string propertyName, string xmlAttributeName, MappingKin new ("HardwareAccelerated", "hardwareAccelerated", MappingKind.Bool), new ("NoHistory", "noHistory", MappingKind.Bool), new ("MultiProcess", "multiprocess", MappingKind.Bool), + new ("RelinquishTaskIdentity", "relinquishTaskIdentity", MappingKind.Bool), + new ("ResizeableActivity", "resizeableActivity", MappingKind.Bool), + new ("ResumeWhilePausing", "resumeWhilePausing", MappingKind.Bool), + new ("ShowForAllUsers", "showForAllUsers", MappingKind.Bool), + new ("ShowOnLockScreen", "showOnLockScreen", MappingKind.Bool), + new ("ShowWhenLocked", "showWhenLocked", MappingKind.Bool), + new ("SingleUser", "singleUser", MappingKind.Bool), new ("StateNotNeeded", "stateNotNeeded", MappingKind.Bool), new ("Immersive", "immersive", MappingKind.Bool), - new ("ResizeableActivity", "resizeableActivity", MappingKind.Bool), new ("SupportsPictureInPicture", "supportsPictureInPicture", MappingKind.Bool), - new ("ShowForAllUsers", "showForAllUsers", MappingKind.Bool), new ("TurnScreenOn", "turnScreenOn", MappingKind.Bool), + new ("VisibleToInstantApps", "visibleToInstantApps", MappingKind.Bool), new ("LaunchMode", "launchMode", MappingKind.Enum, AndroidEnumConverter.LaunchModeToString), new ("ScreenOrientation", "screenOrientation", MappingKind.Enum, AndroidEnumConverter.ScreenOrientationToString), new ("ConfigurationChanges", "configChanges", MappingKind.Enum, AndroidEnumConverter.ConfigChangesToString), + new ("RecreateOnConfigChanges", "recreateOnConfigChanges", MappingKind.Enum, AndroidEnumConverter.ConfigChangesToString), new ("WindowSoftInputMode", "windowSoftInputMode", MappingKind.Enum, AndroidEnumConverter.SoftInputToString), new ("DocumentLaunchMode", "documentLaunchMode", MappingKind.Enum, AndroidEnumConverter.DocumentLaunchModeToString), new ("UiOptions", "uiOptions", MappingKind.Enum, AndroidEnumConverter.UiOptionsToString), new ("PersistableMode", "persistableMode", MappingKind.Enum, AndroidEnumConverter.ActivityPersistableModeToString), + new ("RotationAnimation", "rotationAnimation", MappingKind.Enum, AndroidEnumConverter.RotationAnimationToString), ]; internal static readonly PropertyMapping[] ServiceMappings = [ @@ -148,6 +165,9 @@ internal static void ApplyMappings (XElement element, IReadOnlyDictionary i, long l => (int)l, short s => s, byte b => b, _ => 0 }; var strValue = m.EnumConverter (intValue, targetSdkVersion); @@ -159,6 +179,14 @@ internal static void ApplyMappings (XElement element, IReadOnlyDictionary value switch { + float f => f.ToString (CultureInfo.InvariantCulture), + double d => d.ToString (CultureInfo.InvariantCulture), + int i => i.ToString (CultureInfo.InvariantCulture), + long l => l.ToString (CultureInfo.InvariantCulture), + _ => value?.ToString () ?? "", + }; + internal static void MapComponentProperties (XElement element, ComponentInfo component, int targetSdkVersion = 0) { ApplyMappings (element, component.Properties, CommonMappings, targetSdkVersion: targetSdkVersion); diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/ComponentInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/ComponentInfo.cs index 1995de0ecff..7ad4e5bf238 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/ComponentInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/ComponentInfo.cs @@ -18,4 +18,5 @@ public sealed record ComponentInfo public IReadOnlyDictionary Properties { get; init; } = new Dictionary (); public IReadOnlyList IntentFilters { get; init; } = []; public IReadOnlyList MetaData { get; init; } = []; + public IReadOnlyDictionary? LayoutProperties { get; init; } } diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs index eb0f30b1e3c..d1442c2c1b7 100644 --- a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.IO; using System.Linq; using System.Xml.Linq; using Microsoft.Android.Sdk.TrimmableTypeMap; @@ -66,6 +67,78 @@ static List GetDataAttributes (XElement? filter) .ToList (); } + [Fact] + public void Placeholders_InvalidEntryWithoutValue_WarnsXA1010 () + { + var gen = CreateDefaultGenerator (); + var warnings = new List (); + gen.WarnInvalidPlaceholder = warnings.Add; + gen.ManifestPlaceholders = "ph2=a=b;ph1"; + var template = ParseTemplate (""" + + + + + + """); + GenerateAndLoad (gen, template: template); + Assert.Single (warnings); + Assert.Contains ("ph1", warnings [0]); + } + + [Fact] + public void Placeholders_AllValid_DoesNotWarn () + { + var gen = CreateDefaultGenerator (); + var warnings = new List (); + gen.WarnInvalidPlaceholder = warnings.Add; + gen.ManifestPlaceholders = "ph1=val1;ph2=val2"; + var template = ParseTemplate (""" + + + + + + """); + var doc = GenerateAndLoad (gen, template: template); + Assert.Empty (warnings); + Assert.Equal ("val1", (string?) doc.Root?.Element ("application")?.Attribute (AndroidNs + "label")); + } + + [Fact] + public void Package_PlaceholderToken_ReplacedWithResolvedPackageName () + { + // A template package that is a placeholder token (e.g. "${PACKAGENAME}") must be replaced + // with the resolved $(_AndroidPackage); otherwise manifest validation fails (AllResourcesInClassLibrary). + var gen = CreateDefaultGenerator (); + gen.PackageName = "x__PACKAGENAME_.x__PACKAGENAME_"; + var template = ParseTemplate (""" + + + + + + """); + var doc = GenerateAndLoad (gen, template: template); + Assert.Equal ("x__PACKAGENAME_.x__PACKAGENAME_", (string?) doc.Root?.Attribute ("package")); + } + + [Fact] + public void Package_ValidExplicitPackage_Preserved () + { + var gen = CreateDefaultGenerator (); + gen.PackageName = "com.other.app"; + var template = ParseTemplate (""" + + + + + + """); + var doc = GenerateAndLoad (gen, template: template); + Assert.Equal ("com.example.app", (string?) doc.Root?.Attribute ("package")); + } + [Fact] public void Activity_MainLauncher () { @@ -115,6 +188,36 @@ public void Activity_WithProperties () Assert.Equal ("sensorPortrait", (string?)activity?.Attribute (AndroidNs + "screenOrientation")); } + [Fact] + public void Activity_AttributesAreSortedAlphabetically () + { + // The legacy ManifestDocumentElement sorts attributes alphabetically; the trimmable + // generator must match so the 'legacy' AndroidManifestMerger path is byte-compatible. + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MyActivity", new ComponentInfo { + Kind = ComponentKind.Activity, + Properties = new Dictionary { + ["Theme"] = "@style/MyTheme", + ["Label"] = "My Activity", + ["Exported"] = true, + ["Enabled"] = true, + ["Icon"] = "@drawable/icon", + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var activity = doc.Root?.Element ("application")?.Element ("activity"); + Assert.NotNull (activity); + var localNames = activity.Attributes ().Select (a => a.Name.LocalName).ToList (); + var expected = localNames.OrderBy (n => n, System.StringComparer.OrdinalIgnoreCase).ToList (); + Assert.Equal (expected, localNames); + // android:name must appear in its alphabetical position (not forced first). + Assert.Contains ("enabled", localNames); + Assert.True (localNames.IndexOf ("enabled") < localNames.IndexOf ("exported")); + Assert.True (localNames.IndexOf ("label") < localNames.IndexOf ("name")); + Assert.True (localNames.IndexOf ("name") < localNames.IndexOf ("theme")); + } + [Fact] public void Activity_IntentFilter () { @@ -198,6 +301,35 @@ public void Activity_IntentFilterPluralDataProperties () ], GetDataAttributes (filter)); } + [Fact] + public void Activity_IntentFilterSingularDataPropertiesEachGetOwnElement () + { + // Mirrors the IntentFilterData device test: each singular Data* property must + // produce its own element (matching legacy IntentFilterAttribute.GetData ()). + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/ShareActivity", new ComponentInfo { + Kind = ComponentKind.Activity, + IntentFilters = [ + new IntentFilterInfo { + Actions = ["action1"], + Properties = new Dictionary { + ["DataPath"] = "foo", + ["DataPathPattern"] = "foo*", + ["DataPathPrefix"] = "foo", + ["Label"] = "testTarget", + }, + }, + ], + }); + + var doc = GenerateAndLoad (gen, [peer]); + var filter = doc.Root?.Element ("application")?.Element ("activity")?.Element ("intent-filter"); + + Assert.NotNull (filter); + Assert.Equal ("testTarget", (string?)filter?.Attribute (AndroidNs + "label")); + Assert.Equal (3, filter?.Elements ("data").Count ()); + } + [Fact] public void Activity_MetaData () { @@ -225,13 +357,124 @@ public void Activity_MetaData () Assert.Equal ("@xml/config", (string?)meta2?.Attribute (AndroidNs + "resource")); } + [Fact] + public void Activity_LayoutAttributeElement () + { + // Mirrors the LayoutAttributeElement device test: a [Layout] attribute on an activity + // must produce a child element with the mapped android: attributes. + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MainActivity", new ComponentInfo { + Kind = ComponentKind.Activity, + LayoutProperties = new Dictionary { + ["DefaultWidth"] = "500dp", + ["DefaultHeight"] = "600dp", + ["Gravity"] = "center", + ["MinWidth"] = "300dp", + ["MinHeight"] = "400dp", + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var layout = doc.Root?.Element ("application")?.Element ("activity")?.Element ("layout"); + + Assert.NotNull (layout); + Assert.Equal ("500dp", (string?)layout?.Attribute (AndroidNs + "defaultWidth")); + Assert.Equal ("600dp", (string?)layout?.Attribute (AndroidNs + "defaultHeight")); + Assert.Equal ("center", (string?)layout?.Attribute (AndroidNs + "gravity")); + Assert.Equal ("300dp", (string?)layout?.Attribute (AndroidNs + "minWidth")); + Assert.Equal ("400dp", (string?)layout?.Attribute (AndroidNs + "minHeight")); + } + + [Fact] + public void Activity_AllExtendedProperties () + { + // Covers the activity attributes added for AllActivityAttributeProperties: each must be + // emitted on the element (the manifestmerger.jar then sorts them alphabetically). + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/TestActivity", new ComponentInfo { + Kind = ComponentKind.Activity, + Properties = new Dictionary { + ["AllowEmbedded"] = true, + ["AutoRemoveFromRecents"] = true, + ["Banner"] = "@drawable/icon", + ["ColorMode"] = "hdr", + ["EnableVrMode"] = "foo", + ["LockTaskMode"] = "normal", + ["Logo"] = "@drawable/icon", + ["MaxAspectRatio"] = 1.2f, + ["MaxRecents"] = 1, + ["RecreateOnConfigChanges"] = 0x0001, // ConfigChanges.Mcc + ["RelinquishTaskIdentity"] = true, + ["ResumeWhilePausing"] = true, + ["RotationAnimation"] = 1, // WindowRotationAnimation.Crossfade + ["ShowOnLockScreen"] = true, + ["ShowWhenLocked"] = true, + ["SingleUser"] = true, + ["VisibleToInstantApps"] = true, + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var activity = doc.Root?.Element ("application")?.Element ("activity"); + Assert.NotNull (activity); + + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "allowEmbedded")); + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "autoRemoveFromRecents")); + Assert.Equal ("@drawable/icon", (string?)activity?.Attribute (AndroidNs + "banner")); + Assert.Equal ("hdr", (string?)activity?.Attribute (AndroidNs + "colorMode")); + Assert.Equal ("foo", (string?)activity?.Attribute (AndroidNs + "enableVrMode")); + Assert.Equal ("normal", (string?)activity?.Attribute (AndroidNs + "lockTaskMode")); + Assert.Equal ("@drawable/icon", (string?)activity?.Attribute (AndroidNs + "logo")); + Assert.Equal ("1.2", (string?)activity?.Attribute (AndroidNs + "maxAspectRatio")); + Assert.Equal ("1", (string?)activity?.Attribute (AndroidNs + "maxRecents")); + Assert.Equal ("mcc", (string?)activity?.Attribute (AndroidNs + "recreateOnConfigChanges")); + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "relinquishTaskIdentity")); + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "resumeWhilePausing")); + Assert.Equal ("crossfade", (string?)activity?.Attribute (AndroidNs + "rotationAnimation")); + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "showOnLockScreen")); + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "showWhenLocked")); + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "singleUser")); + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "visibleToInstantApps")); + } + + [Fact] + public void Activity_ParentActivityResolvesToManifestName () + { + // [Activity (ParentActivity = typeof (Parent))] captures the managed type name; the generator + // must resolve it to the parent's Java/manifest name (here the JCW package differs from the + // managed namespace). + var gen = CreateDefaultGenerator (); + var parent = new JavaPeerInfo { + JavaName = "com/foo/bar/MainActivity", + CompatJniName = "com/foo/bar/MainActivity", + ManagedTypeName = "UnnamedProject.MainActivity", + ManagedTypeNamespace = "UnnamedProject", + ManagedTypeShortName = "MainActivity", + AssemblyName = "TestApp", + ComponentAttribute = new ComponentInfo { Kind = ComponentKind.Activity }, + }; + var child = CreatePeer ("com/example/app/ChildActivity", new ComponentInfo { + Kind = ComponentKind.Activity, + Properties = new Dictionary { + ["ParentActivity"] = "UnnamedProject.MainActivity", + }, + }); + + var doc = GenerateAndLoad (gen, [parent, child]); + var childEl = doc.Root?.Element ("application")?.Elements ("activity") + .FirstOrDefault (a => (string?)a.Attribute (AttName) == "com.example.app.ChildActivity"); + + Assert.NotNull (childEl); + Assert.Equal ("com.foo.bar.MainActivity", (string?)childEl?.Attribute (AndroidNs + "parentActivityName")); + } + [Theory] [InlineData (ComponentKind.Service, "service")] [InlineData (ComponentKind.BroadcastReceiver, "receiver")] public void Component_BasicProperties (ComponentKind kind, string elementName) { var gen = CreateDefaultGenerator (); - var peer = CreatePeer ("com/example/app/MyComponent", new ComponentInfo { + var peer = CreatePeer ("com/example/app/MyComponent", new ComponentInfo { Kind = kind, Properties = new Dictionary { ["Exported"] = true, @@ -818,6 +1061,71 @@ public void ManifestPlaceholders_Replaced () Assert.Equal ("12345", (string?)meta?.Attribute (AndroidNs + "value")); } + [Fact] + public void ApplicationIdPlaceholder_ReplacedWithPackageName () + { + // ${applicationId} is a built-in placeholder (not a user key=value entry) that resolves + // to the application package name. It appears in merged library-manifest content such as + // a name or a authority (see MergeLibraryManifest). + var gen = CreateDefaultGenerator (); + + var template = ParseTemplate ( + """ + + + + + + + + """); + + var doc = GenerateAndLoad (gen, template: template); + + var permission = doc.Root?.Elements ("permission").FirstOrDefault (); + Assert.Equal ("com.example.app.permission.C2D_MESSAGE", (string?)permission?.Attribute (AndroidNs + "name")); + + var provider = doc.Root?.Element ("application")?.Elements ("provider") + .FirstOrDefault (p => (string?)p.Attribute (AndroidNs + "name") == "com.example.FacebookInitProvider"); + Assert.Equal ("com.example.app.FacebookInitProvider", (string?)provider?.Attribute (AndroidNs + "authorities")); + } + + [Fact] + public void LibraryManifest_MergedAndRelativeNamesQualified () + { + // Mirrors MergeLibraryManifest: a library (.aar) manifest's top-level elements are merged + // into the app manifest, relative component names ('.Foo') are qualified with the library's + // own package, and ${applicationId} resolves to the application package. + var gen = CreateDefaultGenerator (); + var libManifest = Path.GetTempFileName (); + try { + File.WriteAllText (libManifest, + """ + + + + + + + + """); + gen.LibraryManifests = [libManifest]; + + var doc = GenerateAndLoad (gen); + + var permission = doc.Root?.Elements ("permission").FirstOrDefault (); + Assert.Equal ("com.example.app.permission.C2D_MESSAGE", (string?)permission?.Attribute (AndroidNs + "name")); + + var provider = doc.Root?.Element ("application")?.Elements ("provider") + .FirstOrDefault (p => (string?)p.Attribute (AndroidNs + "authorities") == "com.example.app.LibProvider"); + Assert.NotNull (provider); + // Relative name qualified with the library's own package, not the app package. + Assert.Equal ("com.lib.test.internal.LibProvider", (string?)provider.Attribute (AndroidNs + "name")); + } finally { + File.Delete (libManifest); + } + } + [Fact] public void ApplicationJavaClass_Set () {