Skip to content

Commit

Permalink
[Xamarin.Andorid.Build.Tasks] First Pass on Dynamic Asset Features
Browse files Browse the repository at this point in the history
Context dotnet#4810

This is the first implementation for supporting building Dynamic Feature
Assets modules for Android.  This idea is to have these "features" as
standard Xamarin Android Library projects. The `ProjectReference` will need to
use the following

```xml
<ItemGroup>
    <ProjectReference Include="Features\AssetsFeature\AssetsFeature.csproj">
         <ReferenceOutputAssembly>false</ReferenceOutputAssembly>
         <AndroidDynamicFeature>true</AndroidDynamicFeature>
    </ProjectReference>
  </ItemGroup>
```

We set `ReferenceOutputAssembly` to `false` to ensure that the assembly
for the feature is NOT included in the final aab file.  The new meta
Data `AndroidDynamicFeature` allows the build system to pick up project
references which are "features".

As part of the final packaging step of the main app we will gather up all
the `ProjectRefernce` items which have `AndroidDynamicFeature` set to
`true` (and maybe `ReferenceOutputAssembly` set to `false`). This will
be done by the `_BuildDynamicFeatures` which will run just after
`_CreateBaseApk`.

It will call `_GetDynamicFeatureOutputs` for each `ProjectReference`
which will collect the `output` files for each feature. It will then call
the `BuildDynamicFeature` target via the `MSBuild` task for each
`ProjectReference`.  The `BuildDynamicFeature` is the target responsible
for collecting all the assets and packaging them using `aapt2` up into a
zip. Once all the `BuildDynamicFeature` calls are complete the created
`zip` files will be added to the `AndroidAppBundleModules` and then
included in the final `aab` file.

It might seem odd that the feature projects are built after the main app.
However this is required because the  feature needs to use the
`packaged_resources` file as an input to `aapt2` when building the
feature. This is why the `_BuildDynamicFeatures` happens AFTER `_CreateBaseApk`.
It is only at that point that the final `packaged_resources` file exists.

One of the very weird things is that the feature zip needs to be built
using the `aapt2` `--static-lib` flag. As a result we need to call
`aapt2 convert` on the final zip. This is because it is in the `apk`
`binary` format and needs to be converted over to the `aab` `proto` format.
So there is a new `Aapt2Convert` task which handles that job. It also
makes sure the `AndroidManifest.xml` file is in the right place when
converting to `proto` format.

A basic project example using .net 6 for a feature would look like this.

```xml
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net6.0-android</TargetFramework>
  </PropertyGroup>
</Project>
```

As you can see it is just a normal library project. At this time is CANNOT
contain any `AndroidResource` items such as drawables or layouts. It must
only contain `AndroidAsset` items. So we probably should have a new
template for a `Dynamic Feature` which just creates the `csproj` and
the `Assets` folder.

One sticking point is probably the `AndroidManifest.xml` file which we
need for a `feature`. There is a sample

```xml
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:dist="http://schemas.android.com/apk/distribution"
        android:versionCode="1"
        android:versionName="1.0"
        package="com.companyname.DynamicAssetsExample"
        featureSplit="assetsfeature"
        android:isFeatureSplit="true">
  <dist:module dist:title="@string/assetsfeature" dist:instant="false">
    <dist:delivery>
      <dist:on-demand />
    </dist:delivery>
    <dist:fusing dist:include="false" />
  </dist:module>
  <uses-sdk android:minSdkVersion="21" android:targetSdkVersion="30" />
  <application android:hasCode="false" tools:replace="android:hasCode" />
</manifest>
```

The interesting parts are all the additional `dist` elements. What we can
probably do is auto generate this during the `BuildDynamicFeature`.
However we need to think carefully about this since if we plan to have
code and `Activities` in the feature at some point, those will also need
to end up in the `AndroidManifest.xml`.

For additional information on the Play Core Dynamic Features check the
following links.
[1] https://developer.android.com/guide/playcore/asset-delivery
[2] https://developer.android.com/guide/playcore/feature-delivery
  • Loading branch information
