/*
* Copyright 2017-present Facebook, Inc.
*
* 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
*
* http://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.
*/
package com.facebook.buck.android;
import com.facebook.buck.android.resources.ResourcesZipBuilder;
import com.facebook.buck.io.ProjectFilesystem;
import com.facebook.buck.model.BuildTargets;
import com.facebook.buck.rules.AbstractBuildRule;
import com.facebook.buck.rules.AddToRuleKey;
import com.facebook.buck.rules.BuildContext;
import com.facebook.buck.rules.BuildRuleParams;
import com.facebook.buck.rules.BuildableContext;
import com.facebook.buck.rules.ExplicitBuildTargetSourcePath;
import com.facebook.buck.rules.SourcePath;
import com.facebook.buck.rules.SourcePathResolver;
import com.facebook.buck.rules.SourcePathRuleFinder;
import com.facebook.buck.step.AbstractExecutionStep;
import com.facebook.buck.step.ExecutionContext;
import com.facebook.buck.step.Step;
import com.facebook.buck.step.StepExecutionResult;
import com.facebook.buck.step.fs.MakeCleanDirectoryStep;
import com.google.common.base.Preconditions;
import com.google.common.collect.FluentIterable;
import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.ImmutableSortedSet;
import com.google.common.collect.TreeMultimap;
import com.google.common.hash.HashCode;
import com.google.common.hash.Hashing;
import com.google.common.io.ByteSource;
import com.google.common.io.Files;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.FileVisitResult;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Collections;
import java.util.Optional;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
/**
* MergeAssets adds the assets for an APK into the output of aapt.
*
* <p>Android's ApkBuilder seemingly would do this, but it doesn't actually compress the assets that
* are added.
*/
public class MergeAssets extends AbstractBuildRule {
// TODO(cjhopman): This should be an input-based rule, but the asset directories are from symlink
// trees and the file hash caches don't currently handle those correctly. The symlink trees
// shouldn't actually be necessary anymore as we can just take the full list of source paths
// directly here.
@AddToRuleKey private final ImmutableSet<SourcePath> assetsDirectories;
@AddToRuleKey private Optional<SourcePath> baseApk;
public MergeAssets(
BuildRuleParams buildRuleParams,
SourcePathRuleFinder ruleFinder,
Optional<SourcePath> baseApk,
ImmutableSortedSet<SourcePath> assetsDirectories) {
super(
buildRuleParams.copyAppendingExtraDeps(
ImmutableSortedSet.copyOf(
ruleFinder.filterBuildRuleInputs(
FluentIterable.from(assetsDirectories).append(baseApk.orElse(null))))));
this.baseApk = baseApk;
this.assetsDirectories = assetsDirectories;
}
@Override
public ImmutableList<Step> getBuildSteps(
BuildContext context, BuildableContext buildableContext) {
SourcePathResolver pathResolver = context.getSourcePathResolver();
TreeMultimap<Path, Path> assets = TreeMultimap.create();
ImmutableList.Builder<Step> steps = ImmutableList.builder();
steps.addAll(
MakeCleanDirectoryStep.of(getProjectFilesystem(), getPathToMergedAssets().getParent()));
steps.add(
new AbstractExecutionStep("finding_assets") {
@Override
public StepExecutionResult execute(ExecutionContext context)
throws IOException, InterruptedException {
for (SourcePath sourcePath : assetsDirectories) {
try {
Path relativePath = pathResolver.getRelativePath(sourcePath);
Path absolutePath = pathResolver.getAbsolutePath(sourcePath);
ProjectFilesystem assetFilesystem = pathResolver.getFilesystem(sourcePath);
assetFilesystem.walkFileTree(
relativePath,
new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
Preconditions.checkState(
!Files.getFileExtension(file.toString()).equals("gz"),
"BUCK doesn't support adding .gz files to assets (%s).",
file);
assets.put(absolutePath, absolutePath.relativize(file));
return super.visitFile(file, attrs);
}
});
} catch (IOException e) {
throw new RuntimeException(e);
}
}
return StepExecutionResult.SUCCESS;
}
});
steps.add(
new MergeAssetsStep(
getProjectFilesystem().getPathForRelativePath(getPathToMergedAssets()),
baseApk.map(pathResolver::getAbsolutePath),
assets));
buildableContext.recordArtifact(getPathToMergedAssets());
return steps.build();
}
@Override
public SourcePath getSourcePathToOutput() {
return new ExplicitBuildTargetSourcePath(getBuildTarget(), getPathToMergedAssets());
}
public Path getPathToMergedAssets() {
return BuildTargets.getGenPath(
getProjectFilesystem(), getBuildTarget(), "%s/merged.assets.ap_");
}
private static class MergeAssetsStep extends AbstractExecutionStep {
// See https://android.googlesource.com/platform/frameworks/base.git/+/nougat-release/tools/aapt/Package.cpp
private static final ImmutableSet<String> NO_COMPRESS_EXTENSIONS =
ImmutableSet.of(
"jpg", "jpeg", "png", "gif", "wav", "mp2", "mp3", "ogg", "aac", "mpg", "mpeg", "mid",
"midi", "smf", "jet", "rtttl", "imy", "xmf", "mp4", "m4a", "m4v", "3gp", "3gpp", "3g2",
"3gpp2", "amr", "awb", "wma", "wmv", "webm", "mkv");
private final Path pathToMergedAssets;
private final Optional<Path> pathToBaseApk;
private final TreeMultimap<Path, Path> assets;
public MergeAssetsStep(
Path pathToMergedAssets, Optional<Path> pathToBaseApk, TreeMultimap<Path, Path> assets) {
super("merging_assets");
this.pathToMergedAssets = pathToMergedAssets;
this.pathToBaseApk = pathToBaseApk;
this.assets = assets;
}
@Override
public StepExecutionResult execute(ExecutionContext context)
throws IOException, InterruptedException {
try (ResourcesZipBuilder output = new ResourcesZipBuilder(pathToMergedAssets)) {
if (pathToBaseApk.isPresent()) {
try (ZipFile base = new ZipFile(pathToBaseApk.get().toFile())) {
for (ZipEntry inputEntry : Collections.list(base.entries())) {
String extension = Files.getFileExtension(inputEntry.getName());
// Only compress if aapt compressed it and the extension looks compressible.
// This is a workaround for aapt2 compressing everything.
boolean shouldCompress =
inputEntry.getMethod() != ZipEntry.STORED
&& !NO_COMPRESS_EXTENSIONS.contains(extension);
try (InputStream stream = base.getInputStream(inputEntry)) {
output.addEntry(
stream,
inputEntry.getSize(),
inputEntry.getCrc(),
inputEntry.getName(),
shouldCompress ? Deflater.BEST_COMPRESSION : 0,
inputEntry.isDirectory());
}
}
}
}
Path assetsZipRoot = Paths.get("assets");
for (Path assetRoot : assets.keySet()) {
for (Path asset : assets.get(assetRoot)) {
ByteSource assetSource = Files.asByteSource(assetRoot.resolve(asset).toFile());
HashCode assetCrc32 = assetSource.hash(Hashing.crc32());
String extension = Files.getFileExtension(asset.toString());
int compression =
NO_COMPRESS_EXTENSIONS.contains(extension) ? 0 : Deflater.BEST_COMPRESSION;
try (InputStream assetStream = assetSource.openStream()) {
output.addEntry(
assetStream,
assetSource.size(),
// CRC32s are only 32 bits, but setCrc() takes a
// long. Avoid sign-extension here during the
// conversion to long by masking off the high 32 bits.
assetCrc32.asInt() & 0xFFFFFFFFL,
assetsZipRoot.resolve(asset).toString(),
compression,
false);
}
}
}
}
return StepExecutionResult.SUCCESS;
}
}
}