// Copyright 2020 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Google.Android.AppBundle.Editor.Internal.AssetPacks;
using UnityEditor;
namespace Google.Android.AppBundle.Editor
{
///
/// Provides helper methods for building AssetBundles with various texture compression formats.
///
public static class AssetBundleBuilder
{
///
/// Run one or more AssetBundle builds with the specified texture compression formats.
/// Notes about the parameter:
/// - If a relative path is provided, the file paths in the returned AssetPackConfig will be relative paths.
/// - If an absolute path is provided, the file paths in the returned object will be absolute paths.
/// - AssetBundle builds for additional texture formats will be created in siblings of this directory. For
/// example, for outputDirectory "a/b/c" and texture format ASTC, there will be a directory "a/b/c#tcf_astc".
/// - If allowClearDirectory is false, this directory and any sibling directories must be empty or not exist,
/// otherwise an exception is thrown.
///
/// The output directory for the ETC1 AssetBundles. See other notes above.
/// The main argument to .
/// A delivery mode to apply to every asset pack in the generated config.
/// Texture formats to build for in addition to ETC1.
/// Options to pass to .
/// Allows this method to clear the contents of the output directory.
/// An containing file paths to all generated AssetBundles.
public static AssetPackConfig BuildAssetBundles(
string outputPath,
AssetBundleBuild[] builds,
AssetPackDeliveryMode deliveryMode,
IEnumerable additionalTextureFormats = null,
BuildAssetBundleOptions assetBundleOptions = BuildAssetBundleOptions.UncompressedAssetBundle,
bool allowClearDirectory = false)
{
var nameToTextureFormatToPath = BuildAssetBundles(outputPath, builds, assetBundleOptions,
MobileTextureSubtarget.ETC, additionalTextureFormats, allowClearDirectory);
var assetPackConfig = new AssetPackConfig();
foreach (var compressionToPath in nameToTextureFormatToPath.Values)
{
assetPackConfig.AddAssetBundles(compressionToPath, deliveryMode);
}
return assetPackConfig;
}
///
/// Run one or more AssetBundle builds with the specified texture compression formats.
/// This variant allows for overriding the base format and provides a different return type.
///
/// The output directory for base AssetBundles. See the other method for notes.
/// The main argument to .
/// Options to pass to .
/// The default format. Note: ETC1 is supported by all devices.
/// Texture formats to generate for in addition to the base.
/// Allows this method to clear the contents of the output directory.
/// A dictionary from AssetBundle name to TextureCompressionFormat to file outputPath.
public static Dictionary> BuildAssetBundles(
string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions,
MobileTextureSubtarget baseTextureFormat, IEnumerable additionalTextureFormats,
bool allowClearDirectory)
{
if (builds == null || builds.Length == 0)
{
throw new ArgumentException("AssetBundleBuild parameter cannot be null or empty");
}
foreach (var build in builds)
{
if (!AndroidAppBundle.IsValidModuleName(build.assetBundleName))
{
throw new ArgumentException("Invalid AssetBundle name: " + build.assetBundleName);
}
}
if (string.IsNullOrEmpty(outputPath))
{
throw new ArgumentNullException("outputPath");
}
CheckDirectory(outputPath, allowClearDirectory);
// Make unique and silently remove the base format, if it was present.
var textureSubtargets = new HashSet(
additionalTextureFormats ?? Enumerable.Empty());
textureSubtargets.Remove(baseTextureFormat);
// Note: GetCompressionFormatSuffix() will throw for unsupported formats.
var paths = textureSubtargets.Select(format => outputPath + GetCompressionFormatSuffix(format));
foreach (var path in paths)
{
CheckDirectory(path, allowClearDirectory);
}
// Throws if the base format is invalid.
GetCompressionFormat(baseTextureFormat);
return BuildAssetBundlesInternal(
outputPath, builds, assetBundleOptions, baseTextureFormat, textureSubtargets);
}
///
/// Run one or more AssetBundle builds for the specified device tiers.
/// Notes about the parameter:
/// - If a relative path is provided, the file paths in the returned AssetPackConfig will be relative paths.
/// - If an absolute path is provided, the file paths in the returned object will be absolute paths.
/// - AssetBundle builds for device tiers will be created in siblings of this directory. For
/// example, for outputDirectory "a/b/c" and device tier HIGH, there will be a directory "a/b/c#tier_high".
/// - If allowClearDirectory is false, this directory and any sibling directories must be empty or not exist,
/// otherwise an exception is thrown.
///
/// The output directory for AssetBundles. See other notes above.
/// A delivery mode to apply to every asset pack in the generated config.
/// A dictionary from Device Tier to asset bundle builds. All device tiers
/// should contains the same asset bundle names.
///
/// Default device tier to be used for standalone APKs. Set to zero if not specified.
/// Options to pass to .
/// Allows this method to clear the contents of the output directory.
/// An containing file paths to all generated AssetBundles.
public static AssetPackConfig BuildAssetBundlesDeviceTier(
string outputPath,
AssetPackDeliveryMode deliveryMode,
Dictionary deviceTierToBuilds,
DeviceTier defaultDeviceTier = null,
BuildAssetBundleOptions assetBundleOptions = BuildAssetBundleOptions.UncompressedAssetBundle,
bool allowClearDirectory = false)
{
if (defaultDeviceTier == null)
{
defaultDeviceTier = DeviceTier.From(0);
}
if (!deviceTierToBuilds.Keys.Contains(defaultDeviceTier))
{
throw new ArgumentException("Default device tier not present in deviceTierToBuilds.");
}
var nameToDeviceTierToPath = BuildAssetBundlesDeviceTier(outputPath, deviceTierToBuilds,
assetBundleOptions, allowClearDirectory);
var assetPackConfig = new AssetPackConfig();
foreach (var deviceTierToPath in nameToDeviceTierToPath.Values)
{
assetPackConfig.AddAssetBundles(deviceTierToPath, deliveryMode);
}
assetPackConfig.DefaultDeviceTier = defaultDeviceTier;
return assetPackConfig;
}
///
/// Run one or more AssetBundle builds for each device tier.
///
/// The output directory for base AssetBundles. See the other method for notes.
/// A dictionary from Device Tier to asset bundle builds. All device tiers
/// should contains the same asset bundle names.
/// Options to pass to .
/// Allows this method to clear the contents of the output directory.
/// A dictionary from AssetBundle name to DeviceTier to file outputPath.
public static Dictionary> BuildAssetBundlesDeviceTier(
string outputPath, Dictionary deviceTierToBuilds,
BuildAssetBundleOptions assetBundleOptions,
bool allowClearDirectory)
{
if (deviceTierToBuilds == null || deviceTierToBuilds.Count == 0)
{
throw new ArgumentException("DeviceTierToBuild parameter cannot be null or empty");
}
if (deviceTierToBuilds.Values.Any(builds => builds == null || builds.Length == 0))
{
throw new ArgumentException("AssetBundleBuilds value cannot be null or empty");
}
var assetBundleNames = deviceTierToBuilds.Values.First().Select(build => build.assetBundleName).ToList();
if (deviceTierToBuilds.Values.Any(builds =>
!(builds.Length == assetBundleNames.Count() &&
builds.Select(build => build.assetBundleName).Except(assetBundleNames).ToList().Count == 0)))
{
throw new ArgumentException("AssetBundleBuild names cannot differ across device tiers.");
}
foreach (var assetBundleName in assetBundleNames)
{
if (!AndroidAppBundle.IsValidModuleName(assetBundleName))
{
throw new ArgumentException("Invalid AssetBundle name: " + assetBundleName);
}
}
if (string.IsNullOrEmpty(outputPath))
{
throw new ArgumentNullException("outputPath");
}
CheckDirectory(outputPath, allowClearDirectory);
var tiers = new HashSet(deviceTierToBuilds.Keys);
var paths = tiers.Select(tier => outputPath + DeviceTierTargetingTools.GetTargetingSuffix(tier));
foreach (var path in paths)
{
CheckDirectory(path, allowClearDirectory);
}
return BuildAssetBundlesDeviceTierInternal(
outputPath, assetBundleOptions, deviceTierToBuilds, assetBundleNames);
}
// The internal method assumes all parameter preconditions have already been checked.
private static Dictionary> BuildAssetBundlesInternal(
string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions,
MobileTextureSubtarget baseTextureFormat, IEnumerable textureSubtargets)
{
// Use a dictionary to capture the generated AssetBundles' file names.
var nameToTextureFormatToPath = builds.ToDictionary(
build => build.assetBundleName, _ => new Dictionary());
var originalAndroidBuildSubtarget = EditorUserBuildSettings.androidBuildSubtarget;
try
{
// First build for the additional texture formats.
foreach (var textureSubtarget in textureSubtargets)
{
// Build AssetBundles in the the base format's directory.
EditorUserBuildSettings.androidBuildSubtarget = textureSubtarget;
BuildAssetBundles(outputPath, builds, assetBundleOptions);
// Then move the files to a new directory with the compression format suffix.
var outputPathWithSuffix = outputPath + GetCompressionFormatSuffix(textureSubtarget);
Directory.Move(outputPath, outputPathWithSuffix);
var textureCompressionFormat = GetCompressionFormat(textureSubtarget);
UpdateDictionary(nameToTextureFormatToPath, outputPathWithSuffix, textureCompressionFormat);
}
// Then build for the base format.
EditorUserBuildSettings.androidBuildSubtarget = baseTextureFormat;
BuildAssetBundles(outputPath, builds, assetBundleOptions);
UpdateDictionary(nameToTextureFormatToPath, outputPath, TextureCompressionFormat.Default);
return nameToTextureFormatToPath;
}
finally
{
EditorUserBuildSettings.androidBuildSubtarget = originalAndroidBuildSubtarget;
}
}
private static Dictionary> BuildAssetBundlesDeviceTierInternal(
string outputPath, BuildAssetBundleOptions assetBundleOptions,
Dictionary deviceTierToBuilds, IEnumerable assetBundleNames)
{
// Use a dictionary to capture the generated AssetBundles' file names.
var nameToDeviceTierToPath = assetBundleNames.ToDictionary(
bundleName => bundleName, _ => new Dictionary());
foreach (var deviceTier in deviceTierToBuilds.Keys)
{
var tierBuilds = deviceTierToBuilds[deviceTier];
// Build AssetBundles in the the base format's directory.
BuildAssetBundles(outputPath, tierBuilds, assetBundleOptions);
// Then move the files to a new directory with the device tier suffix.
var outputPathWithSuffix = outputPath + DeviceTierTargetingTools.GetTargetingSuffix(deviceTier);
Directory.Move(outputPath, outputPathWithSuffix);
UpdateDictionaryDeviceTier(nameToDeviceTierToPath, outputPathWithSuffix, deviceTier);
}
return nameToDeviceTierToPath;
}
private static void UpdateDictionaryDeviceTier(
Dictionary> nameToDeviceTierToPath,
string outputPath, DeviceTier deviceTier)
{
foreach (var entry in nameToDeviceTierToPath)
{
var filePath = Path.Combine(outputPath, entry.Key);
entry.Value[deviceTier] = filePath;
if (!File.Exists(filePath))
{
throw new InvalidOperationException(string.Format("Missing AssetBundle file: " + filePath));
}
}
}
private static void BuildAssetBundles(
string outputPath, AssetBundleBuild[] builds, BuildAssetBundleOptions assetBundleOptions)
{
// The output directory must exist before calling BuildAssetBundles(), so create it first.
Directory.CreateDirectory(outputPath);
var assetPackManifest =
BuildPipeline.BuildAssetBundles(outputPath, builds, assetBundleOptions, BuildTarget.Android);
if (assetPackManifest == null)
{
throw new InvalidOperationException("Error building AssetBundles");
}
}
private static void UpdateDictionary(
Dictionary> nameToTextureFormatToPath,
string outputPath, TextureCompressionFormat textureCompressionFormat)
{
foreach (var entry in nameToTextureFormatToPath)
{
var filePath = Path.Combine(outputPath, entry.Key);
entry.Value[textureCompressionFormat] = filePath;
if (!File.Exists(filePath))
{
throw new InvalidOperationException(string.Format("Missing AssetBundle file: " + filePath));
}
}
}
private static string GetCompressionFormatSuffix(MobileTextureSubtarget subtarget)
{
var format = GetCompressionFormat(subtarget);
return TextureTargetingTools.GetTargetingSuffix(format);
}
private static TextureCompressionFormat GetCompressionFormat(MobileTextureSubtarget subtarget)
{
switch (subtarget)
{
case MobileTextureSubtarget.ASTC:
return TextureCompressionFormat.Astc;
case MobileTextureSubtarget.DXT:
return TextureCompressionFormat.Dxt1;
case MobileTextureSubtarget.ETC:
return TextureCompressionFormat.Etc1;
case MobileTextureSubtarget.ETC2:
return TextureCompressionFormat.Etc2;
case MobileTextureSubtarget.PVRTC:
return TextureCompressionFormat.Pvrtc;
default:
throw new ArgumentException("Unsupported MobileTextureSubtarget: " + subtarget);
}
}
private static void CheckDirectory(string path, bool allowClearDirectory)
{
var directoryInfo = new DirectoryInfo(path);
if (!directoryInfo.Exists)
{
return;
}
if (!allowClearDirectory && directoryInfo.GetFileSystemInfos().Any())
{
throw new ArgumentException("Directory is not empty: " + path);
}
directoryInfo.Delete( /* recursive= */ true);
}
}
}