dellis1972 committed Nov 8, 2023
1 parent 9808988 commit 3d45abf
Show file tree
Hide file tree
Showing 20 changed files with 1,312 additions and 33 deletions.
461 changes: 461 additions & 0 deletions Documentation/guides/DynamicFeatures.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions build-tools/installers/create-installers.targets
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,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.DynamicFeature.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')" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Copyright (C) 2011-2012 Xamarin. All rights reserved.

<UsingTask TaskName="Xamarin.Android.Tasks.Aapt2Compile" AssemblyFile="Xamarin.Android.Build.Tasks.dll" />
<UsingTask TaskName="Xamarin.Android.Tasks.Aapt2Link" AssemblyFile="Xamarin.Android.Build.Tasks.dll" />
<UsingTask TaskName="Xamarin.Android.Tasks.Aapt2Convert" AssemblyFile="Xamarin.Android.Build.Tasks.dll" />
<UsingTask TaskName="Xamarin.Android.Tasks.CreateDynamicFeatureManifest" AssemblyFile="Xamarin.Android.Build.Tasks.dll" />

<PropertyGroup>
<Aapt2DaemonMaxInstanceCount Condition=" '$(Aapt2DaemonMaxInstanceCount)' == '' " >0</Aapt2DaemonMaxInstanceCount>
Expand Down Expand Up @@ -163,6 +165,7 @@ Copyright (C) 2011-2012 Xamarin. All rights reserved.
OutputImportDirectory="$(_AndroidLibrayProjectIntermediatePath)"
OutputFile="$(ResgenTemporaryDirectory)\resources.apk"
PackageName="$(_AndroidPackage)"
PackageId="$(FeaturePackageId)"
JavaPlatformJarPath="$(JavaPlatformJarPath)"
JavaDesignerOutputDirectory="$(ResgenTemporaryDirectory)"
CompiledResourceFlatFiles="@(_CompiledFlatFiles)"
Expand Down Expand Up @@ -224,7 +227,9 @@ Copyright (C) 2011-2012 Xamarin. All rights reserved.
AdditionalAndroidResourcePaths="@(_LibraryResourceDirectories)"
YieldDuringToolExecution="$(YieldDuringToolExecution)"
PackageName="$(_AndroidPackage)"
PackageId="$(FeaturePackageId)"
JavaPlatformJarPath="$(JavaPlatformJarPath)"
AdditionalApksToLink="@(_AdditionalApksToLink)"
VersionCodePattern="$(AndroidVersionCodePattern)"
VersionCodeProperties="$(AndroidVersionCodeProperties)"
SupportedAbis="@(_BuildTargetAbis)"
Expand All @@ -247,4 +252,126 @@ Copyright (C) 2011-2012 Xamarin. All rights reserved.
<FileWrites Include="$(IntermediateOutputPath)android\*\aapt_rules.txt" />
</ItemGroup>
</Target>

<!-- Dynamic Feature Section -->

<Target Name="_GenerateDynamicFeatureManifest"
Inputs="$(MSBuildProjectFile)"
Outputs="$(IntermediateOutputPath)AndroidManifest.xml">
<PropertyGroup>
<AndroidManifest Condition=" '$(AndroidManifest)' == '' ">$(IntermediateOutputPath)AndroidManifest.xml</AndroidManifest>
<FeatureSplitName Condition=" '$(FeatureSplitName)' == '' " >$(ProjectName)</FeatureSplitName>
<!-- FeatureDeliveryType : Valid Values are InstallTime, OnDemand -->
<FeatureDeliveryType Condition=" '$(FeatureDeliveryType)' == '' " >InstallTime</FeatureDeliveryType>
<IsFeatureSplit Condition=" '$(IsFeatureSplit)' == '' " >True</IsFeatureSplit>
<!-- FeatureType : Valid Values are Feature, AssetPack -->
<FeatureType Condition=" '$(FeatureType)' == '' ">Feature</FeatureType>
</PropertyGroup>
<MakeDir Directories="$(IntermediateOutputPath)android\bin" Condition="!Exists('$(IntermediateOutputPath)android\bin')" />
<CreateDynamicFeatureManifest
FeatureSplitName="$(FeatureSplitName)"
FeatureDeliveryType="$(FeatureDeliveryType)"
IsFeatureSplit="$(IsFeatureSplit)"
FeatureTitleResource="$(FeatureTitleResource)"
FeatureType="$(FeatureType)"
PackageName="$(PackageName)"
OutputFile="$(AndroidManifest)"
MinSdkVersion="$(_MinSdkVersion)"
TargetSdkVersion="$(_TargetSdkVersion)"
/>
<ItemGroup>
<FileWrites Include="$(AndroidManifest)" />
</ItemGroup>
</Target>

