Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
aa80d15
Expose Android package output items
Redth Jun 16, 2026
a311128
Trim Android package output item metadata
Redth Jun 16, 2026
0ca42b0
Simplify Android package output collection
Redth Jun 16, 2026
9a91ce2
Guard per-ABI package output collection
Redth Jun 16, 2026
222e91f
Add ABI metadata to package outputs
Redth Jun 16, 2026
d7e18c3
Add publish package item coverage
Redth Jun 16, 2026
9756adc
Rename application artifact item
Redth Jun 16, 2026
595668b
Align application artifact query target
Redth Jun 17, 2026
220e089
Strengthen application artifact extension tests
Redth Jun 17, 2026
8dd85f4
Run application artifact extensions during publish
Redth Jun 17, 2026
4afcab5
Document application artifact query targets
Redth Jun 17, 2026
0be3044
Clarify application artifact enrichment docs
Redth Jun 17, 2026
8accb87
Make application artifact build dependency mandatory
Redth Jun 17, 2026
a39b446
Use dotnet build in artifact query docs
Redth Jun 17, 2026
62fded7
Use transforms for artifact test output
Redth Jun 17, 2026
f0b7861
Use transforms for packaging artifact test output
Redth Jun 17, 2026
56bff99
Harden Android application artifacts target
Redth Jun 17, 2026
7af99da
Simplify application artifact target graph
Redth Jun 17, 2026
eef5cc8
Consolidate ApplicationArtifact tests into one parameterized test
jonathanpeppers Jun 17, 2026
b4450c4
Fix application artifact target result test
Redth Jun 26, 2026
89bef7c
Add warning count assertion helper
Redth Jul 2, 2026
e1c9393
Merge remote-tracking branch 'origin/main' into redth/android-publish…
Copilot Jul 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Documentation/docs-mobile/building-apps/build-items.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<Target Name="WriteApplicationArtifacts" AfterTargets="Publish">
<WriteLinesToFile
File="$(PublishDir)application-artifacts.txt"
Lines="@(ApplicationArtifact->'%(FullPath)|%(Filename)%(Extension)|%(PackageFormat)|%(Signed)|%(PackageId)|%(Abi)')"
Overwrite="true" />
</Target>
```

## AndroidAdditionalJavaManifest

`<AndroidAdditionalJavaManifest>` is used in conjunction with
Expand Down
51 changes: 51 additions & 0 deletions Documentation/docs-mobile/building-apps/build-targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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.

Comment thread
Redth marked this conversation as resolved.
## StartAndroidActivity

Starts the default activity on the device or the running emulator.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ properties that determine build ordering.
_CopyPackage;
_Sign;
_CreateUniversalApkFromBundle;
_CollectApplicationArtifacts;
</BuildDependsOn>
<IncrementalCleanDependsOn>
_PrepareAssemblies;
Expand All @@ -62,6 +63,7 @@ properties that determine build ordering.
<_PackageForAndroidDependsOn>
Build;
_CopyPackage;
_CollectApplicationArtifacts;
</_PackageForAndroidDependsOn>
<_PrepareBuildApkDependsOnTargets>
_SetLatestTargetFrameworkVersion;
Expand Down Expand Up @@ -115,12 +117,14 @@ properties that determine build ordering.
_CopyPackage;
_Sign;
_CreateUniversalApkFromBundle;
_CollectApplicationArtifacts;
</_MinimalSignAndroidPackageDependsOn>
<SignAndroidPackageDependsOn Condition=" '$(BuildingInsideVisualStudio)' != 'true' ">
Build;
Package;
_Sign;
_CreateUniversalApkFromBundle;
_CollectApplicationArtifacts;
</SignAndroidPackageDependsOn>
<SignAndroidPackageDependsOn Condition=" '$(BuildingInsideVisualStudio)' == 'true' ">
$(_MinimalSignAndroidPackageDependsOn);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,46 @@ This file contains the implementation for 'dotnet publish'.

<PropertyGroup>
<_PublishDependsOn>
Build;
GetApplicationArtifacts;
Comment thread
Redth marked this conversation as resolved.
PrepareForPublish;
_CalculateAndroidFilesToPublish;
CopyFilesToPublishDirectory;
_UpdateApplicationArtifactsForPublish;
</_PublishDependsOn>
</PropertyGroup>

<Target Name="Publish" DependsOnTargets="$(_PublishDependsOn)" />
<Target Name="Publish"
DependsOnTargets="$(_PublishDependsOn)"
Returns="@(ApplicationArtifact)" />

<Target Name="_CalculateAndroidFilesToPublish">
<ItemGroup>
<_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)')" />
<ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" />
<ResolvedFileToPublish Include="@(ApplicationArtifact)" RelativePath="%(Filename)%(Extension)" />
<ResolvedFileToPublish Include="@(_AndroidFilesToPublish)" RelativePath="%(FileName)%(Extension)" />
</ItemGroup>
</Target>

<Target Name="_UpdateApplicationArtifactsForPublish">
<ItemGroup>
<_ApplicationArtifactForPublish Remove="@(_ApplicationArtifactForPublish)" />
<_ApplicationArtifactPublishCopy Remove="@(_ApplicationArtifactPublishCopy)" />
<_ApplicationArtifactForPublish Include="@(ApplicationArtifact)" />
<_ApplicationArtifactPublishCopy
Include="@(_ApplicationArtifactForPublish->'$(PublishDir)%(Filename)%(Extension)')"
Condition="Exists('$(PublishDir)%(_ApplicationArtifactForPublish.Filename)%(_ApplicationArtifactForPublish.Extension)')">
<PackageFormat>%(_ApplicationArtifactForPublish.PackageFormat)</PackageFormat>
<Signed>%(_ApplicationArtifactForPublish.Signed)</Signed>
<PackageId>%(_ApplicationArtifactForPublish.PackageId)</PackageId>
<Abi Condition=" '%(_ApplicationArtifactForPublish.Abi)' != '' ">%(_ApplicationArtifactForPublish.Abi)</Abi>
</_ApplicationArtifactPublishCopy>
<ApplicationArtifact Remove="@(ApplicationArtifact)" />
<ApplicationArtifact Include="@(_ApplicationArtifactPublishCopy)" />
<_ApplicationArtifactForPublish Remove="@(_ApplicationArtifactForPublish)" />
<_ApplicationArtifactPublishCopy Remove="@(_ApplicationArtifactPublishCopy)" />
</ItemGroup>
</Target>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -210,6 +211,121 @@ public void DotNetBuild (string runtimeIdentifiers, bool isRelease, bool aot, bo
}
}

[Test]
// target, isRelease, packageFormat, withExtensionHook
[TestCase ("GetApplicationArtifacts", false, "apk", false)]
[TestCase ("Publish", false, "apk", false)]
[TestCase ("GetApplicationArtifacts", true, "aab", false)]
[TestCase ("GetApplicationArtifacts", false, "apk", true)]
public void DotNetBuildReturnsApplicationArtifacts (string target, bool isRelease, string packageFormat, 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 (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 = () => """
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup>
<GetApplicationArtifactsDependsOn>$(GetApplicationArtifactsDependsOn);_AddExtensionArtifactMetadata</GetApplicationArtifactsDependsOn>
</PropertyGroup>
<Target Name="_AddExtensionArtifactMetadata">
<Error Condition=" '@(ApplicationArtifact)' == '' " Text="Expected ApplicationArtifact items before extension metadata augmentation." />
<ItemGroup>
<ApplicationArtifact Update="@(ApplicationArtifact)" MauiArtifact="true" />
</ItemGroup>
</Target>
</Project>
"""
});
}

using var builder = CreateDllBuilder ();
builder.Save (proj);

var dotnet = new DotNetCLI (Path.Combine (Root, builder.ProjectDirectory, proj.ProjectFilePath)) {
Verbosity = "minimal",
};
var msbuildArgs = new List<string> { $"-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 {
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<Dictionary<string, string>> 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<Dictionary<string, string>> ();
foreach (var item in targetResult.GetProperty ("Items").EnumerateArray ()) {
var metadata = new Dictionary<string, string> (StringComparer.Ordinal);
foreach (var property in item.EnumerateObject ()) {
metadata.Add (property.Name, property.Value.GetString () ?? "");
}
items.Add (metadata);
}
return items;
}

static void AssertApplicationArtifactTargetResultItem (List<Dictionary<string, string>> 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<string, string> item, string name)
{
return item.TryGetValue (name, out var value) ? value : "";
}

static string FormatApplicationArtifactTargetResultItems (List<Dictionary<string, string>> 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")}"));
}


[Test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ());
}

Expand Down Expand Up @@ -236,7 +236,7 @@ public IEnumerable<string> LastBuildOutput {

public bool IsTargetSkipped (string target, bool defaultIfNotUsed = false) => BuildOutput.IsTargetSkipped (LastBuildOutput, target, defaultIfNotUsed);

List<string> GetDefaultCommandLineArgs (string verb, string target = null, string runtimeIdentifier = null, string [] parameters = null)
List<string> 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))
Expand Down Expand Up @@ -269,6 +269,9 @@ List<string> 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");
Expand Down
Loading
Loading