Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
23 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
73b90f5
Address PR review feedback
Copilot Jul 13, 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
30 changes: 30 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,36 @@ 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.

## AndroidPackageOutput

`@(AndroidPackageOutput)` contains the final Android package 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.

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.

For example:

```xml
<Target Name="WriteAndroidPackageOutputs" AfterTargets="Publish">
<WriteLinesToFile
File="$(PublishDir)android-package-outputs.txt"
Lines="@(AndroidPackageOutput->'%(FullPath)|%(Filename)%(Extension)|%(PackageFormat)|%(Signed)|%(PackageId)|%(Abi)')"
Overwrite="true" />
</Target>
```

## AndroidAdditionalJavaManifest

`<AndroidAdditionalJavaManifest>` is used in conjunction with
Expand Down
3 changes: 3 additions & 0 deletions Documentation/docs-mobile/building-apps/build-targets.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,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
[`@(AndroidPackageOutput)`](build-items.md#androidpackageoutput) 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;
_CollectAndroidPackageOutputs;
</BuildDependsOn>
<IncrementalCleanDependsOn>
_PrepareAssemblies;
Expand All @@ -62,6 +63,7 @@ properties that determine build ordering.
<_PackageForAndroidDependsOn>
Build;
_CopyPackage;
_CollectAndroidPackageOutputs;
</_PackageForAndroidDependsOn>
<_PrepareBuildApkDependsOnTargets>
_SetLatestTargetFrameworkVersion;
Expand Down Expand Up @@ -115,12 +117,14 @@ properties that determine build ordering.
_CopyPackage;
_Sign;
_CreateUniversalApkFromBundle;
_CollectAndroidPackageOutputs;
</_MinimalSignAndroidPackageDependsOn>
<SignAndroidPackageDependsOn Condition=" '$(BuildingInsideVisualStudio)' != 'true' ">
Build;
Package;
_Sign;
_CreateUniversalApkFromBundle;
_CollectAndroidPackageOutputs;
</SignAndroidPackageDependsOn>
<SignAndroidPackageDependsOn Condition=" '$(BuildingInsideVisualStudio)' == 'true' ">
$(_MinimalSignAndroidPackageDependsOn);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,43 @@ This file contains the implementation for 'dotnet publish'.
<_PublishDependsOn>
Build;
PrepareForPublish;
_CollectAndroidPackageOutputs;
_CalculateAndroidFilesToPublish;
CopyFilesToPublishDirectory;
_UpdateAndroidPackageOutputsForPublish;
</_PublishDependsOn>
</PropertyGroup>

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

<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="@(AndroidPackageOutput)" RelativePath="%(Filename)%(Extension)" />
<ResolvedFileToPublish Include="@(_AndroidFilesToPublish)" RelativePath="%(FileName)%(Extension)" />
</ItemGroup>
</Target>

<Target Name="_UpdateAndroidPackageOutputsForPublish">
<ItemGroup>
<_AndroidPackageOutputForPublish Remove="@(_AndroidPackageOutputForPublish)" />
<_AndroidPackageOutputPublishCopy Remove="@(_AndroidPackageOutputPublishCopy)" />
<_AndroidPackageOutputForPublish Include="@(AndroidPackageOutput)" />
<_AndroidPackageOutputPublishCopy
Include="@(_AndroidPackageOutputForPublish->'$(PublishDir)%(Filename)%(Extension)')"
Condition="Exists('$(PublishDir)%(_AndroidPackageOutputForPublish.Filename)%(_AndroidPackageOutputForPublish.Extension)')">
<PackageFormat>%(_AndroidPackageOutputForPublish.PackageFormat)</PackageFormat>
<Signed>%(_AndroidPackageOutputForPublish.Signed)</Signed>
<PackageId>%(_AndroidPackageOutputForPublish.PackageId)</PackageId>
<Abi Condition=" '%(_AndroidPackageOutputForPublish.Abi)' != '' ">%(_AndroidPackageOutputForPublish.Abi)</Abi>
</_AndroidPackageOutputPublishCopy>
<AndroidPackageOutput Remove="@(AndroidPackageOutput)" />
<AndroidPackageOutput Include="@(_AndroidPackageOutputPublishCopy)" />
<_AndroidPackageOutputForPublish Remove="@(_AndroidPackageOutputForPublish)" />
<_AndroidPackageOutputPublishCopy Remove="@(_AndroidPackageOutputPublishCopy)" />
</ItemGroup>
</Target>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -545,6 +545,20 @@ public void CheckSignApk ([Values] bool useApkSigner, [Values] bool perAbiApk, [
Assert.IsTrue (task.Execute (), "Task should have succeeded.");
var proj = new XamarinAndroidApplicationProject () {
IsRelease = isRelease,
Imports = {
new Import (() => "PackageOutputs.targets") {
TextContent = () => """
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Target Name="WriteAndroidPackageOutputItems" AfterTargets="Build">
<WriteLinesToFile
File="$(MSBuildProjectDirectory)/android-package-output-items.txt"
Lines="%(AndroidPackageOutput.Filename)%(AndroidPackageOutput.Extension)|%(AndroidPackageOutput.PackageFormat)|%(AndroidPackageOutput.Signed)|%(AndroidPackageOutput.PackageId)|%(AndroidPackageOutput.Abi)"
Overwrite="true" />
</Target>
</Project>
Comment thread
Redth marked this conversation as resolved.
Outdated
"""
},
}
};
proj.SetRuntime (runtime);
proj.SetProperty (proj.ReleaseProperties, "AndroidUseApkSigner", useApkSigner);
Expand Down Expand Up @@ -573,8 +587,13 @@ public void CheckSignApk ([Values] bool useApkSigner, [Values] bool perAbiApk, [
}

//Make sure the APKs are signed
var packageOutputItems = File.ReadAllLines (Path.Combine (Root, b.ProjectDirectory, "android-package-output-items.txt"));
foreach (var apk in Directory.GetFiles (bin, "*-Signed.apk")) {
AssertApkIsSigned (apk);
var fileName = Path.GetFileName (apk);
var abi = GetPackageOutputAbi (proj.PackageName, fileName);
var expectedItem = $"{fileName}|apk|true|{proj.PackageName}|{abi}";
Assert.IsTrue (packageOutputItems.Contains (expectedItem), $"Expected AndroidPackageOutput item '{expectedItem}'. Actual items:{Environment.NewLine}{string.Join (Environment.NewLine, packageOutputItems)}");
}

// Make sure the APKs have unique version codes
Expand Down Expand Up @@ -610,6 +629,15 @@ public void CheckSignApk ([Values] bool useApkSigner, [Values] bool perAbiApk, [
}
}

string GetPackageOutputAbi (string packageName, string fileName)
{
var prefix = $"{packageName}-";
const string suffix = "-Signed.apk";
if (fileName == $"{packageName}{suffix}" || !fileName.StartsWith (prefix, StringComparison.Ordinal) || !fileName.EndsWith (suffix, StringComparison.Ordinal))
return "";
return fileName.Substring (prefix.Length, fileName.Length - prefix.Length - suffix.Length);
}

int GetVersionCodeFromIntermediateManifest (string manifestFilePath)
{
var doc = XDocument.Load (manifestFilePath);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,31 @@ public void DotNetPublish ([Values] bool isRelease, [ValueSource (nameof(Get_Dot
EnableDefaultItems = true,
ExtraNuGetConfigSources = {
Path.Combine (XABuildPaths.BuildOutputDirectory, "nuget-unsigned"),
},
Imports = {
new Import (() => "PackageOutputs.targets") {
TextContent = () => """
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
Comment thread
Redth marked this conversation as resolved.
Outdated
<Target Name="WriteAndroidPackageOutputItems" AfterTargets="Publish">
<WriteLinesToFile
File="$(MSBuildProjectDirectory)/android-package-output-items.txt"
Lines="%(AndroidPackageOutput.FullPath)|%(AndroidPackageOutput.Filename)%(AndroidPackageOutput.Extension)|%(AndroidPackageOutput.PackageFormat)|%(AndroidPackageOutput.Signed)|%(AndroidPackageOutput.PackageId)"
Overwrite="true" />
</Target>
<Target Name="WriteResolvedPackagePublishItems" AfterTargets="_CalculateAndroidFilesToPublish">
<ItemGroup>
<_ResolvedPackagePublishItem
Include="@(ResolvedFileToPublish)"
Condition=" '%(ResolvedFileToPublish.Extension)' == '.apk' Or '%(ResolvedFileToPublish.Extension)' == '.aab' " />
</ItemGroup>
<WriteLinesToFile
File="$(MSBuildProjectDirectory)/resolved-package-publish-items.txt"
Lines="@(_ResolvedPackagePublishItem->'%(FullPath)|%(RelativePath)')"
Overwrite="true" />
</Target>
</Project>
Comment thread
Redth marked this conversation as resolved.
Outdated
"""
},
}
};
proj.SetRuntime (runtime);
Expand Down Expand Up @@ -348,7 +373,8 @@ public void DotNetPublish ([Values] bool isRelease, [ValueSource (nameof(Get_Dot
Assert.IsTrue (dotnet.LastBuildOutput.ContainsText (expectedMonoAndroidRuntimePath), $"Build should be using {expectedMonoAndroidRuntimePath}");
}

var publishDirectory = Path.Combine (Root, projBuilder.ProjectDirectory, proj.OutputPath, runtimeIdentifier, "publish");
var packageDirectory = Path.Combine (Root, projBuilder.ProjectDirectory, proj.OutputPath, runtimeIdentifier);
var publishDirectory = Path.Combine (packageDirectory, "publish");
var apk = Path.Combine (publishDirectory, $"{proj.PackageName}.apk");
var apkSigned = Path.Combine (publishDirectory, $"{proj.PackageName}-Signed.apk");
// NOTE: the unsigned .apk doesn't exist when $(AndroidPackageFormats) is `aab;apk`
Expand All @@ -364,6 +390,65 @@ public void DotNetPublish ([Values] bool isRelease, [ValueSource (nameof(Get_Dot
FileAssert.Exists (aab);
FileAssert.Exists (aabSigned);
}

var resolvedPackagePublishItems = ReadAndroidPackageOutputItems (Path.Combine (Root, projBuilder.ProjectDirectory, "resolved-package-publish-items.txt"));
if (!isRelease) {
Assert.AreEqual (2, resolvedPackagePublishItems.Count, $"Actual items:{Environment.NewLine}{FormatPackageOutputItems (resolvedPackagePublishItems)}");
AssertResolvedPackagePublishItem (resolvedPackagePublishItems, Path.Combine (packageDirectory, $"{proj.PackageName}.apk"), $"{proj.PackageName}.apk");
AssertResolvedPackagePublishItem (resolvedPackagePublishItems, Path.Combine (packageDirectory, $"{proj.PackageName}-Signed.apk"), $"{proj.PackageName}-Signed.apk");
} else {
Assert.AreEqual (3, resolvedPackagePublishItems.Count, $"Actual items:{Environment.NewLine}{FormatPackageOutputItems (resolvedPackagePublishItems)}");
AssertResolvedPackagePublishItem (resolvedPackagePublishItems, Path.Combine (packageDirectory, $"{proj.PackageName}.aab"), $"{proj.PackageName}.aab");
AssertResolvedPackagePublishItem (resolvedPackagePublishItems, Path.Combine (packageDirectory, $"{proj.PackageName}-Signed.aab"), $"{proj.PackageName}-Signed.aab");
AssertResolvedPackagePublishItem (resolvedPackagePublishItems, Path.Combine (packageDirectory, $"{proj.PackageName}-Signed.apk"), $"{proj.PackageName}-Signed.apk");
}

var packageOutputItems = ReadAndroidPackageOutputItems (Path.Combine (Root, projBuilder.ProjectDirectory, "android-package-output-items.txt"));
if (!isRelease) {
Assert.AreEqual (2, packageOutputItems.Count, $"Actual items:{Environment.NewLine}{FormatPackageOutputItems (packageOutputItems)}");
AssertPackageOutputItem (packageOutputItems, Path.Combine (publishDirectory, $"{proj.PackageName}.apk"), $"{proj.PackageName}.apk", "apk", "false", proj.PackageName);
AssertPackageOutputItem (packageOutputItems, Path.Combine (publishDirectory, $"{proj.PackageName}-Signed.apk"), $"{proj.PackageName}-Signed.apk", "apk", "true", proj.PackageName);
} else {
Assert.AreEqual (3, packageOutputItems.Count, $"Actual items:{Environment.NewLine}{FormatPackageOutputItems (packageOutputItems)}");
AssertPackageOutputItem (packageOutputItems, Path.Combine (publishDirectory, $"{proj.PackageName}.aab"), $"{proj.PackageName}.aab", "aab", "false", proj.PackageName);
AssertPackageOutputItem (packageOutputItems, Path.Combine (publishDirectory, $"{proj.PackageName}-Signed.aab"), $"{proj.PackageName}-Signed.aab", "aab", "true", proj.PackageName);
AssertPackageOutputItem (packageOutputItems, Path.Combine (publishDirectory, $"{proj.PackageName}-Signed.apk"), $"{proj.PackageName}-Signed.apk", "apk", "true", proj.PackageName);
}
}

static List<string []> ReadAndroidPackageOutputItems (string path)
{
FileAssert.Exists (path);
return File.ReadAllLines (path)
.Where (line => line.Length > 0)
.Select (line => line.Split ('|'))
.ToList ();
}

static void AssertPackageOutputItem (List<string []> items, string fullPath, string fileName, string packageFormat, string signed, string packageId)
{
var matches = items.Where (item =>
item.Length == 5 &&
item [0] == fullPath &&
item [1] == fileName &&
item [2] == packageFormat &&
item [3] == signed &&
item [4] == packageId).ToList ();
Assert.AreEqual (1, matches.Count, $"Expected package output item '{fullPath}|{fileName}|{packageFormat}|{signed}|{packageId}'. Actual items:{Environment.NewLine}{FormatPackageOutputItems (items)}");
}

static void AssertResolvedPackagePublishItem (List<string []> items, string fullPath, string relativePath)
{
var matches = items.Where (item =>
item.Length == 2 &&
item [0] == fullPath &&
item [1] == relativePath).ToList ();
Assert.AreEqual (1, matches.Count, $"Expected resolved package publish item '{fullPath}|{relativePath}'. Actual items:{Environment.NewLine}{FormatPackageOutputItems (items)}");
}

static string FormatPackageOutputItems (List<string []> items)
{
return string.Join (Environment.NewLine, items.Select (item => string.Join ("|", item)));
}

[Test]
Expand Down
48 changes: 48 additions & 0 deletions src/Xamarin.Android.Build.Tasks/Xamarin.Android.Common.targets
Original file line number Diff line number Diff line change
Expand Up @@ -2678,6 +2678,54 @@ because xbuild doesn't support framework reference assemblies.
<UnzipToFolder Sources="$(_UniversalApkSetIntermediate)" DestinationDirectories="$(OutDir)" Files="@(_FilestoExtract)" />
</Target>

<Target Name="_CollectAndroidPackageOutputs"
Condition=" '$(AndroidApplication)' == 'true' "
DependsOnTargets="_ValidateAndroidPackageProperties">
<ItemGroup>
<AndroidPackageOutput Remove="@(AndroidPackageOutput)" />
Comment thread
Redth marked this conversation as resolved.
Outdated
<AndroidPackageOutput
Condition=" '$(AndroidPackageFormat)' != 'aab' And Exists('$(ApkFile)') "
Include="$(ApkFile)">
<PackageFormat>apk</PackageFormat>
<Signed>false</Signed>
</AndroidPackageOutput>
<AndroidPackageOutput
Condition=" '$(AndroidPackageFormat)' != 'aab' And '$(AndroidCreatePackagePerAbi)' == 'true' And Exists('$(OutDir)$(_AndroidPackage)-%(_BuildTargetAbis.Identity).apk') "
Include="@(_BuildTargetAbis->'$(OutDir)$(_AndroidPackage)-%(Identity).apk')">
<PackageFormat>apk</PackageFormat>
<Signed>false</Signed>
<Abi>%(_BuildTargetAbis.Identity)</Abi>
</AndroidPackageOutput>
<AndroidPackageOutput Condition="Exists('$(ApkFileSigned)')" Include="$(ApkFileSigned)">
<PackageFormat>apk</PackageFormat>
<Signed>true</Signed>
</AndroidPackageOutput>
<AndroidPackageOutput
Condition=" '$(AndroidPackageFormat)' != 'aab' And '$(AndroidCreatePackagePerAbi)' == 'true' And Exists('$(OutDir)$(_AndroidPackage)-%(_BuildTargetAbis.Identity)-Signed.apk') "
Include="@(_BuildTargetAbis->'$(OutDir)$(_AndroidPackage)-%(Identity)-Signed.apk')">
<PackageFormat>apk</PackageFormat>
<Signed>true</Signed>
<Abi>%(_BuildTargetAbis.Identity)</Abi>
</AndroidPackageOutput>
<AndroidPackageOutput Condition=" '$(AndroidPackageFormat)' == 'aab' And Exists('$(_AabFile)') " Include="$(_AabFile)">
<PackageFormat>aab</PackageFormat>
<Signed>false</Signed>
</AndroidPackageOutput>
<AndroidPackageOutput Condition=" '$(AndroidPackageFormat)' == 'aab' And Exists('$(_AabFileSigned)') " Include="$(_AabFileSigned)">
<PackageFormat>aab</PackageFormat>
<Signed>true</Signed>
</AndroidPackageOutput>
<AndroidPackageOutput
Update="@(AndroidPackageOutput)"
PackageId="$(_AndroidPackage)" />
</ItemGroup>
</Target>

<Target Name="GetAndroidPackageOutputs"
Condition=" '$(AndroidApplication)' == 'true' "
DependsOnTargets="SignAndroidPackage;_CollectAndroidPackageOutputs"
Returns="@(AndroidPackageOutput)" />

<Target Name="SignAndroidPackage" DependsOnTargets="$(SignAndroidPackageDependsOn)">
</Target>

Expand Down
Loading