<Target Name="_ConvertAppPackageForFeatureCompilation"
Condition=" '@(_AndroidDynamicFeature->Count())' != '0' "
Inputs="$(_PackagedResources)"
Outputs="$(_PackagedResources).ap_">
<Aapt2Convert
DaemonMaxInstanceCount="$(Aapt2DaemonMaxInstanceCount)"
DaemonKeepInDomain="$(_Aapt2DaemonKeepInDomain)"
OutputArchive="$(_PackagedResources).ap_"
Files="$(_PackagedResources)"
OutputFormat="binary"
ToolPath="$(Aapt2ToolPath)"
ToolExe="$(Aapt2ToolExe)"
/>
<ItemGroup>
<FileWrites Include="$(_PackagedResources).ap_" />
</ItemGroup>
</Target>

<Target Name="_ConvertFeaturePackage">
<Aapt2Convert
DaemonMaxInstanceCount="$(Aapt2DaemonMaxInstanceCount)"
DaemonKeepInDomain="$(_Aapt2DaemonKeepInDomain)"
OutputArchive="$(_FeaturePackage)"
Files="$(_PackagedResources)"
OutputFormat="proto"
ToolPath="$(Aapt2ToolPath)"
ToolExe="$(Aapt2ToolExe)"
/>
<ItemGroup>
<FileWrites Include="$(_FeaturePackage)" />
</ItemGroup>
</Target>

<Target Name="_CreateFeaturePackageWithAapt2"
DependsOnTargets="UpdateAndroidAssets"
Condition=" '$(FeatureType)' == 'AssetPack' "
Inputs="$(AndroidManifest);@(AndroidAsset);$(_AppPackagedResources)"
Outputs="$(_FeaturePackage)"
>
<PropertyGroup>
<_AndroidIntermediateAapt2OutputDirectory>$(IntermediateOutputPath)aapt2output</_AndroidIntermediateAapt2OutputDirectory>
</PropertyGroup>
<MakeDir Directories="$(_AndroidIntermediateDexOutputDirectory);$(_AndroidIntermediateAapt2OutputDirectory)" />
<Aapt2Link
AndroidManifestFile="$(AndroidManifest)"
CompiledResourceFlatFiles="@(_CompiledFlatFiles)"
DaemonMaxInstanceCount="$(Aapt2DaemonMaxInstanceCount)"
DaemonKeepInDomain="$(_Aapt2DaemonKeepInDomain)"
ResourceDirectories="$(MonoAndroidResDirIntermediate)"
AssemblyIdentityMapFile="$(_AndroidLibrayProjectAssemblyMapFile)"
ImportsDirectory="$(_LibraryProjectImportsDirectoryName)"
OutputImportDirectory="$(_AndroidLibrayProjectIntermediatePath)"
OutputFile="$(_AndroidIntermediateAapt2OutputDirectory)"
OutputToDirectory="True"
YieldDuringToolExecution="$(YieldDuringToolExecution)"
PackageName="$(FeaturePackageName)"
PackageId="$(FeaturePackageId)"
JavaPlatformJarPath="$(JavaPlatformJarPath)"
AdditionalApksToLink="@(_AdditionalApksToLink)"
VersionCodePattern="$(AndroidVersionCodePattern)"
VersionCodeProperties="$(AndroidVersionCodeProperties)"
SupportedAbis="@(_BuildTargetAbis)"
CreatePackagePerAbi="$(AndroidCreatePackagePerAbi)"
AssetsDirectory="$(MonoAndroidAssetsDirIntermediate)"
AdditionalAndroidAssetPaths="@(LibraryAssetDirectories)"
AndroidSdkPlatform="$(_AndroidApiLevel)"
JavaDesignerOutputDirectory="$(AaptTemporaryDirectory)"
ManifestFiles="$(AndroidManifest)"
ProtobufFormat="False"
ExtraArgs="$(AndroidAapt2LinkExtraArgs)"
ToolPath="$(Aapt2ToolPath)"
ToolExe="$(Aapt2ToolExe)"
UncompressedFileExtensions="$(AndroidStoreUncompressedFileExtensions)"
/>
<!-- We don't need this file for asset packs. In fact bundle-tool will error if it is present. -->
<Delete
Files="$(_AndroidIntermediateAapt2OutputDirectory)\resources.arsc"
Condition=" '$(FeatureType)' == 'AssetPack' And Exists ('$(_AndroidIntermediateAapt2OutputDirectory)\resources.arsc') "
/>

