diff --git a/Documentation/docs-mobile/building-apps/build-items.md b/Documentation/docs-mobile/building-apps/build-items.md index 46bd157c07b..d1454f60396 100644 --- a/Documentation/docs-mobile/building-apps/build-items.md +++ b/Documentation/docs-mobile/building-apps/build-items.md @@ -15,6 +15,44 @@ an [MSBuild ItemGroup](/visualstudio/msbuild/itemgroup-element-msbuild). > [!NOTE] > In .NET for Android there is technically no distinction between an application and a bindings project, so build items will work in both. In practice it is highly recommended to create separate application and bindings projects. Build items that are primarily used in bindings projects are documented in the [MSBuild bindings project items](../binding-libs/msbuild-reference/build-items.md) reference guide. +## ApplicationArtifact + +`@(ApplicationArtifact)` contains the final application artifact files produced +by package, signing, and publish targets. This item group can be used by +custom MSBuild targets to discover APK and Android App Bundle outputs without +recalculating the final file names. .NET for Android populates this item group +with Android-specific artifacts, and other .NET mobile platforms can use the +same item name for their final application artifacts. + +Each item includes the following metadata: + +- `%(PackageFormat)`: `apk` or `aab`. +- `%(Signed)`: `true` when the package is signed. +- `%(PackageId)`: The resolved Android package name. +- `%(Abi)`: The Android ABI for a per-ABI APK output. This metadata is only + set for per-ABI APKs. + +MSBuild also provides well-known metadata for each item. For example, +`%(Filename)%(Extension)` is the package file name and `%(FullPath)` is the +full package path. + +Use the [`GetApplicationArtifacts`](build-targets.md#getapplicationartifacts) +target when another target needs to query the application artifacts directly. +Targets appended to `$(GetApplicationArtifactsDependsOn)` run after .NET for +Android populates this item group, so they can update the existing items with +additional metadata before `GetApplicationArtifacts` or `Publish` returns them. + +For example: + +```xml + + + +``` + ## AndroidAdditionalJavaManifest `` is used in conjunction with diff --git a/Documentation/docs-mobile/building-apps/build-targets.md b/Documentation/docs-mobile/building-apps/build-targets.md index bd43e2dbc7f..4d342fab9d5 100644 --- a/Documentation/docs-mobile/building-apps/build-targets.md +++ b/Documentation/docs-mobile/building-apps/build-targets.md @@ -96,6 +96,33 @@ Creates the `@(AndroidDependency)` item group, which is used by the [`InstallAndroidDependencies`](#installandroiddependencies) target to determine which Android SDK packages to install. +## GetApplicationArtifacts + +Creates and returns the +[`@(ApplicationArtifact)`](build-items.md#applicationartifact) item group, +which contains the APK and Android App Bundle files produced by the build. + +This target always depends on the required `Build` target, which produces and +collects platform artifacts into `@(ApplicationArtifact)`. Later imports can set +or append targets to `$(GetApplicationArtifactsDependsOn)` to update those +existing items with additional metadata before this target or the `Publish` +target returns them. Replacing `$(GetApplicationArtifactsDependsOn)` does not +remove the required `Build` dependency. + +Call this target directly when a CI job or custom tool needs the build output +artifact paths: + +```shell +dotnet build MyApp.csproj -t:GetApplicationArtifacts -getTargetResult:GetApplicationArtifacts +``` + +Use the `Publish` target result when the caller needs the copied publish +outputs in `$(PublishDir)`: + +```shell +dotnet build MyApp.csproj -t:Publish -getTargetResult:Publish +``` + ## Install [Creates, signs](#signandroidpackage), and installs the Android package onto @@ -135,6 +162,27 @@ MSBuild property controls which [Visual Studio SDK Manager repository](/xamarin/android/get-started/installation/android-sdk?tabs=windows#repository-selection) is used for package name and package version detection, and URLs to download. +## Publish + +Builds the application, copies final APK and Android App Bundle files to +`$(PublishDir)`, and returns the +[`@(ApplicationArtifact)`](build-items.md#applicationartifact) item group. +Returned items use the copied publish-directory paths and preserve artifact +metadata such as `%(PackageFormat)`, `%(Signed)`, `%(PackageId)`, and `%(Abi)`. + +`Publish` first runs `GetApplicationArtifacts`, which builds the project and +populates `@(ApplicationArtifact)` with the platform-produced artifacts. Targets +appended to `$(GetApplicationArtifactsDependsOn)` then run against those +existing items before `Publish` calculates publish files and before `Publish` +returns `@(ApplicationArtifact)`, so later imports can add metadata for publish +callers. + +For example, to query the published artifacts: + +```shell +dotnet build MyApp.csproj -t:Publish -getTargetResult:Publish +``` + ## RunWithLogging Runs the application with additional logging enabled. Helpful when reporting or investigating an issue with @@ -153,6 +201,9 @@ Creates and signs the Android package (`.apk`) file. Use with `/p:Configuration=Release` to generate self-contained "Release" packages. +Package files created by this target are available in the +[`@(ApplicationArtifact)`](build-items.md#applicationartifact) item group. + ## StartAndroidActivity Starts the default activity on the device or the running emulator. diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets index fe61bad5a88..bb9999a24f1 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.BuildOrder.targets @@ -51,6 +51,7 @@ properties that determine build ordering. _CopyPackage; _Sign; _CreateUniversalApkFromBundle; + _CollectApplicationArtifacts; _PrepareAssemblies; @@ -62,6 +63,7 @@ properties that determine build ordering. <_PackageForAndroidDependsOn> Build; _CopyPackage; + _CollectApplicationArtifacts; <_PrepareBuildApkDependsOnTargets> _SetLatestTargetFrameworkVersion; @@ -115,12 +117,14 @@ properties that determine build ordering. _CopyPackage; _Sign; _CreateUniversalApkFromBundle; + _CollectApplicationArtifacts; Build; Package; _Sign; _CreateUniversalApkFromBundle; + _CollectApplicationArtifacts; $(_MinimalSignAndroidPackageDependsOn); diff --git a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Publish.targets b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Publish.targets index 95dc9575032..60edce2ca09 100644 --- a/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Publish.targets +++ b/src/Xamarin.Android.Build.Tasks/Microsoft.Android.Sdk/targets/Microsoft.Android.Sdk.Publish.targets @@ -10,25 +10,46 @@ This file contains the implementation for 'dotnet publish'. <_PublishDependsOn> - Build; + GetApplicationArtifacts; PrepareForPublish; _CalculateAndroidFilesToPublish; CopyFilesToPublishDirectory; + _UpdateApplicationArtifactsForPublish; - + - <_AllPackageFormats Include="$(AndroidPackageFormat);$(AndroidPackageFormats)" /> - <_AndroidPackageFormats Include="@(_AllPackageFormats->Distinct())" /> - <_AndroidFilesToPublish Include="$(OutputPath)*.%(_AndroidPackageFormats.Identity)" /> <_AndroidFilesToPublish Include="$(AndroidProguardMappingFile)" Condition="Exists ('$(AndroidProguardMappingFile)')" /> <_AndroidFilesToPublish Include="$(_GenerateResourceDesignerAssemblyOutput)" Condition="Exists('$(_GenerateResourceDesignerAssemblyOutput)')" /> + + + + <_ApplicationArtifactForPublish Remove="@(_ApplicationArtifactForPublish)" /> + <_ApplicationArtifactPublishCopy Remove="@(_ApplicationArtifactPublishCopy)" /> + <_ApplicationArtifactForPublish Include="@(ApplicationArtifact)" /> + <_ApplicationArtifactPublishCopy + Include="@(_ApplicationArtifactForPublish->'$(PublishDir)%(Filename)%(Extension)')" + Condition="Exists('$(PublishDir)%(_ApplicationArtifactForPublish.Filename)%(_ApplicationArtifactForPublish.Extension)')"> + %(_ApplicationArtifactForPublish.PackageFormat) + %(_ApplicationArtifactForPublish.Signed) + %(_ApplicationArtifactForPublish.PackageId) + %(_ApplicationArtifactForPublish.Abi) + + + + <_ApplicationArtifactForPublish Remove="@(_ApplicationArtifactForPublish)" /> + <_ApplicationArtifactPublishCopy Remove="@(_ApplicationArtifactPublishCopy)" /> + + + diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest.cs index e78b7a7eaa2..0bdb220971e 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/BuildTest.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reflection; using System.Text; +using System.Text.Json; using System.Threading.Tasks; using System.Xml; using System.Xml.Linq; @@ -210,6 +211,137 @@ public void DotNetBuild (string runtimeIdentifiers, bool isRelease, bool aot, bo } } + [Test] + // target, isRelease, packageFormat, perAbi, withExtensionHook + [TestCase ("GetApplicationArtifacts", false, "apk", false, false)] + [TestCase ("Publish", false, "apk", false, false)] + [TestCase ("GetApplicationArtifacts", true, "aab", false, false)] + [TestCase ("GetApplicationArtifacts", false, "apk", true, false)] + [TestCase ("GetApplicationArtifacts", false, "apk", false, true)] + public void DotNetBuildReturnsApplicationArtifacts (string target, bool isRelease, string packageFormat, bool perAbi, bool withExtensionHook) + { + var proj = new XamarinAndroidApplicationProject { + IsRelease = isRelease, + EnableDefaultItems = true, + }; + proj.SetProperty ("AndroidPackageFormat", packageFormat); + if (packageFormat == "aab") { + // Disable fast deployment for AABs to avoid XA0119. + proj.EmbedAssembliesIntoApk = true; + } + if (perAbi) { + proj.SetRuntimeIdentifiers (new [] { "arm64-v8a", "x86_64" }); + proj.SetProperty ("AndroidCreatePackagePerAbi", "true"); + } + if (withExtensionHook) { + // Validate that $(GetApplicationArtifactsDependsOn) runs *after* _CreateApplicationArtifacts, + // so MAUI-style extension targets can enrich the items the platform already produced. + // If the order regresses, `Update` will have nothing to update and the metadata won't appear. + proj.Imports.Add (new Import (() => "ApplicationArtifacts.targets") { + TextContent = () => """ + + + $(GetApplicationArtifactsDependsOn);_AddExtensionArtifactMetadata + + + + + + + + +""" + }); + } + + using var builder = CreateDllBuilder (); + builder.Save (proj); + + var dotnet = new DotNetCLI (Path.Combine (Root, builder.ProjectDirectory, proj.ProjectFilePath)) { + Verbosity = "minimal", + }; + var msbuildArgs = new List { $"-getTargetResult:{target}" }; + if (isRelease) { + msbuildArgs.Add ("-c:Release"); + } + Assert.IsTrue ( + dotnet.Build (target: target, msbuildArguments: msbuildArgs.ToArray ()), + $"`dotnet build -t:{target} -getTargetResult:{target}` should succeed"); + + var items = ReadApplicationArtifactTargetResultItems (dotnet.ProcessLogFile, target); + var expectedMauiArtifact = withExtensionHook ? "true" : ""; + + if (packageFormat == "aab") { + // AAB produces: unsigned aab + signed aab + signed universal APK from the bundle. + Assert.AreEqual (3, items.Count, $"Actual items:{Environment.NewLine}{FormatApplicationArtifactTargetResultItems (items)}"); + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}.aab", "aab", "false", proj.PackageName, "", expectedMauiArtifact); + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}-Signed.aab", "aab", "true", proj.PackageName, "", expectedMauiArtifact); + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}-Signed.apk", "apk", "true", proj.PackageName, "", expectedMauiArtifact); + } else if (perAbi) { + // Per-ABI: main unsigned + main signed + per-ABI unsigned/signed for each requested ABI. + var abis = new [] { "arm64-v8a", "x86_64" }; + Assert.AreEqual (2 + abis.Length * 2, items.Count, $"Actual items:{Environment.NewLine}{FormatApplicationArtifactTargetResultItems (items)}"); + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}.apk", "apk", "false", proj.PackageName, "", expectedMauiArtifact); + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}-Signed.apk", "apk", "true", proj.PackageName, "", expectedMauiArtifact); + foreach (var abi in abis) { + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}-{abi}.apk", "apk", "false", proj.PackageName, abi, expectedMauiArtifact); + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}-{abi}-Signed.apk", "apk", "true", proj.PackageName, abi, expectedMauiArtifact); + } + } else { + Assert.AreEqual (2, items.Count, $"Actual items:{Environment.NewLine}{FormatApplicationArtifactTargetResultItems (items)}"); + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}.apk", "apk", "false", proj.PackageName, "", expectedMauiArtifact); + AssertApplicationArtifactTargetResultItem (items, $"{proj.PackageName}-Signed.apk", "apk", "true", proj.PackageName, "", expectedMauiArtifact); + } + } + + static List> ReadApplicationArtifactTargetResultItems (string processLogFile, string target) + { + var output = File.ReadAllText (processLogFile); + var jsonStart = output.IndexOf ('{'); + var jsonEnd = output.LastIndexOf ('}'); + Assert.GreaterOrEqual (jsonStart, 0, $"Could not find JSON target result in {processLogFile}.{Environment.NewLine}{output}"); + Assert.Greater (jsonEnd, jsonStart, $"Could not find complete JSON target result in {processLogFile}.{Environment.NewLine}{output}"); + + using var document = JsonDocument.Parse (output.Substring (jsonStart, jsonEnd - jsonStart + 1)); + var targetResult = document.RootElement + .GetProperty ("TargetResults") + .GetProperty (target); + Assert.AreEqual ("Success", targetResult.GetProperty ("Result").GetString (), $"Target {target} should succeed."); + + var items = new List> (); + foreach (var item in targetResult.GetProperty ("Items").EnumerateArray ()) { + var metadata = new Dictionary (StringComparer.Ordinal); + foreach (var property in item.EnumerateObject ()) { + metadata.Add (property.Name, property.Value.GetString () ?? ""); + } + items.Add (metadata); + } + return items; + } + + static void AssertApplicationArtifactTargetResultItem (List> items, string fileName, string packageFormat, string signed, string packageId, string abi, string mauiArtifact) + { + var matches = items.Where (item => + GetTargetResultMetadata (item, "Filename") + GetTargetResultMetadata (item, "Extension") == fileName && + GetTargetResultMetadata (item, "PackageFormat") == packageFormat && + GetTargetResultMetadata (item, "Signed") == signed && + GetTargetResultMetadata (item, "PackageId") == packageId && + GetTargetResultMetadata (item, "Abi") == abi && + GetTargetResultMetadata (item, "MauiArtifact") == mauiArtifact).ToList (); + Assert.AreEqual (1, matches.Count, $"Expected application artifact item '{fileName}|{packageFormat}|{signed}|{packageId}|{abi}|{mauiArtifact}'. Actual items:{Environment.NewLine}{FormatApplicationArtifactTargetResultItems (items)}"); + } + + static string GetTargetResultMetadata (Dictionary item, string name) + { + return item.TryGetValue (name, out var value) ? value : ""; + } + + static string FormatApplicationArtifactTargetResultItems (List> items) + { + return string.Join (Environment.NewLine, items.Select (item => + $"{GetTargetResultMetadata (item, "Identity")}|{GetTargetResultMetadata (item, "Filename")}{GetTargetResultMetadata (item, "Extension")}|{GetTargetResultMetadata (item, "PackageFormat")}|{GetTargetResultMetadata (item, "Signed")}|{GetTargetResultMetadata (item, "PackageId")}|{GetTargetResultMetadata (item, "Abi")}|{GetTargetResultMetadata (item, "MauiArtifact")}")); + } + static object [] MonoComponentMaskChecks () => new object [] { new object[] { true, // enableProfiler diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/PackagingTest.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/PackagingTest.cs index ea19d25ad0f..f5676ab3d04 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/PackagingTest.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.Android.Build.Tests/PackagingTest.cs @@ -569,7 +569,7 @@ public void CheckSignApk ([Values] bool useApkSigner, [Values] bool perAbiApk, [ if (runtime != AndroidRuntime.NativeAOT) { b.AssertHasNoWarnings (); } else { - StringAssertEx.Contains ("2 Warning(s)", b.LastBuildOutput, "NativeAOT should produce two IL3053 warnings"); + b.AssertHasAtMostWarnings (2); } //Make sure the APKs are signed diff --git a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs index 3f1c4c1bb57..de14abe6dbc 100644 --- a/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs +++ b/src/Xamarin.Android.Build.Tasks/Tests/Xamarin.ProjectTools/Common/DotNetCLI.cs @@ -116,27 +116,27 @@ public bool New (string template, string output = null) return Execute (arguments.ToArray ()); } - public bool Restore (string target = null, string runtimeIdentifier = null, string [] parameters = null) + public bool Restore (string target = null, string runtimeIdentifier = null, string [] parameters = null, string [] msbuildArguments = null) { - var arguments = GetDefaultCommandLineArgs ("restore", target, runtimeIdentifier, parameters); + var arguments = GetDefaultCommandLineArgs ("restore", target, runtimeIdentifier, parameters, msbuildArguments); return Execute (arguments.ToArray ()); } - public bool Build (string target = null, string runtimeIdentifier = null, string [] parameters = null) + public bool Build (string target = null, string runtimeIdentifier = null, string [] parameters = null, string [] msbuildArguments = null) { - var arguments = GetDefaultCommandLineArgs ("build", target, runtimeIdentifier, parameters); + var arguments = GetDefaultCommandLineArgs ("build", target, runtimeIdentifier, parameters, msbuildArguments); return Execute (arguments.ToArray ()); } - public bool Pack (string target = null, string runtimeIdentifier = null, string [] parameters = null) + public bool Pack (string target = null, string runtimeIdentifier = null, string [] parameters = null, string [] msbuildArguments = null) { - var arguments = GetDefaultCommandLineArgs ("pack", target, runtimeIdentifier, parameters); + var arguments = GetDefaultCommandLineArgs ("pack", target, runtimeIdentifier, parameters, msbuildArguments); return Execute (arguments.ToArray ()); } - public bool Publish (string target = null, string runtimeIdentifier = null, string [] parameters = null) + public bool Publish (string target = null, string runtimeIdentifier = null, string [] parameters = null, string [] msbuildArguments = null) { - var arguments = GetDefaultCommandLineArgs ("publish", target, runtimeIdentifier, parameters); + var arguments = GetDefaultCommandLineArgs ("publish", target, runtimeIdentifier, parameters, msbuildArguments); return Execute (arguments.ToArray ()); } @@ -236,7 +236,7 @@ public IEnumerable LastBuildOutput { public bool IsTargetSkipped (string target, bool defaultIfNotUsed = false) => BuildOutput.IsTargetSkipped (LastBuildOutput, target, defaultIfNotUsed); - List GetDefaultCommandLineArgs (string verb, string target = null, string runtimeIdentifier = null, string [] parameters = null) + List GetDefaultCommandLineArgs (string verb, string target = null, string runtimeIdentifier = null, string [] parameters = null, string [] msbuildArguments = null) { string testDir = string.IsNullOrEmpty (ProjectDirectory) ? Path.GetDirectoryName (projectOrSolution) : ProjectDirectory; if (string.IsNullOrEmpty (BuildLogFile)) @@ -269,6 +269,9 @@ List GetDefaultCommandLineArgs (string verb, string target = null, strin arguments.Add ($"/p:{parameter}"); } } + if (msbuildArguments != null) { + arguments.AddRange (msbuildArguments); + } if (!string.IsNullOrEmpty (runtimeIdentifier)) { // NOTE: that this one has to be -r, /r does not appear to work arguments.Add ("-r"); diff --git a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets index ea3cf49c141..0845413266d 100644 --- a/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets +++ b/src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets @@ -2678,6 +2678,55 @@ because xbuild doesn't support framework reference assemblies. + + + + + apk + false + + + apk + false + %(_BuildTargetAbis.Identity) + + + apk + true + + + apk + true + %(_BuildTargetAbis.Identity) + + + aab + false + + + aab + true + + + + + + +