// Copyright 2018 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.Collections.Generic; using System.IO; using System.Linq; using System.Text; using Google.Android.AppBundle.Editor.Internal.AssetPacks; using Google.Android.AppBundle.Editor.Internal.PlayServices; using UnityEditor; using UnityEngine; namespace Google.Android.AppBundle.Editor.Internal.BuildTools { /// /// Provides methods for bundletool. /// public class BundletoolHelper : IBuildTool { /// /// Contains parameters needed to generate a config file for the build-bundle command. /// public class BuildBundleConfigParams { /// /// If true, enables targeting of module contents by texture formats. /// public bool enableTcfTargeting; /// /// When targeting by texture format, specifies the default format that will be used to generate /// standalone APKs for Android pre-Lollipop devices that don't support split APKs. /// public string defaultTcfSuffix; /// /// If true, enables targeting of module contents by device tiers. /// public bool enableDeviceTierTargeting; /// /// When targeting by device tier, specifies the default device tier that will be used to generate /// standalone APKs for Android pre-Lollipop devices that don't support split APKs. /// If not specified, it defaults to "0". /// public string defaultDeviceTier = "0"; /// /// Whether or not this bundle contains an install-time asset pack. /// public bool containsInstallTimeAssetPack; /// /// Minimum Android SDK version, e.g. from PlayerSettings.Android. /// public AndroidSdkVersions minSdkVersion; /// /// Options for overriding the default file compression policies. /// public CompressionOptions compressionOptions; /// /// Options for configuring asset only app bundles. /// public AssetOnlyOptions assetOnlyOptions; } // Paths where the bundletool jar may potentially be found. private const string PackagePath = "com.google.android.appbundle/Editor/Tools/bundletool-all.jar"; private const string PackagesPath = "Packages/" + PackagePath; private const string PluginPath = "Assets/GooglePlayPlugins/" + PackagePath; private const string OverridePath = "bundletool-all.jar"; /// /// List of glob patterns specifying files that Unity requires be left uncompressed. /// Similar to PlaybackEngines/AndroidPlayer/Tools/GradleTemplates/mainTemplate.gradle /// private static readonly string[] UnityUncompressedGlob = { "assets/**/*.unity3d", "assets/**/*.ress", "assets/**/*.resource" }; /// /// Make the Bundle Config exported as JSON cleaner by removing the suffix stripping fields /// that are not enabled. /// This also fix an issue with bundletool v0.11.0 which does not consider the config as valid if /// a suffix stripping field is defined (even if not enabled) for any dimension other than texture compression /// format. /// /// The configuration serialized to a JSON string. /// The cleaned JSON string. public static string CleanDisabledSuffixStripping(string configJson) { return configJson.Replace(",\"suffixStripping\":{\"enabled\":false,\"defaultSuffix\":\"\"}", ""); } /// /// BundleTool config optimized for Unity-based apps. /// public static BundletoolConfig.Config MakeConfig( BuildBundleConfigParams configParams, string streamingAssetsPath) { var config = new BundletoolConfig.Config(); // APK download size is smaller when native libraries are uncompressed. uncompressNativeLibraries sets // android:extractNativeLibs="false" in the manifest, which also reduces on-disk for Android 6.0+ devices. config.optimizations.uncompressNativeLibraries.enabled = true; config.compression.uncompressedGlob.AddRange(UnityUncompressedGlob); var compressionOptions = configParams.compressionOptions; if (compressionOptions.UncompressedGlobs != null) { config.compression.uncompressedGlob.AddRange(compressionOptions.UncompressedGlobs); } if (!compressionOptions.CompressStreamingAssets) { config.compression.uncompressedGlob.AddRange(GetStreamingAssetsFileGlobs(streamingAssetsPath)); } if (compressionOptions.CompressInstallTimeAssetPacks) { config.compression.installTimeAssetModuleDefaultCompression = BundletoolConfig.Compressed; } var dimensions = config.optimizations.splitsConfig.splitDimension; // Split on ABI so only one set of native libraries (armeabi-v7a, arm64-v8a, or x86) is sent to a device. dimensions.Add(new BundletoolConfig.SplitDimension { value = BundletoolConfig.Abi, negate = false }); // Do not split on LANGUAGE since Unity games don't store localized strings in the typical Android manner. dimensions.Add(new BundletoolConfig.SplitDimension { value = BundletoolConfig.Language, negate = true }); // Do not split on SCREEN_DENSITY since Unity games don't have per-density resources other than app icons. dimensions.Add( new BundletoolConfig.SplitDimension { value = BundletoolConfig.ScreenDensity, negate = true }); if (configParams.enableTcfTargeting) { dimensions.Add(new BundletoolConfig.SplitDimension { value = BundletoolConfig.TextureCompressionFormat, negate = false, suffixStripping = { enabled = true, defaultSuffix = configParams.defaultTcfSuffix } }); } if (configParams.enableDeviceTierTargeting) { dimensions.Add(new BundletoolConfig.SplitDimension { value = BundletoolConfig.DeviceTier, negate = false, suffixStripping = { enabled = true, defaultSuffix = configParams.defaultDeviceTier } }); } if (configParams.assetOnlyOptions != null) { config.type = BundletoolConfig.AssetOnly; config.asset_modules_config = new BundletoolConfig.AssetModulesConfig { app_version = new List(configParams.assetOnlyOptions.AppVersions), asset_version_tag = configParams.assetOnlyOptions.AssetVersionTag }; return config; } // Bundletool requires the below standaloneConfig when supporting install-time asset packs for pre-Lollipop. if (configParams.containsInstallTimeAssetPack && TextureTargetingTools.IsSdkVersionPreLollipop(configParams.minSdkVersion)) { config.optimizations.standaloneConfig.splitDimension.Add(new BundletoolConfig.SplitDimension { value = BundletoolConfig.Abi, negate = true }); config.optimizations.standaloneConfig.splitDimension.Add(new BundletoolConfig.SplitDimension { value = BundletoolConfig.Language, negate = true }); config.optimizations.standaloneConfig.splitDimension.Add(new BundletoolConfig.SplitDimension { value = BundletoolConfig.ScreenDensity, negate = true }); config.optimizations.standaloneConfig.splitDimension.Add(new BundletoolConfig.SplitDimension { value = BundletoolConfig.TextureCompressionFormat, negate = true }); config.optimizations.standaloneConfig.strip64BitLibraries = true; } return config; } /// /// Searches the streaming assets path and returns a list of globs that includes the contained files relative to /// their final location within the APK. /// Note: Does not include .meta files. /// Visible for testing. /// public static IEnumerable GetStreamingAssetsFileGlobs(string streamingAssetsPath) { if (!Directory.Exists(streamingAssetsPath)) { return new List(); } var streamingAssets = new DirectoryInfo(streamingAssetsPath); // Create a glob for every subdirectory in the streaming assets path. // This is more efficient than returning every file from each of the subdirectories. var directoryGlobs = streamingAssets.GetDirectories("*", SearchOption.TopDirectoryOnly) .Select(directory => Path.Combine(directory.FullName, "**")); // Create a list of files that are located in the root of the streaming assets directory. var fileNames = streamingAssets.GetFiles("*", SearchOption.TopDirectoryOnly) .Where(file => !file.Name.EndsWith(".meta")) .Select(file => file.FullName); // Combine the directory glob list and file list and update the paths to be relative to the final file // locations within the APK. return directoryGlobs.Concat(fileNames) .Select(fullName => "assets/" + fullName.Remove(0, streamingAssetsPath.Length + 1)) .Select(name => name.Replace("\\", "/")); // Support Windows' path separator. } private readonly JavaUtils _javaUtils; private string _streamingAssetsPath; private string _bundletoolJarPath; /// /// Constructor. /// public BundletoolHelper(JavaUtils javaUtils) { _javaUtils = javaUtils; } public virtual bool Initialize(BuildToolLogger buildToolLogger) { if (!_javaUtils.Initialize(buildToolLogger)) { return false; } _streamingAssetsPath = Application.streamingAssetsPath; _bundletoolJarPath = BundletoolJarPath; if (_bundletoolJarPath == null) { buildToolLogger.DisplayErrorDialog("Failed to locate bundletool."); return false; } return true; } /// /// Builds an Android App Bundle at the specified location, overwriting an existing file if necessary. The /// bundle will contain the specified modules, with optional targeting done by texture compression format. /// /// The output Android App Bundle (AAB) file. /// The modules to build inside the bundle. /// Metadata files to include in the bundle. /// Contains parameters needed to generate JSON for --config. /// An error message if there was a problem running bundletool, or null if successful. public virtual string BuildBundle(string outputFile, IEnumerable moduleFiles, IEnumerable metadataFiles, BuildBundleConfigParams buildBundleConfigParams) { var bundleConfigJsonFile = Path.Combine(Path.GetTempPath(), "BundleConfig.json"); var bundleConfig = MakeConfig(buildBundleConfigParams, _streamingAssetsPath); var bundleConfigJsonText = CleanDisabledSuffixStripping(JsonUtility.ToJson(bundleConfig)); File.WriteAllText(bundleConfigJsonFile, bundleConfigJsonText); var metadataArgumentBuilder = new StringBuilder(); foreach (var metadataFile in metadataFiles) { metadataArgumentBuilder.AppendFormat(" --metadata-file={0}", metadataFile); } // TODO(b/128882014): fix bundletool support for quoted paths around moduleFiles. return Run( "build-bundle --overwrite --config={0} --modules={1} --output={2}{3}", CommandLine.QuotePath(bundleConfigJsonFile), string.Join(",", moduleFiles.ToArray()), CommandLine.QuotePath(outputFile), metadataArgumentBuilder.ToString()); } /// /// Builds an APK Set file from the specified Android App Bundle file. /// /// The output file from . /// A ZIP file containing APKs. /// The type of APKs to produce, such as "persistent" or "instant". /// /// Whether or not the --local-testing flag is enabled. This will change the behaviour of Play Asset Delivery so /// that fast-follow and on-demand packs are fetched from storage rather than downloaded. /// /// An error message if there was a problem running bundletool, or null if successful. public virtual string BuildApkSet( string bundleFile, string apkSetFile, BundletoolBuildMode buildMode, bool enableLocalTesting) { return Run( "build-apks --bundle={0} --output={1} --mode={2}{3}", CommandLine.QuotePath(bundleFile), CommandLine.QuotePath(apkSetFile), buildMode.GetModeFlag(), enableLocalTesting ? " --local-testing" : ""); } /// /// Installs the specified APK Set file to device. /// /// A ZIP file containing APKs, produced from "bundletool build-apks". /// Path to the adb executable used to install apks. /// An error message if there was a problem running bundletool, or null if successful. public virtual string InstallApkSet(string apkSetFile, string adbPath) { return Run("install-apks --adb={0} --apks={1}", CommandLine.QuotePath(adbPath), CommandLine.QuotePath(apkSetFile)); } private string Run(string bundletoolCommand, params object[] args) { var bundletoolCommandWithArgs = string.Format(bundletoolCommand, args); var arguments = string.Format("-jar {0} {1}", CommandLine.QuotePath(_bundletoolJarPath), bundletoolCommandWithArgs); var result = CommandLine.Run(_javaUtils.JavaBinaryPath, arguments); return result.exitCode == 0 ? null : result.message; } /// /// Returns the absolute path to the bundletool jar packaged with the plugin. /// Returning the absolute path handles cases where Unity treats relative paths /// differently than the command line. For example Unity treats the Packages/ /// folder as a symlink to Library/PackageCache while the command line does not. /// private static string BundletoolJarPath { get { // GUIDToAssetPath throws an exception if called on a non-main thread. To catch this case early, we // define this variable here, rather than below, where it is used. var guidPath = AssetDatabase.GUIDToAssetPath("c52291e63505c4121a167e6f0121c1b1"); string relativePath = null; // Bundletool is normally included with the .unitypackage or UPM package, but the bundletool used for // builds can be overridden by placing a JAR in the project root, i.e. the parent folder of "Assets". if (File.Exists(OverridePath)) { relativePath = OverridePath; } else if (File.Exists(PackagesPath)) { relativePath = PackagesPath; } else if (File.Exists(PluginPath)) { relativePath = PluginPath; } else if (!string.IsNullOrEmpty(guidPath) && guidPath.EndsWith(".jar") && File.Exists(guidPath)) { relativePath = guidPath; } return relativePath == null ? null : Path.GetFullPath(relativePath); } } } }