<ZipDirectory
SourceDirectory="$(_AndroidIntermediateAapt2OutputDirectory)"
DestinationFile="$(_PackagedResources)"
Overwrite="true"
/>
<ItemGroup>
<FileWrites Include="$(_PackagedResources)" />
<FileWrites Include="$(_AndroidIntermediateAapt2OutputDirectory)\**\*.*" />
</ItemGroup>
</Target>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<Project xmlns="http://schemas.microsoft.com/developer/msbuild/2003">

<UsingTask TaskName="Xamarin.Android.Tasks.CalculatePackageIdsForFeatures" AssemblyFile="Xamarin.Android.Build.Tasks.dll" />
<!-- Andorid Feature Build System-->
<PropertyGroup>
<_BuildDynamicFeaturesInParallel>False</_BuildDynamicFeaturesInParallel>
</PropertyGroup>
<Target Name="_CheckIsDynamicFeature"
Returns="@(_DynamicFeaetureProjectMetadata)" Condition=" '$(FeatureSplitName)' != '' Or '$(FeatureType)' != '' ">
<ItemGroup>
<_DynamicFeaetureProjectMetadata Include="$(MSBuildProjectFullPath)">
<IsDynamicFeature>true</IsDynamicFeature>
<FeaturePackage>$(MSBuildProjectDirectory)\$(_FeaturePackage)</FeaturePackage>
<FeatureType>$(FeatureType)</FeatureType>
<_BaseZipIntermediate>$(IntermediateOutputPath)\$(MSBuildProjectName).zip</_BaseZipIntermediate>
</_DynamicFeaetureProjectMetadata>
</ItemGroup>
</Target>
<Target Name="_ResolveAndroidDynamicFeatureProjects" BeforeTargets="AssignProjectConfiguration" Condition=" '$(AndroidApplication)' == 'true' ">
<MSBuild
Projects="@(ProjectReference)"
Targets="_CheckIsDynamicFeature"
BuildInParallel="$(_BuildDynamicFeaturesInParallel)"
SkipNonexistentTargets="true"
RebaseOutputs="true"
>
<Output TaskParameter="TargetOutputs" ItemName="_AndroidDynamicFeatureProjects" />
</MSBuild>
<ItemGroup>
<ProjectReference Remove="%(_AndroidDynamicFeatureProjects.OriginalItemSpec)" />
</ItemGroup>
</Target>
<Target Name="_GetDynamicFeatureOutputs" DependsOnTargets="_ResolveAndroidDynamicFeatureProjects">
<CalculatePackageIdsForFeatures
FeatureProjects="@(_AndroidDynamicFeatureProjects)"
>
<Output TaskParameter="Output" ItemName="_AndroidDynamicFeature" />
</CalculatePackageIdsForFeatures>
</Target>
<Target Name="_BuildDynamicFeatures"
AfterTargets="_CreateBaseApk"
DependsOnTargets="_GetDynamicFeatureOutputs;_ConvertAppPackageForFeatureCompilation"
Condition=" '$(AndroidPackageFormat)' == 'aab' And '$(AndroidApplication)' == 'true' "
Inputs="@(_AndroidDynamicFeature)"
Outputs="@(_AndroidDynamicFeatureProjects->'%(FeaturePackage)')">
<PropertyGroup>
<_BuildArguments>
Configuration=$(Configuration);
BuildingFeature=True;
PackageName=$(_AndroidPackage);
AndroidPackageFormat=aab;
JavaPlatformJarPath=$(JavaPlatformJarPath);
Aapt2ToolPath=$(Aapt2ToolPath);Aapt2ToolExe=$(Aapt2ToolExe);
_TargetSdkVersion=$(_TargetSdkVersion);
_MinSdkVersion=$(_MinSdkVersion);
_AndroidIncludeRuntime=False;
_AppPackagedResources=$(MSBuildProjectDirectory)\$(_PackagedResources).ap_;
_AppAssemblyDirectory=$(MSBuildProjectDirectory)\$(MonoAndroidIntermediateAssemblyDir);
</_BuildArguments>
</PropertyGroup>
<MSBuild Projects="@(_AndroidDynamicFeature)" BuildInParallel="$(_BuildDynamicFeaturesInParallel)" Targets="Restore;BuildDynamicFeature" RebaseOutputs="true"
Properties="$(_BuildArguments)">
<Output TaskParameter="TargetOutputs" ItemName="_FeatureAssemblies" />
</MSBuild>
<ItemGroup>
<AndroidAppBundleModules Include="%(_AndroidDynamicFeatureProjects.FeaturePackage)" />
</ItemGroup>
</Target>

