From 36597667f0ac70c8c3b7b204dd6883fb6ff0539d Mon Sep 17 00:00:00 2001 From: Jonathan Pobst Date: Thu, 16 Nov 2023 12:28:05 -1000 Subject: [PATCH] [Xamarin.Android.Build.Tasks] Add support for @(AndroidMavenLibrary). (#8420) Context: https://github.com/xamarin/xamarin-android/issues/4528 Add support for the `@(AndroidMavenLibrary)` item group, which will download the requested Java artifact version from a Maven repository and add it as an `@(AndroidLibrary)` for binding. For example, to download the Maven package [pkg:maven/com.google.auto.value/auto-value-annotations@1.10.4][0] from Maven Central and create an Android binding for it: ~~ Specification ~~ The `//AndroidMavenLibrary/@Include` format is `{GroupId}:{ArtifactId}`. Any additional attributes such as `%(Bind)` or `%(Pack)` will be copied to the created `@(AndroidLibrary)` item. `%(Bind)` and `%(Pack)` default to `true` and control the binding process as specified for [`@(AndroidLibrary)`][1]. `%(Repository)` controls which Maven Repository to download artifacts from. Supported values include: * `Central` for [Maven Central][2]. This is the default value if `%(Repository)` is not specified. * `Google` for [Google's Maven][3]. * A URL, for downloading from a custom Maven instance. *Note*: Maven authentication is *not* supported. ~~Examples:~~ There are 2 parts to #4528: 1. Downloading artifacts from Maven 2. Ensuring all dependencies specified in the POM file are met Only (1) is currently addressed. (2) will be addressed in the future. [0]: https://repo1.maven.org/maven2/com/google/auto/value/auto-value-annotations/1.10.4/ [1]: https://github.com/xamarin/xamarin-android/blob/main/Documentation/guides/OneDotNetEmbeddedResources.md#msbuild-item-groups [2]: https://repo1.maven.org/maven2/ [3]: https://maven.google.com/web/index.html --- Documentation/guides/AndroidMavenLibrary.md | 61 +++++ .../guides/building-apps/build-items.md | 18 ++ .../automation/guardian/source.gdnsuppress | 14 ++ .../installers/create-installers.targets | 3 + .../xaprepare/ThirdPartyNotices/MavenNet.cs | 40 +++ .../Xamarin.Android.Bindings.Maven.targets | 39 +++ .../Microsoft.Android.Sdk.After.targets | 1 + .../Properties/Resources.Designer.cs | 60 ++++- .../Properties/Resources.resx | 49 +++- .../Tasks/MavenDownload.cs | 156 ++++++++++++ .../BindingBuildTest.cs | 20 ++ .../Tasks/MavenDownloadTests.cs | 199 +++++++++++++++ .../Utilities/ITaskItemExtensions.cs | 28 +++ .../Utilities/MavenExtensions.cs | 228 ++++++++++++++++++ .../Xamarin.Android.Build.Tasks.csproj | 1 + .../Xamarin.Android.Build.Tasks.targets | 7 + 16 files changed, 922 insertions(+), 2 deletions(-) create mode 100644 Documentation/guides/AndroidMavenLibrary.md create mode 100644 build-tools/xaprepare/xaprepare/ThirdPartyNotices/MavenNet.cs create mode 100644 src/Xamarin.Android.Build.Tasks/MSBuild/Xamarin/Android/Xamarin.Android.Bindings.Maven.targets create mode 100644 src/Xamarin.Android.Build.Tasks/Tasks/MavenDownload.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/MavenDownloadTests.cs create mode 100644 src/Xamarin.Android.Build.Tasks/Utilities/MavenExtensions.cs diff --git a/Documentation/guides/AndroidMavenLibrary.md b/Documentation/guides/AndroidMavenLibrary.md new file mode 100644 index 00000000000..e1e22bc5659 --- /dev/null +++ b/Documentation/guides/AndroidMavenLibrary.md @@ -0,0 +1,61 @@ +# AndroidMavenLibrary + +Note: This feature is only available in .NET 9+. + +## Description + +`` allows a Maven artifact to be specified which will automatically be downloaded and added to a .NET Android binding project. This can be useful to simplify maintenance of .NET Android bindings for artifacts hosted in Maven. + +## Specification + + A basic use of `` looks like: + +```xml + + + + +``` + +This will do two things at build time: +- Download the Java [artifact](https://central.sonatype.com/artifact/com.squareup.okhttp3/okhttp/4.9.3) with group id `com.squareup.okhttp3`, artifact id `okhttp`, and version `4.9.3` from [Maven Central](https://central.sonatype.com/) to a local cache (if not already cached). +- Add the cached package to the .NET Android bindings build as an [``](https://github.com/xamarin/xamarin-android/blob/main/Documentation/guides/building-apps/build-items.md#androidlibrary). + +Note that only the requested Java artifact is added to the .NET Android bindings build. Any artifact dependencies are not added. If the requested artifact has dependencies, they must be fulfilled individually. + +### Options + +`` defaults to using Maven Central, however it should support any Maven repository that does not require authentication. This can be controlled with the `Repository` attribute. + +Supported values are `Central` (default), `Google`, or a URL to another Maven repository. + +```xml + + + +``` + +```xml + + + +``` + +Additionally, any attributes applied to the `` element will be copied to the `` it creates internally. Thus, [attributes](https://github.com/xamarin/xamarin-android/blob/main/Documentation/guides/OneDotNetEmbeddedResources.md#msbuild-item-groups) like `Bind` and `Pack` can be used to control the binding process. (Both default to `true`.) + +```xml + + + +``` diff --git a/Documentation/guides/building-apps/build-items.md b/Documentation/guides/building-apps/build-items.md index 6da648100fe..3e0ff017700 100644 --- a/Documentation/guides/building-apps/build-items.md +++ b/Documentation/guides/building-apps/build-items.md @@ -174,6 +174,24 @@ installing app bundles. This build action was introduced in Xamarin.Android 11.3. +## AndroidMavenLibrary + +`` allows a Maven artifact to be specified which will +automatically be downloaded and added to a .NET Android binding project. +This can be useful to simplify maintenance of .NET Android bindings for artifacts +hosted in Maven. + +```xml + + + + +``` +See the [AndroidMavenLibrary documentation](../AndroidMavenLibrary.md) +for more details. + +This build action was introduced in .NET 9. + ## AndroidNativeLibrary [Native libraries](~/android/platform/native-libraries.md) diff --git a/build-tools/automation/guardian/source.gdnsuppress b/build-tools/automation/guardian/source.gdnsuppress index 7d42cdcacc6..d0a4455d7c6 100644 --- a/build-tools/automation/guardian/source.gdnsuppress +++ b/build-tools/automation/guardian/source.gdnsuppress @@ -231,6 +231,20 @@ "createdDate": "2023-02-22 23:55:29Z", "expirationDate": null, "type": null + }, + "243e199c7aec22377e0363bdca82384278cc36b0674f35697935fde6c45cfd0e": { + "signature": "243e199c7aec22377e0363bdca82384278cc36b0674f35697935fde6c45cfd0e", + "alternativeSignatures": [], + "target": "build-tools/xaprepare/xaprepare/ThirdPartyNotices/MavenNet.cs", + "memberOf": [ + "default" + ], + "tool": "policheck", + "ruleId": "79607", + "justification": "Reference to a proper name.", + "createdDate": "2023-10-26 21:20:54Z", + "expirationDate": null, + "type": null } } } diff --git a/build-tools/installers/create-installers.targets b/build-tools/installers/create-installers.targets index db38390c930..d8d433ff16f 100644 --- a/build-tools/installers/create-installers.targets +++ b/build-tools/installers/create-installers.targets @@ -110,6 +110,8 @@ <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)libZipSharp.dll" /> <_MSBuildFiles Include="@(_LocalizationLanguages->'$(MicrosoftAndroidSdkOutDir)%(Identity)\libZipSharp.resources.dll')" /> <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)libZipSharp.pdb" /> + <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)MavenNet.dll" /> + <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)MavenNet.pdb" /> <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Mono.Unix.dll" /> <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Mono.Unix.pdb" /> <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Microsoft.Android.Build.BaseTasks.dll" /> @@ -136,6 +138,7 @@ <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Xamarin.Android.Application.targets" /> <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Xamarin.Android.Bindings.ClassParse.targets" /> <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Xamarin.Android.Bindings.Core.targets" /> + <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Xamarin.Android.Bindings.Maven.targets" /> <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Xamarin.Android.Build.Tasks.dll" /> <_MSBuildFiles Include="$(MicrosoftAndroidSdkOutDir)Xamarin.Android.Build.Tasks.pdb" /> <_MSBuildFiles Include="@(_LocalizationLanguages->'$(MicrosoftAndroidSdkOutDir)%(Identity)\Microsoft.Android.Build.BaseTasks.resources.dll')" /> diff --git a/build-tools/xaprepare/xaprepare/ThirdPartyNotices/MavenNet.cs b/build-tools/xaprepare/xaprepare/ThirdPartyNotices/MavenNet.cs new file mode 100644 index 00000000000..a09a63de12f --- /dev/null +++ b/build-tools/xaprepare/xaprepare/ThirdPartyNotices/MavenNet.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace Xamarin.Android.Prepare +{ + [TPN] + class MavenNet_TPN : ThirdPartyNotice + { + static readonly Uri url = new Uri ("https://github.com/Redth/MavenNet/"); + + public override string LicenseFile => string.Empty; + public override string Name => "Redth/MavenNet"; + public override Uri SourceUrl => url; + public override string LicenseText => @" +MIT License + +Copyright (c) 2017 Jonathan Dick + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the ""Software""), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE."; + + public override bool Include (bool includeExternalDeps, bool includeBuildDeps) => includeExternalDeps; + } +} diff --git a/src/Xamarin.Android.Build.Tasks/MSBuild/Xamarin/Android/Xamarin.Android.Bindings.Maven.targets b/src/Xamarin.Android.Build.Tasks/MSBuild/Xamarin/Android/Xamarin.Android.Bindings.Maven.targets new file mode 100644 index 00000000000..e47e4b6bd93 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/MSBuild/Xamarin/Android/Xamarin.Android.Bindings.Maven.targets @@ -0,0 +1,39 @@ + + + + + + + + + $(LocalAppData)\dotnet-android\MavenCacheDirectory\ + $(HOME)/Library/Caches/dotnet-android/MavenCacheDirectory/ + $(HOME)/.cache/dotnet-android/MavenCacheDirectory/ + $([MSBuild]::EnsureTrailingSlash('$(MavenCacheDirectory)')) + + + + + + + + + + + + + + + + + diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets index 662aff0130b..95063269a7b 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.After.targets @@ -17,6 +17,7 @@ This file is imported *after* the Microsoft.NET.Sdk/Sdk.targets. + diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs index 351165d04d7..525498f9096 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.Designer.cs @@ -1123,7 +1123,65 @@ public static string XA4233 { return ResourceManager.GetString("XA4233", resourceCulture); } } - + + /// + /// Looks up a localized string similar to '<{0}>' item '{1}' is missing required metadata '{2}'. + /// + public static string XA4234 { + get { + return ResourceManager.GetString("XA4234", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Maven artifact specification '{0}' is invalid. The correct format is 'group_id:artifact_id'.. + /// + public static string XA4235 { + get { + return ResourceManager.GetString("XA4235", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot download Maven artifact '{0}:{1}'. + ///- {2}: {3} + ///- {4}: {5}. + /// + public static string XA4236 { + get { + return ResourceManager.GetString("XA4236", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot download POM file for Maven artifact '{0}:{1}'. + ///- {2}: {3}. + /// + public static string XA4237 { + get { + return ResourceManager.GetString("XA4237", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Cannot download parent POM file for Maven artifact '{0}:{1}'. + ///- {2}: {3}. + /// + public static string XA4238 { + get { + return ResourceManager.GetString("XA4238", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unknown Maven repository: '{0}'.. + /// + public static string XA4239 { + get { + return ResourceManager.GetString("XA4239", resourceCulture); + } + } + /// /// Looks up a localized string similar to Native library '{0}' will not be bundled because it has an unsupported ABI. Move this file to a directory with a valid Android ABI name such as 'libs/armeabi-v7a/'.. /// diff --git a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx index f6647ba8e92..5eeed4389f3 100644 --- a/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx +++ b/src/Xamarin.Android.Build.Tasks/Properties/Resources.resx @@ -949,4 +949,51 @@ To use a custom JDK path for a command line build, set the 'JavaSdkDirectory' MS {0} - An Android Resource Identifier. - + + '<{0}>' item '{1}' is missing required metadata '{2}' + {0} - The MSBuild ItemGroup Item name +{1} - The MSBuild Item ItemSpec +{2} - The omitted MSBuild Item metadata attribute + + + Maven artifact specification '{0}' is invalid. The correct format is 'group_id:artifact_id'. + The following are literal names and should not be translated: Maven, group_id, artifact_id +{0} - A Maven artifact specification + + + Cannot download Maven artifact '{0}:{1}'. +- {2}: {3} +- {4}: {5} + The following are literal names and should not be translated: Maven +{0} - Maven artifact group id +{1} - Maven artifact id +{2} - The .jar filename we tried to download +{3} - The HttpClient reported download exception message +{4} - The .aar filename we tried to download +{5} - The HttpClient provided download exception message + + + Cannot download POM file for Maven artifact '{0}:{1}'. +- {2}: {3} + The following are literal names and should not be translated: POM, Maven +{0} - Maven artifact group id +{1} - Maven artifact id +{2} - The .pom filename we tried to download +{3} - The HttpClient reported download exception message + + + + Cannot download parent POM file for Maven artifact '{0}:{1}'. +- {2}: {3} + The following are literal names and should not be translated: POM, Maven +{0} - Maven artifact group id +{1} - Maven artifact id +{2} - The .pom filename we tried to download +{3} - The HttpClient reported download exception message + + + Unknown Maven repository: '{0}'. + The following are literal names and should not be translated: Maven +{0} - User supplied Maven repository type + + \ No newline at end of file diff --git a/src/Xamarin.Android.Build.Tasks/Tasks/MavenDownload.cs b/src/Xamarin.Android.Build.Tasks/Tasks/MavenDownload.cs new file mode 100644 index 00000000000..1a0928d1552 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tasks/MavenDownload.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MavenNet; +using MavenNet.Models; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tasks; + +public class MavenDownload : AndroidAsyncTask +{ + public override string TaskPrefix => "MDT"; + + /// + /// The cache directory to use for Maven artifacts. + /// + [Required] + public string MavenCacheDirectory { get; set; } = null!; // NRT enforced by [Required] + + /// + /// The set of Maven libraries that we are being asked to acquire. + /// + public ITaskItem []? AndroidMavenLibraries { get; set; } + + /// + /// The set of requested Maven libraries that we were able to successfully acquire. + /// + [Output] + public ITaskItem []? ResolvedAndroidMavenLibraries { get; set; } + + public async override System.Threading.Tasks.Task RunTaskAsync () + { + var resolved = new List (); + + // Note each called function is responsible for raising any errors it encounters to the user + foreach (var library in AndroidMavenLibraries.OrEmpty ()) { + + // Validate artifact + var id = library.ItemSpec; + var version = library.GetRequiredMetadata ("AndroidMavenLibrary", "Version", Log); + + if (version is null) + continue; + + var artifact = MavenExtensions.ParseArtifact (id, version, Log); + + if (artifact is null) + continue; + + // Check for repository files + if (await GetRepositoryArtifactOrDefault (artifact, library, Log) is TaskItem result) { + library.CopyMetadataTo (result); + resolved.Add (result); + continue; + } + } + + ResolvedAndroidMavenLibraries = resolved.ToArray (); + } + + async System.Threading.Tasks.Task GetRepositoryArtifactOrDefault (Artifact artifact, ITaskItem item, TaskLoggingHelper log) + { + // Handles a Repository="Central|Google|" entry, like: + // + // Note if Repository is not specifed, it is defaulted to "Central" + + // Initialize repo + var repository = GetRepository (item); + + if (repository is null) + return null; + + artifact.Repository = repository; + + // Download artifact + var artifact_file = await MavenExtensions.DownloadPayload (artifact, MavenCacheDirectory, Log, CancellationToken); + + if (artifact_file is null) + return null; + + // Download POM + var pom_file = await MavenExtensions.DownloadPom (artifact, MavenCacheDirectory, Log, CancellationToken); + + if (pom_file is null) + return null; + + var result = new TaskItem (artifact_file); + + result.SetMetadata ("ArtifactSpec", item.ItemSpec); + result.SetMetadata ("ArtifactFile", artifact_file); + result.SetMetadata ("ArtifactPom", pom_file); + + return result; + } + + async System.Threading.Tasks.Task TryGetParentPom (ITaskItem item, TaskLoggingHelper log) + { + var child_pom_file = item.GetRequiredMetadata ("AndroidMavenLibrary", "ArtifactPom", Log); + + // Shouldn't be possible because we just created this items + if (child_pom_file is null) + return null; + + // No parent POM needed + if (!(MavenExtensions.CheckForNeededParentPom (child_pom_file) is Artifact artifact)) + return null; + + // Initialize repo (parent will be in same repository as child) + var repository = GetRepository (item); + + if (repository is null) + return null; + + artifact.Repository = repository; + + // Download POM + var pom_file = await MavenExtensions.DownloadPom (artifact, MavenCacheDirectory, Log, CancellationToken); + + if (pom_file is null) + return null; + + var result = new TaskItem ($"{artifact.GroupId}:{artifact.Id}"); + + result.SetMetadata ("Version", artifact.Versions.FirstOrDefault ()); + result.SetMetadata ("ArtifactPom", pom_file); + + // Copy repository data + item.CopyMetadataTo (result); + + return result; + } + + MavenRepository? GetRepository (ITaskItem item) + { + var type = item.GetMetadataOrDefault ("Repository", "Central"); + + var repo = type.ToLowerInvariant () switch { + "central" => MavenRepository.FromMavenCentral (), + "google" => MavenRepository.FromGoogle (), + _ => (MavenRepository?) null + }; + + if (repo is null && type.StartsWith ("http", StringComparison.OrdinalIgnoreCase)) + repo = MavenRepository.FromUrl (type); + + if (repo is null) + Log.LogCodedError ("XA4239", Properties.Resources.XA4239, type); + + return repo; + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BindingBuildTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BindingBuildTest.cs index a38d5a8299f..860c845eb67 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BindingBuildTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BindingBuildTest.cs @@ -795,5 +795,25 @@ public void AarWithRClassesJar () Assert.IsTrue (appBuilder.Build (app), "App build should have succeeded."); } } + + [Test] + public void AndroidMavenLibrary () + { + // Test that downloads .jar from Maven and successfully binds it + var item = new BuildItem ("AndroidMavenLibrary", "com.google.auto.value:auto-value-annotations"); + item.Metadata.Add ("Version", "1.10.4"); + + var proj = new XamarinAndroidBindingProject { + Jars = { item } + }; + + using (var b = CreateDllBuilder ()) { + Assert.IsTrue (b.Build (proj), "Build should have succeeded."); + + // Ensure the generated file exists + var cs_file = b.Output.GetIntermediaryPath (Path.Combine ("generated", "src", "Com.Google.Auto.Value.AutoValueAttribute.cs")); + FileAssert.Exists (cs_file); + } + } } } diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/MavenDownloadTests.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/MavenDownloadTests.cs new file mode 100644 index 00000000000..5d9c6bda151 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/Tasks/MavenDownloadTests.cs @@ -0,0 +1,199 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using NUnit.Framework; +using Xamarin.Android.Tasks; +using Task = System.Threading.Tasks.Task; +namespace Xamarin.Android.Build.Tests; + +public class MavenDownloadTests +{ + [Test] + public async Task MissingVersionMetadata () + { + var engine = new MockBuildEngine (TestContext.Out, new List ()); + var task = new MavenDownload { + BuildEngine = engine, + AndroidMavenLibraries = [CreateMavenTaskItem ("com.google.android.material:material", null)], + }; + + await task.RunTaskAsync (); + + Assert.AreEqual (1, engine.Errors.Count); + Assert.AreEqual ("'' item 'com.google.android.material:material' is missing required metadata 'Version'", engine.Errors [0].Message); + } + + [Test] + public async Task InvalidArtifactSpecification_WrongNumberOfParts () + { + var engine = new MockBuildEngine (TestContext.Out, new List ()); + var task = new MavenDownload { + BuildEngine = engine, + AndroidMavenLibraries = [CreateMavenTaskItem ("com.google.android.material", "1.0.0")], + }; + + await task.RunTaskAsync (); + + Assert.AreEqual (1, engine.Errors.Count); + Assert.AreEqual ("Maven artifact specification 'com.google.android.material' is invalid. The correct format is 'group_id:artifact_id'.", engine.Errors [0].Message); + } + + [Test] + public async Task InvalidArtifactSpecification_EmptyPart () + { + var engine = new MockBuildEngine (TestContext.Out, new List ()); + var task = new MavenDownload { + BuildEngine = engine, + AndroidMavenLibraries = [CreateMavenTaskItem ("com.google.android.material: ", "1.0.0")], + }; + + await task.RunTaskAsync (); + + Assert.AreEqual (1, engine.Errors.Count); + Assert.AreEqual ("Maven artifact specification 'com.google.android.material: ' is invalid. The correct format is 'group_id:artifact_id'.", engine.Errors [0].Message); + } + + [Test] + public async Task UnknownRepository () + { + var engine = new MockBuildEngine (TestContext.Out, new List ()); + var task = new MavenDownload { + BuildEngine = engine, + AndroidMavenLibraries = [CreateMavenTaskItem ("com.google.android.material:material", "1.0.0", "bad-repo")], + }; + + await task.RunTaskAsync (); + + Assert.AreEqual (1, engine.Errors.Count); + Assert.AreEqual ("Unknown Maven repository: 'bad-repo'.", engine.Errors [0].Message); + } + + [Test] + public async Task UnknownArtifact () + { + var engine = new MockBuildEngine (TestContext.Out, new List ()); + var task = new MavenDownload { + BuildEngine = engine, + MavenCacheDirectory = Path.GetTempPath (), + AndroidMavenLibraries = [CreateMavenTaskItem ("com.example:dummy", "1.0.0")], + }; + + await task.RunTaskAsync (); + + Assert.AreEqual (1, engine.Errors.Count); + Assert.AreEqual ($"Cannot download Maven artifact 'com.example:dummy'.{Environment.NewLine}- com.example_dummy.jar: Response status code does not indicate success: 404 (Not Found).{Environment.NewLine}- com.example_dummy.aar: Response status code does not indicate success: 404 (Not Found).", engine.Errors [0].Message.ReplaceLineEndings ()); + } + + [Test] + public async Task UnknownPom () + { + var temp_cache_dir = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ()); + + try { + var engine = new MockBuildEngine (TestContext.Out, new List ()); + var task = new MavenDownload { + BuildEngine = engine, + MavenCacheDirectory = temp_cache_dir, + AndroidMavenLibraries = [CreateMavenTaskItem ("com.example:dummy", "1.0.0")], + }; + + // Create the dummy jar so we bypass that step and try to download the dummy pom + var dummy_jar = Path.Combine (temp_cache_dir, "central", "com.example", "dummy", "1.0.0", "com.example_dummy.jar"); + Directory.CreateDirectory (Path.GetDirectoryName (dummy_jar)); + + using (File.Create (dummy_jar)) { } + + await task.RunTaskAsync (); + + Assert.AreEqual (1, engine.Errors.Count); + Assert.AreEqual ($"Cannot download POM file for Maven artifact 'com.example:dummy'.{Environment.NewLine}- com.example_dummy.pom: Response status code does not indicate success: 404 (Not Found).", engine.Errors [0].Message.ReplaceLineEndings ()); + } finally { + DeleteTempDirectory (temp_cache_dir); + } + } + + [Test] + public async Task MavenCentralSuccess () + { + var temp_cache_dir = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ()); + + try { + var engine = new MockBuildEngine (TestContext.Out, new List ()); + var task = new MavenDownload { + BuildEngine = engine, + MavenCacheDirectory = temp_cache_dir, + AndroidMavenLibraries = [CreateMavenTaskItem ("com.google.auto.value:auto-value-annotations", "1.10.4")], + }; + + await task.RunTaskAsync (); + + Assert.AreEqual (0, engine.Errors.Count); + Assert.AreEqual (1, task.ResolvedAndroidMavenLibraries.Length); + + var output_item = task.ResolvedAndroidMavenLibraries [0]; + + Assert.AreEqual ("com.google.auto.value:auto-value-annotations", output_item.GetMetadata ("ArtifactSpec")); + Assert.AreEqual (Path.Combine (temp_cache_dir, "central", "com.google.auto.value", "auto-value-annotations", "1.10.4", "com.google.auto.value_auto-value-annotations.jar"), output_item.GetMetadata ("ArtifactFile")); + Assert.AreEqual (Path.Combine (temp_cache_dir, "central", "com.google.auto.value", "auto-value-annotations", "1.10.4", "com.google.auto.value_auto-value-annotations.pom"), output_item.GetMetadata ("ArtifactPom")); + + Assert.True (File.Exists (output_item.GetMetadata ("ArtifactFile"))); + Assert.True (File.Exists (output_item.GetMetadata ("ArtifactPom"))); + } finally { + DeleteTempDirectory (temp_cache_dir); + } + } + + [Test] + public async Task MavenGoogleSuccess () + { + var temp_cache_dir = Path.Combine (Path.GetTempPath (), Guid.NewGuid ().ToString ()); + + try { + var engine = new MockBuildEngine (TestContext.Out, new List ()); + var task = new MavenDownload { + BuildEngine = engine, + MavenCacheDirectory = temp_cache_dir, + AndroidMavenLibraries = [CreateMavenTaskItem ("androidx.core:core", "1.12.0", "Google")], + }; + + await task.RunTaskAsync (); + + Assert.AreEqual (0, engine.Errors.Count); + Assert.AreEqual (1, task.ResolvedAndroidMavenLibraries.Length); + + var output_item = task.ResolvedAndroidMavenLibraries [0]; + + Assert.AreEqual ("androidx.core:core", output_item.GetMetadata ("ArtifactSpec")); + Assert.AreEqual (Path.Combine (temp_cache_dir, "google", "androidx.core", "core", "1.12.0", "androidx.core_core.aar"), output_item.GetMetadata ("ArtifactFile")); + Assert.AreEqual (Path.Combine (temp_cache_dir, "google", "androidx.core", "core", "1.12.0", "androidx.core_core.pom"), output_item.GetMetadata ("ArtifactPom")); + + Assert.True (File.Exists (output_item.GetMetadata ("ArtifactFile"))); + Assert.True (File.Exists (output_item.GetMetadata ("ArtifactPom"))); + } finally { + DeleteTempDirectory (temp_cache_dir); + } + } + + ITaskItem CreateMavenTaskItem (string name, string version, string repository = null) + { + var item = new TaskItem (name); + + if (version is not null) + item.SetMetadata ("Version", version); + if (repository is not null) + item.SetMetadata ("Repository", repository); + + return item; + } + + void DeleteTempDirectory (string dir) + { + try { + Directory.Delete (dir, true); + } catch { + // Ignore any cleanup failure + } + } +} diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/ITaskItemExtensions.cs b/src/Xamarin.Android.Build.Tasks/Utilities/ITaskItemExtensions.cs index c40a60e9bfc..f91af2d232f 100644 --- a/src/Xamarin.Android.Build.Tasks/Utilities/ITaskItemExtensions.cs +++ b/src/Xamarin.Android.Build.Tasks/Utilities/ITaskItemExtensions.cs @@ -1,7 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Xml.Linq; +using Microsoft.Android.Build.Tasks; using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; namespace Xamarin.Android.Tasks { @@ -19,5 +22,30 @@ public static IEnumerable ToXElements (this ICollection ite yield return e; } } + + public static string GetMetadataOrDefault (this ITaskItem item, string name, string defaultValue) + { + var value = item.GetMetadata (name); + + if (string.IsNullOrWhiteSpace (value)) + return defaultValue; + + return value; + } + + public static string? GetRequiredMetadata (this ITaskItem item, string itemName, string name, TaskLoggingHelper log) + { + var value = item.GetMetadata (name); + + if (string.IsNullOrWhiteSpace (value)) { + log.LogCodedError ("XA4234", Properties.Resources.XA4234, itemName, item.ToString (), name); + return null; + } + + return value; + } + + public static bool HasMetadata (this ITaskItem item, string name) + => item.MetadataNames.OfType ().Contains (name, StringComparer.OrdinalIgnoreCase); } } diff --git a/src/Xamarin.Android.Build.Tasks/Utilities/MavenExtensions.cs b/src/Xamarin.Android.Build.Tasks/Utilities/MavenExtensions.cs new file mode 100644 index 00000000000..4fc7f031833 --- /dev/null +++ b/src/Xamarin.Android.Build.Tasks/Utilities/MavenExtensions.cs @@ -0,0 +1,228 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; +using System.Xml.Serialization; +using MavenNet; +using MavenNet.Models; +using Microsoft.Android.Build.Tasks; +using Microsoft.Build.Utilities; + +namespace Xamarin.Android.Tasks; + +static class MavenExtensions +{ + static readonly char [] separator = [':']; + static XmlSerializer pom_serializer = new XmlSerializer (typeof (Project)); + + /// + /// Shortcut for !string.IsNullOrWhiteSpace (s) + /// + public static bool HasValue ([NotNullWhen (true)] this string? s) => !string.IsNullOrWhiteSpace (s); + + // Helps to 'foreach' into a possibly null array + public static T [] OrEmpty (this T []? value) + { + return value ?? Array.Empty (); + } + + public static Artifact? ParseArtifact (string id, string version, TaskLoggingHelper log) + { + var parts = id.Split (separator, StringSplitOptions.RemoveEmptyEntries); + + if (parts.Length != 2 || parts.Any (string.IsNullOrWhiteSpace)) { + log.LogCodedError ("XA4235", Properties.Resources.XA4235, id); + return null; + } + + var artifact = new Artifact (parts [1], parts [0], version); + + return artifact; + } + + public static Project ParsePom (string pomFile) + { + Project result = null; + + using (var sr = File.OpenRead (pomFile)) + result = (Project) pom_serializer.Deserialize (new XmlTextReader (sr) { + Namespaces = false, + }); + + return result; + } + + public static Artifact? CheckForNeededParentPom (string pomFile) + => ParsePom (pomFile).GetParentPom (); + + public static Artifact? GetParentPom (this Project? pom) + { + if (pom?.Parent != null) + return new Artifact (pom.Parent.ArtifactId, pom.Parent.GroupId, pom.Parent.Version); + + return null; + } + + // Returns artifact output path + public static async Task DownloadPayload (Artifact artifact, string cacheDir, TaskLoggingHelper log, CancellationToken cancellationToken) + { + var version = artifact.Versions.First (); + + var output_directory = Path.Combine (cacheDir, artifact.GetRepositoryCacheName (), artifact.GroupId, artifact.Id, version); + + Directory.CreateDirectory (output_directory); + + var filename = $"{artifact.GroupId}_{artifact.Id}"; + var jar_filename = Path.Combine (output_directory, Path.Combine ($"{filename}.jar")); + var aar_filename = Path.Combine (output_directory, Path.Combine ($"{filename}.aar")); + + // We don't need to redownload if we already have a cached copy + if (File.Exists (jar_filename)) + return jar_filename; + + if (File.Exists (aar_filename)) + return aar_filename; + + if (await TryDownloadPayload (artifact, jar_filename, cancellationToken) is not string jar_error) + return jar_filename; + + if (await TryDownloadPayload (artifact, aar_filename, cancellationToken) is not string aar_error) + return aar_filename; + + log.LogCodedError ("XA4236", Properties.Resources.XA4236, artifact.GroupId, artifact.Id, Path.GetFileName (jar_filename), jar_error, Path.GetFileName (aar_filename), aar_error); + + return null; + } + + // Returns artifact output path + public static async Task DownloadPom (Artifact artifact, string cacheDir, TaskLoggingHelper log, CancellationToken cancellationToken, bool isParent = false) + { + var version = artifact.Versions.First (); + var output_directory = Path.Combine (cacheDir, artifact.GetRepositoryCacheName (), artifact.GroupId, artifact.Id, version); + + Directory.CreateDirectory (output_directory); + + var filename = $"{artifact.GroupId}_{artifact.Id}"; + var pom_filename = Path.Combine (output_directory, Path.Combine ($"{filename}.pom")); + + // We don't need to redownload if we already have a cached copy + if (File.Exists (pom_filename)) + return pom_filename; + + if (await TryDownloadPayload (artifact, pom_filename, cancellationToken) is not string pom_error) + return pom_filename; + + if (!isParent) + log.LogCodedError ("XA4237", Properties.Resources.XA4237, artifact.GroupId, artifact.Id, Path.GetFileName (pom_filename), pom_error); + else + log.LogCodedError ("XA4238", Properties.Resources.XA4238, artifact.GroupId, artifact.Id, Path.GetFileName (pom_filename), pom_error); + + return null; + } + + // Return value indicates download success + static async Task TryDownloadPayload (Artifact artifact, string filename, CancellationToken cancellationToken) + { + try { + using var src = await artifact.OpenLibraryFile (artifact.Versions.First (), Path.GetExtension (filename)); + using var sw = File.Create (filename); + + await src.CopyToAsync (sw, 81920, cancellationToken); + + return null; + } catch (Exception ex) { + return ex.Message; + } + } + + public static string GetRepositoryCacheName (this Artifact artifact) + { + var type = artifact.Repository; + + if (type is MavenCentralRepository) + return "central"; + + if (type is GoogleMavenRepository) + return "google"; + + if (type is UrlMavenRepository url) { + using var hasher = SHA256.Create (); + var hash = hasher.ComputeHash (Encoding.UTF8.GetBytes (url.BaseUri.ToString ())); + return Convert.ToBase64String (hash); + } + + // Should never be hit + throw new ArgumentException ($"Unexpected repository type: {type.GetType ()}"); + } + + public static void FixDependency (Project project, Project? parent, Dependency dependency) + { + // Handle Parent POM + if ((string.IsNullOrEmpty (dependency.Version) || string.IsNullOrEmpty (dependency.Scope)) && parent != null) { + var parent_dependency = parent.FindParentDependency (dependency); + + // Try to fish a version out of the parent POM + if (string.IsNullOrEmpty (dependency.Version)) + dependency.Version = ReplaceVersionProperties (parent, parent_dependency?.Version); + + // Try to fish a scope out of the parent POM + if (string.IsNullOrEmpty (dependency.Scope)) + dependency.Scope = parent_dependency?.Scope; + } + + var version = dependency.Version; + + if (string.IsNullOrWhiteSpace (version)) + return; + + version = ReplaceVersionProperties (project, version); + + // VersionRange.Parse cannot handle single number versions that we sometimes see in Maven, like "1". + // Fix them to be "1.0". + // https://github.com/NuGet/Home/issues/10342 + if (version != null && !version.Contains (".")) + version += ".0"; + + dependency.Version = version; + } + + static string? ReplaceVersionProperties (Project project, string? version) + { + // Handle versions with Properties, like: + // + // 1.8 + // 2.8.6 + // + // + // + // com.google.code.gson + // gson + // ${gson.version} + // + // + if (string.IsNullOrWhiteSpace (version) || project?.Properties == null) + return version; + + foreach (var prop in project.Properties.Any) + version = version?.Replace ($"${{{prop.Name.LocalName}}}", prop.Value); + + return version; + } + + public static bool IsCompileDependency (this Dependency dependency) => string.IsNullOrWhiteSpace (dependency.Scope) || dependency.Scope.IndexOf ("compile", StringComparison.OrdinalIgnoreCase) != -1; + + public static bool IsRuntimeDependency (this Dependency dependency) => dependency?.Scope != null && dependency.Scope.IndexOf ("runtime", StringComparison.OrdinalIgnoreCase) != -1; + + public static Dependency? FindParentDependency (this Project project, Dependency dependency) + { + return project.DependencyManagement?.Dependencies?.FirstOrDefault ( + d => d.GroupAndArtifactId () == dependency.GroupAndArtifactId () && d.Classifier != "sources"); + } + + public static string GroupAndArtifactId (this Dependency dependency) => $"{dependency.GroupId}.{dependency.ArtifactId}"; +} diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj index 92132281396..19c8ec47ff9 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.csproj @@ -34,6 +34,7 @@ + diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.targets index fbd0fd842c7..177f45a22a0 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Build.Tasks.targets @@ -55,6 +55,11 @@ PreserveNewest Xamarin.Android.Bindings.JarToXml.targets + + PreserveNewest + Xamarin.Android.Bindings.Maven.targets + PreserveNewest @@ -304,6 +309,8 @@ <_ExtraPackageTarget Include="$(OutputPath)\Xamarin.Build.AsyncTask.pdb" /> <_ExtraPackageSource Include="$(PkgMono_Unix)\lib\$(TargetFrameworkNETStandard)\Mono.Unix.pdb" /> <_ExtraPackageTarget Include="$(OutputPath)\Mono.Unix.pdb" /> + <_ExtraPackageSource Include="$(PkgMavenNet)\lib\netstandard2.0\MavenNet.pdb" /> + <_ExtraPackageTarget Include="$(OutputPath)\MavenNet.pdb" /> <_ExtraPackageSource Include="$(MSBuildThisFileDirectory)\Resources\Mono.Unix.dll.config" /> <_ExtraPackageTarget Include="$(OutputPath)\Mono.Unix.dll.config" />