diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AndroidEnumConverter.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AndroidEnumConverter.cs new file mode 100644 index 00000000000..b25a1407a4f --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AndroidEnumConverter.cs @@ -0,0 +1,130 @@ +#nullable enable + +using System.Collections.Generic; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +/// +/// Converts Android enum integer values to their XML attribute string representations. +/// Ported from ManifestDocumentElement.cs. +/// +static class AndroidEnumConverter +{ + public static string? LaunchModeToString (int value) => value switch { + 1 => "singleTop", + 2 => "singleTask", + 3 => "singleInstance", + 4 => "singleInstancePerTask", + _ => null, + }; + + public static string? ScreenOrientationToString (int value) => value switch { + 0 => "landscape", + 1 => "portrait", + 3 => "sensor", + 4 => "nosensor", + 5 => "user", + 6 => "behind", + 7 => "reverseLandscape", + 8 => "reversePortrait", + 9 => "sensorLandscape", + 10 => "sensorPortrait", + 11 => "fullSensor", + 12 => "userLandscape", + 13 => "userPortrait", + 14 => "fullUser", + 15 => "locked", + -1 => "unspecified", + _ => null, + }; + + public static string? ConfigChangesToString (int value) + { + var parts = new List (); + if ((value & 0x0001) != 0) parts.Add ("mcc"); + if ((value & 0x0002) != 0) parts.Add ("mnc"); + if ((value & 0x0004) != 0) parts.Add ("locale"); + if ((value & 0x0008) != 0) parts.Add ("touchscreen"); + if ((value & 0x0010) != 0) parts.Add ("keyboard"); + if ((value & 0x0020) != 0) parts.Add ("keyboardHidden"); + if ((value & 0x0040) != 0) parts.Add ("navigation"); + if ((value & 0x0080) != 0) parts.Add ("orientation"); + if ((value & 0x0100) != 0) parts.Add ("screenLayout"); + if ((value & 0x0200) != 0) parts.Add ("uiMode"); + if ((value & 0x0400) != 0) parts.Add ("screenSize"); + if ((value & 0x0800) != 0) parts.Add ("smallestScreenSize"); + if ((value & 0x1000) != 0) parts.Add ("density"); + if ((value & 0x2000) != 0) parts.Add ("layoutDirection"); + if ((value & 0x4000) != 0) parts.Add ("colorMode"); + if ((value & 0x8000) != 0) parts.Add ("grammaticalGender"); + if ((value & 0x10000000) != 0) parts.Add ("fontWeightAdjustment"); + if ((value & 0x40000000) != 0) parts.Add ("fontScale"); + return parts.Count > 0 ? string.Join ("|", parts) : null; + } + + public static string? SoftInputToString (int value) + { + var parts = new List (); + int state = value & 0x0f; + int adjust = value & 0xf0; + if (state == 1) parts.Add ("stateUnchanged"); + else if (state == 2) parts.Add ("stateHidden"); + else if (state == 3) parts.Add ("stateAlwaysHidden"); + else if (state == 4) parts.Add ("stateVisible"); + else if (state == 5) parts.Add ("stateAlwaysVisible"); + if (adjust == 0x10) parts.Add ("adjustResize"); + else if (adjust == 0x20) parts.Add ("adjustPan"); + else if (adjust == 0x30) parts.Add ("adjustNothing"); + return parts.Count > 0 ? string.Join ("|", parts) : null; + } + + public static string? DocumentLaunchModeToString (int value) => value switch { + 1 => "intoExisting", + 2 => "always", + 3 => "never", + _ => null, + }; + + public static string? UiOptionsToString (int value) => value switch { + 1 => "splitActionBarWhenNarrow", + _ => null, + }; + + public static string? ForegroundServiceTypeToString (int value) + { + var parts = new List (); + if ((value & 0x00000001) != 0) parts.Add ("dataSync"); + if ((value & 0x00000002) != 0) parts.Add ("mediaPlayback"); + if ((value & 0x00000004) != 0) parts.Add ("phoneCall"); + if ((value & 0x00000008) != 0) parts.Add ("location"); + if ((value & 0x00000010) != 0) parts.Add ("connectedDevice"); + if ((value & 0x00000020) != 0) parts.Add ("mediaProjection"); + if ((value & 0x00000040) != 0) parts.Add ("camera"); + if ((value & 0x00000080) != 0) parts.Add ("microphone"); + if ((value & 0x00000100) != 0) parts.Add ("health"); + if ((value & 0x00000200) != 0) parts.Add ("remoteMessaging"); + if ((value & 0x00000400) != 0) parts.Add ("systemExempted"); + if ((value & 0x00000800) != 0) parts.Add ("shortService"); + if ((value & 0x40000000) != 0) parts.Add ("specialUse"); + return parts.Count > 0 ? string.Join ("|", parts) : null; + } + + public static string? ProtectionToString (int value) + { + int baseValue = value & 0x0f; + return baseValue switch { + 0 => "normal", + 1 => "dangerous", + 2 => "signature", + 3 => "signatureOrSystem", + _ => null, + }; + } + + public static string? ActivityPersistableModeToString (int value) => value switch { + 0 => "persistRootOnly", + 1 => "persistAcrossReboots", + 2 => "persistNever", + _ => null, + }; +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AssemblyLevelElementBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AssemblyLevelElementBuilder.cs new file mode 100644 index 00000000000..8ba00724f87 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/AssemblyLevelElementBuilder.cs @@ -0,0 +1,171 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +/// +/// Adds assembly-level manifest elements (permissions, uses-permissions, uses-features, +/// uses-library, uses-configuration, meta-data, property). +/// +static class AssemblyLevelElementBuilder +{ + static readonly XNamespace AndroidNs = ManifestConstants.AndroidNs; + static readonly XName AttName = ManifestConstants.AttName; + + internal static void AddAssemblyLevelElements (XElement manifest, XElement app, AssemblyManifestInfo info) + { + var existingPermissions = new HashSet ( + manifest.Elements ("permission").Select (e => (string?)e.Attribute (AttName)).OfType ()); + var existingUsesPermissions = new HashSet ( + manifest.Elements ("uses-permission").Select (e => (string?)e.Attribute (AttName)).OfType ()); + + // elements + foreach (var perm in info.Permissions) { + if (string.IsNullOrEmpty (perm.Name) || existingPermissions.Contains (perm.Name)) { + continue; + } + var element = new XElement ("permission", new XAttribute (AttName, perm.Name)); + PropertyMapper.MapDictionaryProperties (element, perm.Properties, "Label", "label"); + PropertyMapper.MapDictionaryProperties (element, perm.Properties, "Description", "description"); + PropertyMapper.MapDictionaryProperties (element, perm.Properties, "Icon", "icon"); + PropertyMapper.MapDictionaryProperties (element, perm.Properties, "PermissionGroup", "permissionGroup"); + PropertyMapper.MapDictionaryEnumProperty (element, perm.Properties, "ProtectionLevel", "protectionLevel", AndroidEnumConverter.ProtectionToString); + manifest.Add (element); + } + + // elements + foreach (var pg in info.PermissionGroups) { + if (string.IsNullOrEmpty (pg.Name)) { + continue; + } + var element = new XElement ("permission-group", new XAttribute (AttName, pg.Name)); + PropertyMapper.MapDictionaryProperties (element, pg.Properties, "Label", "label"); + PropertyMapper.MapDictionaryProperties (element, pg.Properties, "Description", "description"); + PropertyMapper.MapDictionaryProperties (element, pg.Properties, "Icon", "icon"); + manifest.Add (element); + } + + // elements + foreach (var pt in info.PermissionTrees) { + if (string.IsNullOrEmpty (pt.Name)) { + continue; + } + var element = new XElement ("permission-tree", new XAttribute (AttName, pt.Name)); + PropertyMapper.MapDictionaryProperties (element, pt.Properties, "Label", "label"); + PropertyMapper.MapDictionaryProperties (element, pt.Properties, "Icon", "icon"); + manifest.Add (element); + } + + // elements + foreach (var up in info.UsesPermissions) { + if (string.IsNullOrEmpty (up.Name) || existingUsesPermissions.Contains (up.Name)) { + continue; + } + var element = new XElement ("uses-permission", new XAttribute (AttName, up.Name)); + if (up.MaxSdkVersion.HasValue) { + element.SetAttributeValue (AndroidNs + "maxSdkVersion", up.MaxSdkVersion.Value.ToString (CultureInfo.InvariantCulture)); + } + manifest.Add (element); + } + + // elements + var existingFeatures = new HashSet ( + manifest.Elements ("uses-feature").Select (e => (string?)e.Attribute (AttName)).OfType ()); + foreach (var uf in info.UsesFeatures) { + if (uf.Name is not null && !existingFeatures.Contains (uf.Name)) { + var element = new XElement ("uses-feature", + new XAttribute (AttName, uf.Name), + new XAttribute (AndroidNs + "required", uf.Required ? "true" : "false")); + manifest.Add (element); + } else if (uf.GLESVersion != 0) { + var versionStr = $"0x{uf.GLESVersion:X8}"; + if (!manifest.Elements ("uses-feature").Any (e => (string?)e.Attribute (AndroidNs + "glEsVersion") == versionStr)) { + var element = new XElement ("uses-feature", + new XAttribute (AndroidNs + "glEsVersion", versionStr), + new XAttribute (AndroidNs + "required", uf.Required ? "true" : "false")); + manifest.Add (element); + } + } + } + + // elements inside + foreach (var ul in info.UsesLibraries) { + if (string.IsNullOrEmpty (ul.Name)) { + continue; + } + if (!app.Elements ("uses-library").Any (e => (string?)e.Attribute (AttName) == ul.Name)) { + app.Add (new XElement ("uses-library", + new XAttribute (AttName, ul.Name), + new XAttribute (AndroidNs + "required", ul.Required ? "true" : "false"))); + } + } + + // Assembly-level inside + foreach (var md in info.MetaData) { + if (string.IsNullOrEmpty (md.Name)) { + continue; + } + if (!app.Elements ("meta-data").Any (e => (string?)e.Attribute (AndroidNs + "name") == md.Name)) { + app.Add (ComponentElementBuilder.CreateMetaDataElement (md)); + } + } + + // Assembly-level inside + foreach (var prop in info.Properties) { + if (string.IsNullOrEmpty (prop.Name)) { + continue; + } + if (!app.Elements ("property").Any (e => (string?)e.Attribute (AndroidNs + "name") == prop.Name)) { + var element = new XElement ("property", + new XAttribute (AndroidNs + "name", prop.Name)); + if (prop.Value is not null) { + element.SetAttributeValue (AndroidNs + "value", prop.Value); + } + if (prop.Resource is not null) { + element.SetAttributeValue (AndroidNs + "resource", prop.Resource); + } + app.Add (element); + } + } + + // elements + foreach (var uc in info.UsesConfigurations) { + var element = new XElement ("uses-configuration"); + if (uc.ReqFiveWayNav) { + element.SetAttributeValue (AndroidNs + "reqFiveWayNav", "true"); + } + if (uc.ReqHardKeyboard) { + element.SetAttributeValue (AndroidNs + "reqHardKeyboard", "true"); + } + if (uc.ReqKeyboardType is not null) { + element.SetAttributeValue (AndroidNs + "reqKeyboardType", uc.ReqKeyboardType); + } + if (uc.ReqNavigation is not null) { + element.SetAttributeValue (AndroidNs + "reqNavigation", uc.ReqNavigation); + } + if (uc.ReqTouchScreen is not null) { + element.SetAttributeValue (AndroidNs + "reqTouchScreen", uc.ReqTouchScreen); + } + manifest.Add (element); + } + } + + internal static void ApplyApplicationProperties (XElement app, Dictionary properties) + { + PropertyMapper.ApplyMappings (app, properties, PropertyMapper.ApplicationPropertyMappings, skipExisting: true); + } + + internal static void AddInternetPermission (XElement manifest) + { + if (!manifest.Elements ("uses-permission").Any (p => + (string?)p.Attribute (AndroidNs + "name") == "android.permission.INTERNET")) { + manifest.Add (new XElement ("uses-permission", + new XAttribute (AndroidNs + "name", "android.permission.INTERNET"))); + } + } +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs new file mode 100644 index 00000000000..9f6f8e7f516 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ComponentElementBuilder.cs @@ -0,0 +1,184 @@ +#nullable enable + +using System; +using System.Globalization; +using System.Linq; +using System.Xml.Linq; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +/// +/// Builds XML elements for individual Android components (Activity, Service, BroadcastReceiver, ContentProvider). +/// +static class ComponentElementBuilder +{ + static readonly XNamespace AndroidNs = ManifestConstants.AndroidNs; + static readonly XName AttName = ManifestConstants.AttName; + + internal static XElement? CreateComponentElement (JavaPeerInfo peer, string jniName) + { + var component = peer.ComponentAttribute; + if (component is null) { + return null; + } + + string elementName = component.Kind switch { + ComponentKind.Activity => "activity", + ComponentKind.Service => "service", + ComponentKind.BroadcastReceiver => "receiver", + ComponentKind.ContentProvider => "provider", + _ => throw new NotSupportedException ($"Unsupported component kind: {component.Kind}"), + }; + + var element = new XElement (elementName, new XAttribute (AttName, jniName)); + + // Map known properties to android: attributes + PropertyMapper.MapComponentProperties (element, component); + + // Add intent filters + foreach (var intentFilter in component.IntentFilters) { + element.Add (CreateIntentFilterElement (intentFilter)); + } + + // Handle MainLauncher for activities + if (component.Kind == ComponentKind.Activity && component.Properties.TryGetValue ("MainLauncher", out var ml) && ml is bool b && b) { + AddLauncherIntentFilter (element); + } + + // Add metadata + foreach (var meta in component.MetaData) { + element.Add (CreateMetaDataElement (meta)); + } + + return element; + } + + internal static void AddLauncherIntentFilter (XElement activity) + { + // Check if there's already a launcher intent filter + if (activity.Elements ("intent-filter").Any (f => + f.Elements ("action").Any (a => (string?)a.Attribute (AttName) == "android.intent.action.MAIN") && + f.Elements ("category").Any (c => (string?)c.Attribute (AttName) == "android.intent.category.LAUNCHER"))) { + return; + } + + // Add android:exported="true" if not already present + if (activity.Attribute (AndroidNs + "exported") is null) { + activity.Add (new XAttribute (AndroidNs + "exported", "true")); + } + + var filter = new XElement ("intent-filter", + new XElement ("action", new XAttribute (AttName, "android.intent.action.MAIN")), + new XElement ("category", new XAttribute (AttName, "android.intent.category.LAUNCHER"))); + activity.AddFirst (filter); + } + + internal static XElement CreateIntentFilterElement (IntentFilterInfo intentFilter) + { + var filter = new XElement ("intent-filter"); + + foreach (var action in intentFilter.Actions) { + filter.Add (new XElement ("action", new XAttribute (AttName, action))); + } + + foreach (var category in intentFilter.Categories) { + filter.Add (new XElement ("category", new XAttribute (AttName, category))); + } + + // Map IntentFilter properties to XML attributes + if (intentFilter.Properties.TryGetValue ("Label", out var label) && label is string labelStr) { + filter.SetAttributeValue (AndroidNs + "label", labelStr); + } + if (intentFilter.Properties.TryGetValue ("Icon", out var icon) && icon is string iconStr) { + filter.SetAttributeValue (AndroidNs + "icon", iconStr); + } + if (intentFilter.Properties.TryGetValue ("Priority", out var priority) && priority is int priorityInt) { + filter.SetAttributeValue (AndroidNs + "priority", priorityInt.ToString (CultureInfo.InvariantCulture)); + } + + // Data elements + AddIntentFilterDataElement (filter, intentFilter); + + return filter; + } + + internal static void AddIntentFilterDataElement (XElement filter, IntentFilterInfo intentFilter) + { + var dataElement = new XElement ("data"); + bool hasData = false; + + if (intentFilter.Properties.TryGetValue ("DataScheme", out var scheme) && scheme is string schemeStr) { + dataElement.SetAttributeValue (AndroidNs + "scheme", schemeStr); + hasData = true; + } + if (intentFilter.Properties.TryGetValue ("DataHost", out var host) && host is string hostStr) { + dataElement.SetAttributeValue (AndroidNs + "host", hostStr); + hasData = true; + } + if (intentFilter.Properties.TryGetValue ("DataPath", out var path) && path is string pathStr) { + dataElement.SetAttributeValue (AndroidNs + "path", pathStr); + hasData = true; + } + if (intentFilter.Properties.TryGetValue ("DataPathPattern", out var pattern) && pattern is string patternStr) { + dataElement.SetAttributeValue (AndroidNs + "pathPattern", patternStr); + hasData = true; + } + if (intentFilter.Properties.TryGetValue ("DataPathPrefix", out var prefix) && prefix is string prefixStr) { + dataElement.SetAttributeValue (AndroidNs + "pathPrefix", prefixStr); + hasData = true; + } + if (intentFilter.Properties.TryGetValue ("DataMimeType", out var mime) && mime is string mimeStr) { + dataElement.SetAttributeValue (AndroidNs + "mimeType", mimeStr); + hasData = true; + } + if (intentFilter.Properties.TryGetValue ("DataPort", out var port) && port is string portStr) { + dataElement.SetAttributeValue (AndroidNs + "port", portStr); + hasData = true; + } + + if (hasData) { + filter.Add (dataElement); + } + } + + internal static XElement CreateMetaDataElement (MetaDataInfo meta) + { + var element = new XElement ("meta-data", + new XAttribute (AndroidNs + "name", meta.Name)); + + if (meta.Value is not null) { + element.SetAttributeValue (AndroidNs + "value", meta.Value); + } + if (meta.Resource is not null) { + element.SetAttributeValue (AndroidNs + "resource", meta.Resource); + } + return element; + } + + internal static void UpdateApplicationElement (XElement app, JavaPeerInfo peer) + { + string jniName = peer.JavaName.Replace ('/', '.'); + app.SetAttributeValue (AttName, jniName); + + var component = peer.ComponentAttribute; + if (component is null) { + return; + } + PropertyMapper.ApplyMappings (app, component.Properties, PropertyMapper.ApplicationElementMappings); + } + + internal static void AddInstrumentation (XElement manifest, JavaPeerInfo peer) + { + string jniName = peer.JavaName.Replace ('/', '.'); + var element = new XElement ("instrumentation", + new XAttribute (AttName, jniName)); + + var component = peer.ComponentAttribute; + if (component is null) { + return; + } + PropertyMapper.ApplyMappings (element, component.Properties, PropertyMapper.InstrumentationMappings); + + manifest.Add (element); + } +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestConstants.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestConstants.cs new file mode 100644 index 00000000000..4ed62e9baa6 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestConstants.cs @@ -0,0 +1,11 @@ +#nullable enable + +using System.Xml.Linq; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +static class ManifestConstants +{ + public static readonly XNamespace AndroidNs = "http://schemas.android.com/apk/res/android"; + public static readonly XName AttName = AndroidNs + "name"; +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs new file mode 100644 index 00000000000..938c60fd10a --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestGenerator.cs @@ -0,0 +1,288 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +/// +/// Generates AndroidManifest.xml from component attributes captured by the JavaPeerScanner. +/// This is the trimmable-path equivalent of ManifestDocument — it works from ComponentInfo +/// records instead of Cecil TypeDefinitions. +/// +class ManifestGenerator +{ + static readonly XNamespace AndroidNs = ManifestConstants.AndroidNs; + static readonly XName AttName = ManifestConstants.AttName; + static readonly char [] PlaceholderSeparators = [';']; + + int appInitOrder = 2000000000; + + public string PackageName { get; set; } = ""; + public string ApplicationLabel { get; set; } = ""; + public string VersionCode { get; set; } = ""; + public string VersionName { get; set; } = ""; + public string MinSdkVersion { get; set; } = "21"; + public string TargetSdkVersion { get; set; } = "36"; + public string AndroidRuntime { get; set; } = "coreclr"; + public bool Debug { get; set; } + public bool NeedsInternet { get; set; } + public bool EmbedAssemblies { get; set; } + public bool ForceDebuggable { get; set; } + public bool ForceExtractNativeLibs { get; set; } + public string? ManifestPlaceholders { get; set; } + public string? ApplicationJavaClass { get; set; } + + /// + /// Generates the merged manifest and writes it to . + /// Returns the list of additional content provider names (for ApplicationRegistration.java). + /// + public IList Generate ( + string? manifestTemplatePath, + IReadOnlyList allPeers, + AssemblyManifestInfo assemblyInfo, + string outputPath) + { + var doc = LoadOrCreateManifest (manifestTemplatePath); + var manifest = doc.Root; + if (manifest is null) { + throw new InvalidOperationException ("Manifest document has no root element."); + } + + EnsureManifestAttributes (manifest); + var app = EnsureApplicationElement (manifest); + + // Apply assembly-level [Application] properties + if (assemblyInfo.ApplicationProperties is not null) { + AssemblyLevelElementBuilder.ApplyApplicationProperties (app, assemblyInfo.ApplicationProperties); + } + + var existingTypes = new HashSet ( + app.Descendants ().Select (a => (string?)a.Attribute (AttName)).OfType ()); + + // Add components from scanned types + foreach (var peer in allPeers) { + if (peer.IsAbstract || peer.ComponentAttribute is null) { + continue; + } + + // Skip Application types (handled separately via assembly-level attribute) + if (peer.ComponentAttribute.Kind == ComponentKind.Application) { + ComponentElementBuilder.UpdateApplicationElement (app, peer); + continue; + } + + if (peer.ComponentAttribute.Kind == ComponentKind.Instrumentation) { + ComponentElementBuilder.AddInstrumentation (manifest, peer); + continue; + } + + string jniName = peer.JavaName.Replace ('/', '.'); + if (existingTypes.Contains (jniName)) { + continue; + } + + var element = ComponentElementBuilder.CreateComponentElement (peer, jniName); + if (element is not null) { + app.Add (element); + } + } + + // Add assembly-level manifest elements + AssemblyLevelElementBuilder.AddAssemblyLevelElements (manifest, app, assemblyInfo); + + // Add runtime provider + var providerNames = AddRuntimeProviders (app); + + // Set ApplicationJavaClass + if (!string.IsNullOrEmpty (ApplicationJavaClass) && app.Attribute (AttName) is null) { + app.SetAttributeValue (AttName, ApplicationJavaClass); + } + + // Handle debuggable + bool needDebuggable = Debug && app.Attribute (AndroidNs + "debuggable") is null; + if (ForceDebuggable || needDebuggable) { + app.SetAttributeValue (AndroidNs + "debuggable", "true"); + } + + // Handle extractNativeLibs + if (ForceExtractNativeLibs) { + app.SetAttributeValue (AndroidNs + "extractNativeLibs", "true"); + } + + // Add internet permission for debug + if (Debug || NeedsInternet) { + AssemblyLevelElementBuilder.AddInternetPermission (manifest); + } + + // Apply manifest placeholders + string? placeholders = ManifestPlaceholders; + if (placeholders is not null && placeholders.Length > 0) { + ApplyPlaceholders (doc, placeholders); + } + + // Write output + var outputDir = Path.GetDirectoryName (outputPath); + if (outputDir is not null) { + Directory.CreateDirectory (outputDir); + } + doc.Save (outputPath); + + return providerNames; + } + + XDocument LoadOrCreateManifest (string? templatePath) + { + if (!string.IsNullOrEmpty (templatePath) && File.Exists (templatePath)) { + return XDocument.Load (templatePath); + } + + return new XDocument ( + new XDeclaration ("1.0", "utf-8", null), + new XElement ("manifest", + new XAttribute (XNamespace.Xmlns + "android", AndroidNs.NamespaceName), + new XAttribute ("package", PackageName))); + } + + void EnsureManifestAttributes (XElement manifest) + { + manifest.SetAttributeValue (XNamespace.Xmlns + "android", AndroidNs.NamespaceName); + + if (string.IsNullOrEmpty ((string?)manifest.Attribute ("package"))) { + manifest.SetAttributeValue ("package", PackageName); + } + + if (manifest.Attribute (AndroidNs + "versionCode") is null) { + manifest.SetAttributeValue (AndroidNs + "versionCode", + string.IsNullOrEmpty (VersionCode) ? "1" : VersionCode); + } + + if (manifest.Attribute (AndroidNs + "versionName") is null) { + manifest.SetAttributeValue (AndroidNs + "versionName", + string.IsNullOrEmpty (VersionName) ? "1.0" : VersionName); + } + + // Add + if (!manifest.Elements ("uses-sdk").Any ()) { + manifest.AddFirst (new XElement ("uses-sdk", + new XAttribute (AndroidNs + "minSdkVersion", MinSdkVersion), + new XAttribute (AndroidNs + "targetSdkVersion", TargetSdkVersion))); + } + } + + XElement EnsureApplicationElement (XElement manifest) + { + var app = manifest.Element ("application"); + if (app is null) { + app = new XElement ("application"); + manifest.Add (app); + } + + if (app.Attribute (AndroidNs + "label") is null && !string.IsNullOrEmpty (ApplicationLabel)) { + app.SetAttributeValue (AndroidNs + "label", ApplicationLabel); + } + + return app; + } + + IList AddRuntimeProviders (XElement app) + { + string packageName = "mono"; + string className = "MonoRuntimeProvider"; + + if (string.Equals (AndroidRuntime, "nativeaot", StringComparison.OrdinalIgnoreCase)) { + packageName = "net.dot.jni.nativeaot"; + className = "NativeAotRuntimeProvider"; + } + + // Check if runtime provider already exists in template + string runtimeProviderName = $"{packageName}.{className}"; + if (!app.Elements ("provider").Any (p => { + var name = (string?)p.Attribute (ManifestConstants.AttName); + return name == runtimeProviderName || + ((string?)p.Attribute (AndroidNs.GetName ("authorities")))?.EndsWith (".__mono_init__", StringComparison.Ordinal) == true; + })) { + app.Add (CreateRuntimeProvider (runtimeProviderName, null, --appInitOrder)); + } + + var providerNames = new List (); + var processAttrName = AndroidNs.GetName ("process"); + var procs = new List (); + + foreach (var el in app.Elements ()) { + var proc = el.Attribute (processAttrName); + if (proc is null || procs.Contains (proc.Value)) { + continue; + } + if (el.Name.NamespaceName != "") { + continue; + } + switch (el.Name.LocalName) { + case "provider": + var autho = el.Attribute (AndroidNs.GetName ("authorities")); + if (autho is not null && autho.Value.EndsWith (".__mono_init__", StringComparison.Ordinal)) { + continue; + } + goto case "activity"; + case "activity": + case "receiver": + case "service": + procs.Add (proc.Value); + string providerName = $"{className}_{procs.Count}"; + providerNames.Add (providerName); + app.Add (CreateRuntimeProvider ($"{packageName}.{providerName}", proc.Value, --appInitOrder)); + break; + } + } + + return providerNames; + } + + XElement CreateRuntimeProvider (string name, string? processName, int initOrder) + { + return new XElement ("provider", + new XAttribute (AndroidNs + "name", name), + new XAttribute (AndroidNs + "exported", "false"), + new XAttribute (AndroidNs + "initOrder", initOrder), + processName is not null ? new XAttribute (AndroidNs + "process", processName) : null, + new XAttribute (AndroidNs + "authorities", PackageName + "." + name + ".__mono_init__")); + } + + /// + /// Replaces ${key} placeholders in all attribute values throughout the document. + /// Placeholder format: "key1=value1;key2=value2" + /// + static void ApplyPlaceholders (XDocument doc, string placeholders) + { + 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 (replacements.Count == 0) { + return; + } + + foreach (var element in doc.Descendants ()) { + foreach (var attr in element.Attributes ()) { + var val = attr.Value; + foreach (var kvp in replacements) { + if (val.Contains (kvp.Key)) { + val = val.Replace (kvp.Key, kvp.Value); + } + } + if (val != attr.Value) { + attr.Value = val; + } + } + } + } +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestModel.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestModel.cs new file mode 100644 index 00000000000..166223cc44f --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/ManifestModel.cs @@ -0,0 +1,105 @@ +#nullable enable + +using System.Collections.Generic; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +public enum ComponentKind +{ + Activity, + Service, + BroadcastReceiver, + ContentProvider, + Application, + Instrumentation, +} + +public class ComponentInfo +{ + public bool HasPublicDefaultConstructor { get; set; } + public ComponentKind Kind { get; set; } + public Dictionary Properties { get; set; } = new Dictionary (); + public IReadOnlyList IntentFilters { get; set; } = []; + public IReadOnlyList MetaData { get; set; } = []; +} + +public class IntentFilterInfo +{ + public IReadOnlyList Actions { get; set; } = []; + public IReadOnlyList Categories { get; set; } = []; + public Dictionary Properties { get; set; } = new Dictionary (); +} + +public class MetaDataInfo +{ + public string Name { get; set; } = ""; + public string? Value { get; set; } + public string? Resource { get; set; } +} + +public class PermissionInfo +{ + public string Name { get; set; } = ""; + public Dictionary Properties { get; set; } = new Dictionary (); +} + +public class PermissionGroupInfo +{ + public string Name { get; set; } = ""; + public Dictionary Properties { get; set; } = new Dictionary (); +} + +public class PermissionTreeInfo +{ + public string Name { get; set; } = ""; + public Dictionary Properties { get; set; } = new Dictionary (); +} + +public class UsesPermissionInfo +{ + public string Name { get; set; } = ""; + public int? MaxSdkVersion { get; set; } +} + +public class UsesFeatureInfo +{ + public string? Name { get; set; } + public bool Required { get; set; } + public int GLESVersion { get; set; } +} + +public class UsesLibraryInfo +{ + public string Name { get; set; } = ""; + public bool Required { get; set; } +} + +public class UsesConfigurationInfo +{ + public bool ReqFiveWayNav { get; set; } + public bool ReqHardKeyboard { get; set; } + public string? ReqKeyboardType { get; set; } + public string? ReqNavigation { get; set; } + public string? ReqTouchScreen { get; set; } +} + +public class PropertyInfo +{ + public string Name { get; set; } = ""; + public string? Value { get; set; } + public string? Resource { get; set; } +} + +public class AssemblyManifestInfo +{ + public List Permissions { get; set; } = new List (); + public List PermissionGroups { get; set; } = new List (); + public List PermissionTrees { get; set; } = new List (); + public List UsesPermissions { get; set; } = new List (); + public List UsesFeatures { get; set; } = new List (); + public List UsesLibraries { get; set; } = new List (); + public List UsesConfigurations { get; set; } = new List (); + public List MetaData { get; set; } = new List (); + public List Properties { get; set; } = new List (); + public Dictionary? ApplicationProperties { get; set; } +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PropertyMapper.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PropertyMapper.cs new file mode 100644 index 00000000000..32b07671600 --- /dev/null +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Generator/PropertyMapper.cs @@ -0,0 +1,200 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Xml.Linq; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap; + +/// +/// Defines the property mapping infrastructure for converting +/// properties to Android manifest XML attributes. +/// +static class PropertyMapper +{ + static readonly XNamespace AndroidNs = ManifestConstants.AndroidNs; + + internal enum MappingKind { String, Bool, Enum } + + internal readonly struct PropertyMapping + { + public string PropertyName { get; } + public string XmlAttributeName { get; } + public MappingKind Kind { get; } + public Func? EnumConverter { get; } + + public PropertyMapping (string propertyName, string xmlAttributeName, MappingKind kind = MappingKind.String, Func? enumConverter = null) + { + PropertyName = propertyName; + XmlAttributeName = xmlAttributeName; + Kind = kind; + EnumConverter = enumConverter; + } + } + + internal static readonly PropertyMapping[] CommonMappings = [ + new ("Label", "label"), + new ("Description", "description"), + new ("Icon", "icon"), + new ("RoundIcon", "roundIcon"), + new ("Permission", "permission"), + new ("Process", "process"), + new ("Enabled", "enabled", MappingKind.Bool), + new ("DirectBootAware", "directBootAware", MappingKind.Bool), + new ("Exported", "exported", MappingKind.Bool), + ]; + + internal static readonly PropertyMapping[] ActivityMappings = [ + new ("Theme", "theme"), + new ("ParentActivity", "parentActivityName"), + new ("TaskAffinity", "taskAffinity"), + new ("AllowTaskReparenting", "allowTaskReparenting", MappingKind.Bool), + new ("AlwaysRetainTaskState", "alwaysRetainTaskState", MappingKind.Bool), + new ("ClearTaskOnLaunch", "clearTaskOnLaunch", MappingKind.Bool), + new ("ExcludeFromRecents", "excludeFromRecents", MappingKind.Bool), + new ("FinishOnCloseSystemDialogs", "finishOnCloseSystemDialogs", MappingKind.Bool), + new ("FinishOnTaskLaunch", "finishOnTaskLaunch", MappingKind.Bool), + new ("HardwareAccelerated", "hardwareAccelerated", MappingKind.Bool), + new ("NoHistory", "noHistory", MappingKind.Bool), + new ("MultiProcess", "multiprocess", 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 ("LaunchMode", "launchMode", MappingKind.Enum, AndroidEnumConverter.LaunchModeToString), + new ("ScreenOrientation", "screenOrientation", MappingKind.Enum, AndroidEnumConverter.ScreenOrientationToString), + new ("ConfigurationChanges", "configChanges", 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), + ]; + + internal static readonly PropertyMapping[] ServiceMappings = [ + new ("IsolatedProcess", "isolatedProcess", MappingKind.Bool), + new ("ForegroundServiceType", "foregroundServiceType", MappingKind.Enum, AndroidEnumConverter.ForegroundServiceTypeToString), + ]; + + internal static readonly PropertyMapping[] ContentProviderMappings = [ + new ("Authorities", "authorities"), + new ("GrantUriPermissions", "grantUriPermissions", MappingKind.Bool), + new ("Syncable", "syncable", MappingKind.Bool), + new ("MultiProcess", "multiprocess", MappingKind.Bool), + ]; + + internal static readonly PropertyMapping[] ApplicationElementMappings = [ + new ("Label", "label"), + new ("Icon", "icon"), + new ("RoundIcon", "roundIcon"), + new ("Theme", "theme"), + new ("AllowBackup", "allowBackup", MappingKind.Bool), + new ("SupportsRtl", "supportsRtl", MappingKind.Bool), + new ("HardwareAccelerated", "hardwareAccelerated", MappingKind.Bool), + new ("LargeHeap", "largeHeap", MappingKind.Bool), + new ("Debuggable", "debuggable", MappingKind.Bool), + new ("UsesCleartextTraffic", "usesCleartextTraffic", MappingKind.Bool), + ]; + + internal static readonly PropertyMapping[] InstrumentationMappings = [ + new ("Label", "label"), + new ("Icon", "icon"), + new ("TargetPackage", "targetPackage"), + new ("FunctionalTest", "functionalTest", MappingKind.Bool), + new ("HandleProfiling", "handleProfiling", MappingKind.Bool), + ]; + + internal static readonly PropertyMapping[] ApplicationPropertyMappings = [ + new ("Label", "label"), + new ("Icon", "icon"), + new ("RoundIcon", "roundIcon"), + new ("Theme", "theme"), + new ("NetworkSecurityConfig", "networkSecurityConfig"), + new ("Description", "description"), + new ("Logo", "logo"), + new ("Permission", "permission"), + new ("Process", "process"), + new ("TaskAffinity", "taskAffinity"), + new ("AllowBackup", "allowBackup", MappingKind.Bool), + new ("SupportsRtl", "supportsRtl", MappingKind.Bool), + new ("HardwareAccelerated", "hardwareAccelerated", MappingKind.Bool), + new ("LargeHeap", "largeHeap", MappingKind.Bool), + new ("Debuggable", "debuggable", MappingKind.Bool), + new ("UsesCleartextTraffic", "usesCleartextTraffic", MappingKind.Bool), + new ("RestoreAnyVersion", "restoreAnyVersion", MappingKind.Bool), + ]; + + internal static void ApplyMappings (XElement element, IReadOnlyDictionary properties, PropertyMapping[] mappings, bool skipExisting = false) + { + foreach (var m in mappings) { + if (!properties.TryGetValue (m.PropertyName, out var value) || value is null) { + continue; + } + if (skipExisting && element.Attribute (AndroidNs + m.XmlAttributeName) is not null) { + continue; + } + switch (m.Kind) { + case MappingKind.String when value is string s && !string.IsNullOrEmpty (s): + element.SetAttributeValue (AndroidNs + m.XmlAttributeName, s); + break; + case MappingKind.Bool when value is bool b: + element.SetAttributeValue (AndroidNs + m.XmlAttributeName, b ? "true" : "false"); + break; + case MappingKind.Enum when m.EnumConverter is not null: + int intValue = value switch { int i => i, long l => (int)l, short s => s, byte b => b, _ => 0 }; + var strValue = m.EnumConverter (intValue); + if (strValue is not null) { + element.SetAttributeValue (AndroidNs + m.XmlAttributeName, strValue); + } + break; + } + } + } + + internal static void MapComponentProperties (XElement element, ComponentInfo component) + { + ApplyMappings (element, component.Properties, CommonMappings); + + var extra = component.Kind switch { + ComponentKind.Activity => ActivityMappings, + ComponentKind.Service => ServiceMappings, + ComponentKind.ContentProvider => ContentProviderMappings, + _ => null, + }; + if (extra is not null) { + ApplyMappings (element, component.Properties, extra); + } + + // Handle InitOrder for ContentProvider (int, not a standard mapping) + if (component.Kind == ComponentKind.ContentProvider && component.Properties.TryGetValue ("InitOrder", out var initOrder) && initOrder is int order) { + element.SetAttributeValue (AndroidNs + "initOrder", order.ToString (CultureInfo.InvariantCulture)); + } + } + + internal static void MapDictionaryProperties (XElement element, IReadOnlyDictionary props, string propertyName, string xmlAttrName) + { + if (props.TryGetValue (propertyName, out var value) && value is string s && !string.IsNullOrEmpty (s)) { + element.SetAttributeValue (AndroidNs + xmlAttrName, s); + } + } + + internal static void MapDictionaryEnumProperty (XElement element, IReadOnlyDictionary props, string propertyName, string xmlAttrName, Func converter) + { + if (!props.TryGetValue (propertyName, out var value)) { + return; + } + int intValue = value switch { + int i => i, + long l => (int)l, + short s => s, + byte b => b, + _ => 0, + }; + var strValue = converter (intValue); + if (strValue is not null) { + element.SetAttributeValue (AndroidNs + xmlAttrName, strValue); + } + } +} diff --git a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs index 9fceaeaa3ac..adb6a052f5e 100644 --- a/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs +++ b/src/Microsoft.Android.Sdk.TrimmableTypeMap/Scanner/JavaPeerInfo.cs @@ -117,6 +117,13 @@ public sealed record JavaPeerInfo /// Generic types get TypeMap entries but CreateInstance throws NotSupportedException. /// public bool IsGenericDefinition { get; init; } + + /// + /// Component attribute information ([Activity], [Service], [BroadcastReceiver], + /// [ContentProvider], [Application], [Instrumentation]). + /// Null for types that are not Android components. + /// + public ComponentInfo? ComponentAttribute { get; init; } } /// diff --git a/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs new file mode 100644 index 00000000000..b41fce30764 --- /dev/null +++ b/tests/Microsoft.Android.Sdk.TrimmableTypeMap.Tests/Generator/ManifestGeneratorTests.cs @@ -0,0 +1,617 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; +using Microsoft.Android.Sdk.TrimmableTypeMap; + +using Xunit; + +namespace Microsoft.Android.Sdk.TrimmableTypeMap.Tests; + +public class ManifestGeneratorTests : IDisposable +{ + static readonly XNamespace AndroidNs = "http://schemas.android.com/apk/res/android"; + static readonly XName AttName = AndroidNs + "name"; + + string tempDir; + + public ManifestGeneratorTests () + { + tempDir = Path.Combine (Path.GetTempPath (), "ManifestGeneratorTests_" + Guid.NewGuid ().ToString ("N")); + Directory.CreateDirectory (tempDir); + } + + public void Dispose () + { + if (Directory.Exists (tempDir)) { + Directory.Delete (tempDir, recursive: true); + } + } + + ManifestGenerator CreateDefaultGenerator () => new ManifestGenerator { + PackageName = "com.example.app", + ApplicationLabel = "My App", + VersionCode = "1", + VersionName = "1.0", + MinSdkVersion = "21", + TargetSdkVersion = "36", + AndroidRuntime = "coreclr", + }; + + string OutputPath => Path.Combine (tempDir, "AndroidManifest.xml"); + + string WriteTemplate (string xml) + { + var path = Path.Combine (tempDir, "template.xml"); + File.WriteAllText (path, xml); + return path; + } + + static JavaPeerInfo CreatePeer ( + string javaName, + ComponentInfo? component = null, + bool isAbstract = false, + string assemblyName = "TestApp") + { + return new JavaPeerInfo { + JavaName = javaName, + CompatJniName = javaName, + ManagedTypeName = javaName.Replace ('/', '.'), + ManagedTypeNamespace = javaName.Contains ('/') ? javaName.Substring (0, javaName.LastIndexOf ('/')).Replace ('/', '.') : "", + ManagedTypeShortName = javaName.Contains ('/') ? javaName.Substring (javaName.LastIndexOf ('/') + 1) : javaName, + AssemblyName = assemblyName, + IsAbstract = isAbstract, + ComponentAttribute = component, + }; + } + + XDocument GenerateAndLoad ( + ManifestGenerator gen, + IReadOnlyList? peers = null, + AssemblyManifestInfo? assemblyInfo = null, + string? templatePath = null) + { + peers ??= []; + assemblyInfo ??= new AssemblyManifestInfo (); + gen.Generate (templatePath, peers, assemblyInfo, OutputPath); + return XDocument.Load (OutputPath); + } + + [Fact] + public void Activity_MainLauncher () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MainActivity", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Activity, + Properties = new Dictionary { ["MainLauncher"] = true }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var app = doc.Root?.Element ("application"); + Assert.NotNull (app); + var activity = app?.Element ("activity"); + Assert.NotNull (activity); + + Assert.Equal ("com.example.app.MainActivity", (string?)activity?.Attribute (AttName)); + Assert.Equal ("true", (string?)activity?.Attribute (AndroidNs + "exported")); + + var filter = activity?.Element ("intent-filter"); + Assert.NotNull (filter); + Assert.True (filter?.Elements ("action").Any (a => (string?)a.Attribute (AttName) == "android.intent.action.MAIN")); + Assert.True (filter?.Elements ("category").Any (c => (string?)c.Attribute (AttName) == "android.intent.category.LAUNCHER")); + } + + [Fact] + public void Activity_WithProperties () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MyActivity", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Activity, + Properties = new Dictionary { + ["Label"] = "My Activity", + ["Icon"] = "@drawable/icon", + ["Theme"] = "@style/MyTheme", + ["LaunchMode"] = 2, // singleTask + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var activity = doc.Root?.Element ("application")?.Element ("activity"); + + Assert.Equal ("My Activity", (string?)activity?.Attribute (AndroidNs + "label")); + Assert.Equal ("@drawable/icon", (string?)activity?.Attribute (AndroidNs + "icon")); + Assert.Equal ("@style/MyTheme", (string?)activity?.Attribute (AndroidNs + "theme")); + Assert.Equal ("singleTask", (string?)activity?.Attribute (AndroidNs + "launchMode")); + } + + [Fact] + public void Activity_IntentFilter () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/ShareActivity", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Activity, + IntentFilters = [ + new IntentFilterInfo { + Actions = ["android.intent.action.SEND"], + Categories = ["android.intent.category.DEFAULT"], + Properties = new Dictionary { + ["DataMimeType"] = "text/plain", + }, + }, + ], + }); + + var doc = GenerateAndLoad (gen, [peer]); + var activity = doc.Root?.Element ("application")?.Element ("activity"); + + var filter = activity?.Element ("intent-filter"); + Assert.NotNull (filter); + Assert.True (filter?.Elements ("action").Any (a => (string?)a.Attribute (AttName) == "android.intent.action.SEND")); + Assert.True (filter?.Elements ("category").Any (c => (string?)c.Attribute (AttName) == "android.intent.category.DEFAULT")); + + var data = filter?.Element ("data"); + Assert.NotNull (data); + Assert.Equal ("text/plain", (string?)data?.Attribute (AndroidNs + "mimeType")); + } + + [Fact] + public void Activity_MetaData () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MetaActivity", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Activity, + MetaData = [ + new MetaDataInfo { Name = "com.example.key", Value = "my_value" }, + new MetaDataInfo { Name = "com.example.res", Resource = "@xml/config" }, + ], + }); + + var doc = GenerateAndLoad (gen, [peer]); + var activity = doc.Root?.Element ("application")?.Element ("activity"); + + var metaElements = activity?.Elements ("meta-data").ToList (); + Assert.Equal (2, metaElements?.Count); + + var meta1 = metaElements?.FirstOrDefault (m => (string?)m.Attribute (AndroidNs + "name") == "com.example.key"); + Assert.NotNull (meta1); + Assert.Equal ("my_value", (string?)meta1?.Attribute (AndroidNs + "value")); + + var meta2 = metaElements?.FirstOrDefault (m => (string?)m.Attribute (AndroidNs + "name") == "com.example.res"); + Assert.NotNull (meta2); + Assert.Equal ("@xml/config", (string?)meta2?.Attribute (AndroidNs + "resource")); + } + + [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 { HasPublicDefaultConstructor = true, + Kind = kind, + Properties = new Dictionary { + ["Exported"] = true, + ["Label"] = "My Component", + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var element = doc.Root?.Element ("application")?.Element (elementName); + Assert.NotNull (element); + + Assert.Equal ("com.example.app.MyComponent", (string?)element?.Attribute (AttName)); + Assert.Equal ("true", (string?)element?.Attribute (AndroidNs + "exported")); + Assert.Equal ("My Component", (string?)element?.Attribute (AndroidNs + "label")); + } + + [Fact] + public void ContentProvider_WithAuthorities () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MyProvider", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.ContentProvider, + Properties = new Dictionary { + ["Authorities"] = "com.example.app.provider", + ["Exported"] = false, + ["GrantUriPermissions"] = true, + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var provider = doc.Root?.Element ("application")?.Element ("provider"); + Assert.NotNull (provider); + + Assert.Equal ("com.example.app.MyProvider", (string?)provider?.Attribute (AttName)); + Assert.Equal ("com.example.app.provider", (string?)provider?.Attribute (AndroidNs + "authorities")); + Assert.Equal ("false", (string?)provider?.Attribute (AndroidNs + "exported")); + Assert.Equal ("true", (string?)provider?.Attribute (AndroidNs + "grantUriPermissions")); + } + + [Fact] + public void Application_TypeLevel () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MyApp", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Application, + Properties = new Dictionary { + ["Label"] = "Custom App", + ["AllowBackup"] = false, + ["LargeHeap"] = true, + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var app = doc.Root?.Element ("application"); + Assert.NotNull (app); + + Assert.Equal ("com.example.app.MyApp", (string?)app?.Attribute (AttName)); + Assert.Equal ("false", (string?)app?.Attribute (AndroidNs + "allowBackup")); + Assert.Equal ("true", (string?)app?.Attribute (AndroidNs + "largeHeap")); + } + + [Fact] + public void Instrumentation_GoesToManifest () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/MyInstrumentation", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Instrumentation, + Properties = new Dictionary { + ["Label"] = "My Test", + ["TargetPackage"] = "com.example.target", + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + + // Instrumentation should be under , not + var instrumentation = doc.Root?.Element ("instrumentation"); + Assert.NotNull (instrumentation); + + Assert.Equal ("com.example.app.MyInstrumentation", (string?)instrumentation?.Attribute (AttName)); + Assert.Equal ("My Test", (string?)instrumentation?.Attribute (AndroidNs + "label")); + Assert.Equal ("com.example.target", (string?)instrumentation?.Attribute (AndroidNs + "targetPackage")); + + // Should NOT be inside + var appInstrumentation = doc.Root?.Element ("application")?.Element ("instrumentation"); + Assert.Null (appInstrumentation); + } + + [Fact] + public void RuntimeProvider_Added () + { + var gen = CreateDefaultGenerator (); + var doc = GenerateAndLoad (gen); + var app = doc.Root?.Element ("application"); + + var providers = app?.Elements ("provider").ToList (); + Assert.True (providers?.Count > 0); + + var runtimeProvider = providers?.FirstOrDefault (p => + ((string?)p.Attribute (AndroidNs + "name"))?.Contains ("MonoRuntimeProvider") == true); + Assert.NotNull (runtimeProvider); + + var authorities = (string?)runtimeProvider?.Attribute (AndroidNs + "authorities"); + Assert.True (authorities?.Contains ("com.example.app") == true, "authorities should contain package name"); + Assert.True (authorities?.Contains ("__mono_init__") == true, "authorities should contain __mono_init__"); + Assert.Equal ("false", (string?)runtimeProvider?.Attribute (AndroidNs + "exported")); + } + + [Fact] + public void TemplateManifest_Preserved () + { + var gen = CreateDefaultGenerator (); + var template = WriteTemplate ( + """ + + + + + + """); + + var doc = GenerateAndLoad (gen, templatePath: template); + var app = doc.Root?.Element ("application"); + + Assert.Equal ("false", (string?)app?.Attribute (AndroidNs + "allowBackup")); + Assert.Equal ("@mipmap/ic_launcher", (string?)app?.Attribute (AndroidNs + "icon")); + } + + [Theory] + [InlineData ("", "", "1", "1.0")] + [InlineData ("42", "2.5", "42", "2.5")] + public void VersionDefaults (string versionCode, string versionName, string expectedCode, string expectedName) + { + var gen = CreateDefaultGenerator (); + gen.VersionCode = versionCode; + gen.VersionName = versionName; + + var doc = GenerateAndLoad (gen); + Assert.Equal (expectedCode, (string?)doc.Root?.Attribute (AndroidNs + "versionCode")); + Assert.Equal (expectedName, (string?)doc.Root?.Attribute (AndroidNs + "versionName")); + } + + [Fact] + public void UsesSdk_Added () + { + var gen = CreateDefaultGenerator (); + gen.MinSdkVersion = "24"; + gen.TargetSdkVersion = "34"; + + var doc = GenerateAndLoad (gen); + var usesSdk = doc.Root?.Element ("uses-sdk"); + Assert.NotNull (usesSdk); + + Assert.Equal ("24", (string?)usesSdk?.Attribute (AndroidNs + "minSdkVersion")); + Assert.Equal ("34", (string?)usesSdk?.Attribute (AndroidNs + "targetSdkVersion")); + } + + [Theory] + [InlineData (true, false, false, "debuggable", "true")] + [InlineData (false, true, false, "debuggable", "true")] + [InlineData (false, false, true, "extractNativeLibs", "true")] + public void ApplicationFlags (bool debug, bool forceDebuggable, bool forceExtractNativeLibs, string attrName, string expected) + { + var gen = CreateDefaultGenerator (); + gen.Debug = debug; + gen.ForceDebuggable = forceDebuggable; + gen.ForceExtractNativeLibs = forceExtractNativeLibs; + + var doc = GenerateAndLoad (gen); + var app = doc.Root?.Element ("application"); + Assert.Equal (expected, (string?)app?.Attribute (AndroidNs + attrName)); + } + + [Fact] + public void InternetPermission_WhenDebug () + { + var gen = CreateDefaultGenerator (); + gen.Debug = true; + + var doc = GenerateAndLoad (gen); + var internetPerm = doc.Root?.Elements ("uses-permission") + .FirstOrDefault (p => (string?)p.Attribute (AndroidNs + "name") == "android.permission.INTERNET"); + Assert.NotNull (internetPerm); + } + + [Fact] + public void ManifestPlaceholders_Replaced () + { + var gen = CreateDefaultGenerator (); + gen.ManifestPlaceholders = "myAuthority=com.example.auth;myKey=12345"; + + var template = WriteTemplate ( + """ + + + + + + + + """); + + var doc = GenerateAndLoad (gen, templatePath: template); + var provider = doc.Root?.Element ("application")?.Elements ("provider") + .FirstOrDefault (p => (string?)p.Attribute (AndroidNs + "name") == "com.example.MyProvider"); + Assert.Equal ("com.example.auth", (string?)provider?.Attribute (AndroidNs + "authorities")); + + var meta = doc.Root?.Element ("application")?.Elements ("meta-data") + .FirstOrDefault (m => (string?)m.Attribute (AndroidNs + "name") == "api_key"); + Assert.Equal ("12345", (string?)meta?.Attribute (AndroidNs + "value")); + } + + [Fact] + public void ApplicationJavaClass_Set () + { + var gen = CreateDefaultGenerator (); + gen.ApplicationJavaClass = "com.example.app.CustomApplication"; + + var doc = GenerateAndLoad (gen); + var app = doc.Root?.Element ("application"); + Assert.Equal ("com.example.app.CustomApplication", (string?)app?.Attribute (AttName)); + } + + [Fact] + public void AbstractTypes_Skipped () + { + var gen = CreateDefaultGenerator (); + var peer = CreatePeer ("com/example/app/AbstractActivity", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Activity, + Properties = new Dictionary { ["Label"] = "Abstract" }, + }, isAbstract: true); + + var doc = GenerateAndLoad (gen, [peer]); + var activity = doc.Root?.Element ("application")?.Element ("activity"); + Assert.Null (activity); + } + + [Fact] + public void ExistingType_NotDuplicated () + { + var gen = CreateDefaultGenerator (); + var template = WriteTemplate ( + """ + + + + + + + """); + + var peer = CreatePeer ("com/example/app/ExistingActivity", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Activity, + Properties = new Dictionary { ["Label"] = "New Label" }, + }); + + var doc = GenerateAndLoad (gen, [peer], templatePath: template); + var activities = doc.Root?.Element ("application")?.Elements ("activity") + .Where (a => (string?)a.Attribute (AttName) == "com.example.app.ExistingActivity") + .ToList (); + + Assert.Equal (1, activities?.Count); + // Original label preserved + Assert.Equal ("Existing", (string?)activities? [0].Attribute (AndroidNs + "label")); + } + + [Fact] + public void AssemblyLevel_UsesPermission () + { + var gen = CreateDefaultGenerator (); + var info = new AssemblyManifestInfo (); + info.UsesPermissions.Add (new UsesPermissionInfo { Name = "android.permission.CAMERA" }); + + var doc = GenerateAndLoad (gen, assemblyInfo: info); + var perm = doc.Root?.Elements ("uses-permission") + .FirstOrDefault (p => (string?)p.Attribute (AttName) == "android.permission.CAMERA"); + Assert.NotNull (perm); + } + + [Fact] + public void AssemblyLevel_UsesFeature () + { + var gen = CreateDefaultGenerator (); + var info = new AssemblyManifestInfo (); + info.UsesFeatures.Add (new UsesFeatureInfo { Name = "android.hardware.camera", Required = false }); + + var doc = GenerateAndLoad (gen, assemblyInfo: info); + var feature = doc.Root?.Elements ("uses-feature") + .FirstOrDefault (f => (string?)f.Attribute (AttName) == "android.hardware.camera"); + Assert.NotNull (feature); + Assert.Equal ("false", (string?)feature?.Attribute (AndroidNs + "required")); + } + + [Fact] + public void AssemblyLevel_UsesLibrary () + { + var gen = CreateDefaultGenerator (); + var info = new AssemblyManifestInfo (); + info.UsesLibraries.Add (new UsesLibraryInfo { Name = "org.apache.http.legacy", Required = false }); + + var doc = GenerateAndLoad (gen, assemblyInfo: info); + var lib = doc.Root?.Element ("application")?.Elements ("uses-library") + .FirstOrDefault (l => (string?)l.Attribute (AttName) == "org.apache.http.legacy"); + Assert.NotNull (lib); + Assert.Equal ("false", (string?)lib?.Attribute (AndroidNs + "required")); + } + + [Fact] + public void AssemblyLevel_Permission () + { + var gen = CreateDefaultGenerator (); + var info = new AssemblyManifestInfo (); + info.Permissions.Add (new PermissionInfo { + Name = "com.example.MY_PERMISSION", + Properties = new Dictionary { + ["Label"] = "My Permission", + ["Description"] = "A custom permission", + }, + }); + + var doc = GenerateAndLoad (gen, assemblyInfo: info); + var perm = doc.Root?.Elements ("permission") + .FirstOrDefault (p => (string?)p.Attribute (AttName) == "com.example.MY_PERMISSION"); + Assert.NotNull (perm); + Assert.Equal ("My Permission", (string?)perm?.Attribute (AndroidNs + "label")); + Assert.Equal ("A custom permission", (string?)perm?.Attribute (AndroidNs + "description")); + } + + [Fact] + public void AssemblyLevel_MetaData () + { + var gen = CreateDefaultGenerator (); + var info = new AssemblyManifestInfo (); + info.MetaData.Add (new MetaDataInfo { Name = "com.google.android.gms.version", Value = "12345" }); + + var doc = GenerateAndLoad (gen, assemblyInfo: info); + var meta = doc.Root?.Element ("application")?.Elements ("meta-data") + .FirstOrDefault (m => (string?)m.Attribute (AndroidNs + "name") == "com.google.android.gms.version"); + Assert.NotNull (meta); + Assert.Equal ("12345", (string?)meta?.Attribute (AndroidNs + "value")); + } + + [Fact] + public void AssemblyLevel_Application () + { + var gen = CreateDefaultGenerator (); + var info = new AssemblyManifestInfo { + ApplicationProperties = new Dictionary { + ["Theme"] = "@style/AppTheme", + ["SupportsRtl"] = true, + }, + }; + + var doc = GenerateAndLoad (gen, assemblyInfo: info); + var app = doc.Root?.Element ("application"); + Assert.Equal ("@style/AppTheme", (string?)app?.Attribute (AndroidNs + "theme")); + Assert.Equal ("true", (string?)app?.Attribute (AndroidNs + "supportsRtl")); + } + + [Fact] + public void AssemblyLevel_Deduplication () + { + var gen = CreateDefaultGenerator (); + var template = WriteTemplate ( + """ + + + + + + + + + """); + + var info = new AssemblyManifestInfo (); + info.UsesPermissions.Add (new UsesPermissionInfo { Name = "android.permission.CAMERA" }); + info.UsesLibraries.Add (new UsesLibraryInfo { Name = "org.apache.http.legacy" }); + info.MetaData.Add (new MetaDataInfo { Name = "existing.key", Value = "new_value" }); + + var doc = GenerateAndLoad (gen, assemblyInfo: info, templatePath: template); + + var cameraPerms = doc.Root?.Elements ("uses-permission") + .Where (p => (string?)p.Attribute (AttName) == "android.permission.CAMERA") + .ToList (); + Assert.Equal (1, cameraPerms?.Count); + + var libs = doc.Root?.Element ("application")?.Elements ("uses-library") + .Where (l => (string?)l.Attribute (AttName) == "org.apache.http.legacy") + .ToList (); + Assert.Equal (1, libs?.Count); + + var metas = doc.Root?.Element ("application")?.Elements ("meta-data") + .Where (m => (string?)m.Attribute (AndroidNs + "name") == "existing.key") + .ToList (); + Assert.Equal (1, metas?.Count); + } + + [Fact] + public void ConfigChanges_EnumConversion () + { + var gen = CreateDefaultGenerator (); + // orientation (0x0080) | keyboardHidden (0x0020) | screenSize (0x0400) + int configChanges = 0x0080 | 0x0020 | 0x0400; + var peer = CreatePeer ("com/example/app/ConfigActivity", new ComponentInfo { HasPublicDefaultConstructor = true, + Kind = ComponentKind.Activity, + Properties = new Dictionary { + ["ConfigurationChanges"] = configChanges, + }, + }); + + var doc = GenerateAndLoad (gen, [peer]); + var activity = doc.Root?.Element ("application")?.Element ("activity"); + + var configValue = (string?)activity?.Attribute (AndroidNs + "configChanges"); + + // The value should be pipe-separated and contain all three flags + var parts = configValue?.Split ('|') ?? []; + Assert.True (parts.Contains ("orientation"), "configChanges should contain 'orientation'"); + Assert.True (parts.Contains ("keyboardHidden"), "configChanges should contain 'keyboardHidden'"); + Assert.True (parts.Contains ("screenSize"), "configChanges should contain 'screenSize'"); + Assert.Equal (3, parts.Length); + } +}