<Target Name="_CleanDynamicFeatures"
Condition="'@(_AndroidDynamicFeatureProjects.Count())' != '0' "
DependsOnTargets="_CollectDynamicFeatures">
<MSBuild Projects="@(_AndroidDynamicFeatureProjects)" BuildInParallel="$(_BuildDynamicFeaturesInParallel)" Targets="Clean" RebaseOutputs="true" Properties="Configuration=$(Configuration)" />
</Target>
<!-- Feature Specific Targets. -->

<PropertyGroup>
<_FeaturePackage>$(OutputPath)$(MSBuildProjectName).zip</_FeaturePackage>
<_FeaturePackageTemp>$(IntermediateOutputPath)$(MSBuildProjectName).zip</_FeaturePackageTemp>
</PropertyGroup>

<Target Name="_SetupFeature">
<PropertyGroup>
<FeaturePackageName Condition=" '$(FeaturePackageName)' == '' " >$(_AndroidPackage).$(MSBuildProjectName)</FeaturePackageName>
</PropertyGroup>
<ItemGroup>
<_AdditionalApksToLink Include="$(_AppPackagedResources)" />
<Reference Include="$(_AppAssemblyDirectory)*.dll" AndroidSkipResourceExtraction="True" AndroidSkipAddToPackage="True" />
<_AppAssemblies Include="$(_AppAssemblyDirectory)*.dll" />
<ResolvedAssemblies Include="%(_AppAssemblies.Identity)" AndroidSkipResourceExtraction="True" AndroidSkipAddToPackage="True">
<DestinationSubPath>%(_AppAssemblies.Filename)</DestinationSubPath>
</ResolvedAssemblies>
</ItemGroup>
</Target>
<PropertyGroup>
<BeforeBuildDynamicFeatureDependsOnTargets>
_SetupFeature;
_SetupDesignTimeBuildForBuild;
_GenerateDynamicFeatureManifest;
</BeforeBuildDynamicFeatureDependsOnTargets>
<AfterBuildDynamicFeatureDependsOnTargets>
;_ConvertFeaturePackage
</AfterBuildDynamicFeatureDependsOnTargets>
<CleanDependsOn>
$(CleanDependsOn);
_CleanDynamicFeatures;
</CleanDependsOn>
</PropertyGroup>
<Target Name="BuildDynamicFeature"
Inputs="$(_AndroidManifestAbs);@(AndroidAsset);$(_AppPackagedResources)"
Outputs="$(_FeaturePackage)">

<PropertyGroup>
<_FeatureTargets>$(BeforeBuildDynamicFeatureDependsOnTargets)</_FeatureTargets>
<_FeatureTargets Condition="'$(FeatureType)' == 'Feature' And '@(Compile.Count())' != '0'" >$(_FeatureTargets);PackageForAndroid</_FeatureTargets>
<_FeatureTargets Condition="'$(FeatureType)' == 'AssetPack' " >$(_FeatureTargets);_CreateFeaturePackageWithAapt2</_FeatureTargets>
<_FeatureTargets>$(_FeatureTargets);$(AfterBuildDynamicFeatureDependsOnTargets)</_FeatureTargets>
</PropertyGroup>
<CallTarget Targets="$(_FeatureTargets)" />
</Target>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ properties that determine build ordering.
_ResolveAssemblies;
_ResolveSatellitePaths;
_CreatePackageWorkspace;
_CreateBaseApk;
_BuildDynamicFeatures;
_LinkAssemblies;
_GenerateJavaStubs;
_ManifestMerger;
Expand All @@ -69,7 +71,6 @@ properties that determine build ordering.
_CreateApplicationSharedLibraries;
_CompileDex;
$(_AfterCompileDex);
_CreateBaseApk;
_PrepareAssemblies;
_ResolveSatellitePaths;
_CheckApkPerAbiFlag;
Expand Down
64 changes: 64 additions & 0 deletions src/Xamarin.Android.Build.Tasks/Tasks/Aapt2Convert.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading;
using System.Xml;
using System.Xml.Linq;
using Microsoft.Build.Utilities;
using Microsoft.Build.Framework;
using System.Text.RegularExpressions;
using System.Collections.Generic;
using Xamarin.Android.Tools;
using Microsoft.Android.Build.Tasks;

namespace Xamarin.Android.Tasks {

public class Aapt2Convert : Aapt2 {
public override string TaskPrefix => "A2C";

[Required]
public ITaskItem [] Files { get; set; }
[Required]
public ITaskItem OutputArchive { get; set; }
public string OutputFormat { get; set; } = "binary";
public string ExtraArgs { get; set; }

protected override int GetRequiredDaemonInstances ()
{
return Math.Min (1, DaemonMaxInstanceCount);
}

public async override System.Threading.Tasks.Task RunTaskAsync ()
{
RunAapt (GenerateCommandLineCommands (Files, OutputArchive), OutputArchive.ItemSpec);
ProcessOutput ();
if (OutputFormat == "proto" && File.Exists (OutputArchive.ItemSpec)) {
// move the manifest to the right place.
using (var zip = new ZipArchiveEx (OutputArchive.ItemSpec, File.Exists (OutputArchive.ItemSpec) ? FileMode.Open : FileMode.Create)) {
zip.MoveEntry ("AndroidManifest.xml", "manifest/AndroidManifest.xml");
}
}
}

protected string[] GenerateCommandLineCommands (IEnumerable<ITaskItem> files, ITaskItem output)
{
List<string> cmd = new List<string> ();
cmd.Add ("convert");
if (!string.IsNullOrEmpty (ExtraArgs))
cmd.Add (ExtraArgs);
if (MonoAndroidHelper.LogInternalExceptions)
cmd.Add ("-v");
if (!string.IsNullOrEmpty (OutputFormat)) {
cmd.Add ("--output-format");
cmd.Add (OutputFormat);
}
cmd.Add ($"-o");
cmd.Add (GetFullPath (output.ItemSpec));
foreach (var file in files) {
cmd.Add (GetFullPath (file.ItemSpec));
}
return cmd.ToArray ();
}
}
}
Loading

0 comments on commit 3d45abf

Please sign in to comment.