/*
* Copyright (C) 2015 The Android Open Source Project
*
* 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.android.build.gradle.internal.tasks;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.build.gradle.internal.scope.ConventionMappingHelper;
import com.android.build.gradle.internal.scope.TaskConfigAction;
import com.android.build.gradle.internal.scope.VariantScope;
import com.android.build.gradle.tasks.JavaResourcesProvider;
import com.android.builder.model.PackagingOptions;
import com.android.builder.signing.SignedJarBuilder;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.io.Files;
import org.gradle.api.tasks.InputDirectory;
import org.gradle.api.tasks.Nested;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.OutputDirectory;
import org.gradle.api.tasks.ParallelizableTask;
import org.gradle.api.tasks.TaskAction;
import org.gradle.api.tasks.incremental.IncrementalTaskInputs;
import org.gradle.api.tasks.incremental.InputFileDetails;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.Callable;
/**
* Merges java resources from temporary expansion folders (created from the packaged jars
* resources and source folder java resources) into a single output directory that can be used
* during obfuscation and packaging.
*
* This task is the default {@link JavaResourcesProvider} to provide merged java resources to
* the final variant packaging step. However, if the variant obfuscation is turned on, some of
* these resources packages might need to be adapted to match the obfuscated code. In such
* a scenario, the {@link JavaResourcesProvider} will become the task responsible for obfuscation.
*/
@ParallelizableTask
public class MergeJavaResourcesTask extends DefaultAndroidTask implements JavaResourcesProvider {
@Nested
@Optional
@Nullable
public PackagingOptions packagingOptions;
@InputDirectory
@Optional
@Nullable
public File getSourceJavaResourcesFolder() {
return sourceJavaResourcesFolder;
}
@InputDirectory
@Optional
@Nullable
public File getPackagedJarsJavaResourcesFolder() {
return packagedJarsJavaResourcesFolder;
}
@Nullable
private FileFilter packagingOptionsFilter;
@SuppressWarnings({"UnusedDeclaration"})
@Nullable
private File sourceJavaResourcesFolder;
@SuppressWarnings({"UnusedDeclaration"})
@Nullable
private File packagedJarsJavaResourcesFolder;
@SuppressWarnings({"UnusedDeclaration"})
@Nullable
private File outputDir;
@OutputDirectory
@Nullable
public File getOutputDir() {
return outputDir;
}
public List<File> getExpandedFolders() {
ImmutableList.Builder<File> builder = ImmutableList.builder();
if (getSourceJavaResourcesFolder() != null) {
builder.add(getSourceJavaResourcesFolder());
}
if (getPackagedJarsJavaResourcesFolder() != null) {
builder.add(getPackagedJarsJavaResourcesFolder());
}
return builder.build();
}
@TaskAction
void extractJavaResources(IncrementalTaskInputs incrementalTaskInputs) {
if (packagingOptionsFilter == null || getOutputDir() == null) {
throw new RuntimeException(
"Internal error, packagingOptionsFilter or outputDir is null");
}
incrementalTaskInputs.outOfDate(new org.gradle.api.Action<InputFileDetails>() {
@Override
public void execute(InputFileDetails inputFileDetails) {
try {
packagingOptionsFilter.handleChanged(
getOutputDir(), inputFileDetails.getFile());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
});
incrementalTaskInputs.removed(new org.gradle.api.Action<InputFileDetails>() {
@Override
public void execute(InputFileDetails inputFileDetails) {
try {
packagingOptionsFilter.handleRemoved
(getOutputDir(), inputFileDetails.getFile());
} catch (IOException e) {
throw new RuntimeException(e);
}
}
});
}
/**
* Defines a file filter contract which will use {@link PackagingOptions} to take appropriate
* action.
*/
@VisibleForTesting
static final class FileFilter implements SignedJarBuilder.IZipEntryFilter {
/**
* User's setting for a particular archive entry. This is expressed in the build.gradle
* DSL and used by this filter to determine file merging behaviors.
*/
private enum PackagingOption {
/**
* no action was described for archive entry.
*/
NONE,
/**
* merge all archive entries with the same archive path.
*/
MERGE,
/**
* pick to first archive entry with that archive path (not stable).
*/
PICK_FIRST,
/**
* exclude all archive entries with that archive path.
*/
EXCLUDE
}
@Nullable
private final PackagingOptions packagingOptions;
@NonNull
private final Set<String> excludes;
@NonNull
private final Set<String> pickFirsts;
@NonNull
private final MergeJavaResourcesTask owner;
public FileFilter(@NonNull MergeJavaResourcesTask owner,
@Nullable PackagingOptions packagingOptions) {
this.owner = owner;
this.packagingOptions = packagingOptions;
excludes = this.packagingOptions != null ? this.packagingOptions.getExcludes() :
Collections.<String>emptySet();
pickFirsts = this.packagingOptions != null ? this.packagingOptions.getPickFirsts() :
Collections.<String>emptySet();
}
/**
* Implementation of the {@link SignedJarBuilder.IZipEntryFilter} contract which only
* cares about copying or ignoring files since merging is handled differently.
* @param archivePath the archive file path of the entry
* @return true if the archive entry satisfies the filter, false otherwise.
* @throws ZipAbortException
*/
@Override
public boolean checkEntry(@NonNull String archivePath)
throws ZipAbortException {
PackagingOption packagingOption = getPackagingAction(archivePath);
switch(packagingOption) {
case EXCLUDE:
return false;
case PICK_FIRST:
List<File> allFiles = getAllFiles(archivePath);
return allFiles.isEmpty();
case MERGE:
case NONE:
return true;
default:
throw new RuntimeException("Unhandled PackagingOption " + packagingOption);
}
}
/**
* Notification of an incremental file changed since last successful run of the task.
*
* Usually, we just copy the changed file into the merged folder. However, if the user
* specified {@link PackagingOption#PICK_FIRST}, the file will only be copied if it the
* first pick. Also, if the user specified {@link PackagingOption#MERGE}, all the files
* with the same entry archive path will be re-merged.
*
* @param outputDir merged resources folder.
* @param changedFile changed file located in a temporary expansion folder
* @throws IOException
*/
void handleChanged(@NonNull File outputDir, @NonNull File changedFile)
throws IOException {
String archivePath = getArchivePath(changedFile);
PackagingOption packagingOption = getPackagingAction(archivePath);
switch (packagingOption) {
case EXCLUDE:
return;
case MERGE:
// one of the merged file has changed, re-merge all of them.
mergeAll(outputDir, archivePath);
return;
case PICK_FIRST:
copy(changedFile, outputDir, archivePath);
return;
case NONE:
copy(changedFile, outputDir, archivePath);
}
}
/**
* Notification of a file removal.
*
* file was removed, we need to check that it was not a pickFirst item (since we
* may now need to pick another one) or a merged item since we would need to re-merge
* all remaining items.
*
* @param outputDir expected merged output directory.
* @param removedFile removed file from the temporary resources folders.
* @throws IOException
*/
public void handleRemoved(@NonNull File outputDir, @NonNull File removedFile)
throws IOException {
String archivePath = getArchivePath(removedFile);
// first delete the output file, it will be eventually replaced.
File outFile = new File(outputDir, archivePath);
if (outFile.exists()) {
if (!outFile.delete()) {
throw new IOException("Cannot delete " + outFile.getAbsolutePath());
}
}
FileFilter.PackagingOption itemPackagingOption = getPackagingAction(archivePath);
switch(itemPackagingOption) {
case PICK_FIRST:
// this was a picked up item, make sure we copy the first still available
com.google.common.base.Optional<File> firstPick = getFirstPick(archivePath);
if (firstPick.isPresent()) {
copy(firstPick.get(), outputDir, archivePath);
}
return;
case MERGE:
// re-merge all
mergeAll(outputDir, archivePath);
return;
case EXCLUDE:
case NONE:
// do nothing
return;
default:
throw new RuntimeException("Unhandled package option"
+ itemPackagingOption);
}
}
private static void copy(@NonNull File inputFile,
@NonNull File outputDir,
@NonNull String archivePath) throws IOException {
File outputFile = new File(outputDir, archivePath);
createParentFolderIfNecessary(outputFile);
Files.copy(inputFile, outputFile);
}
private void mergeAll(@NonNull File outputDir, @NonNull String archivePath)
throws IOException {
File outputFile = new File(outputDir, archivePath);
if (outputFile.exists() && !outputFile.delete()) {
throw new RuntimeException("Cannot delete " + outputFile);
}
createParentFolderIfNecessary(outputFile);
List<File> allFiles = getAllFiles(archivePath);
if (!allFiles.isEmpty()) {
OutputStream os = null;
try {
os = new BufferedOutputStream(new FileOutputStream(outputFile));
// take each file in order and merge them.
for (File file : allFiles) {
Files.copy(file, os);
}
} finally {
if (os != null) {
os.close();
}
}
}
}
private static void createParentFolderIfNecessary(@NonNull File outputFile) {
File parentFolder = outputFile.getParentFile();
if (!parentFolder.exists()) {
if (!parentFolder.mkdirs()) {
throw new RuntimeException("Cannot create folder " + parentFolder);
}
}
}
/**
* Return the first file from the temporary expansion folders that satisfy the archive path.
* @param archivePath the entry archive path.
* @return the first file reference of {@link com.google.common.base.Optional#absent()} if
* none exist in any temporary expansion folders.
*/
@NonNull
private com.google.common.base.Optional<File> getFirstPick(
@NonNull final String archivePath) {
return com.google.common.base.Optional.fromNullable(
forEachExpansionFolder(new FolderAction() {
@Nullable
@Override
public File on(File folder) {
File expandedFile = new File(folder, archivePath);
if (expandedFile.exists()) {
return expandedFile;
}
return null;
}
}));
}
/**
* Returns all files from temporary expansion folders with the same archive path.
* @param archivePath the entry archive path.
* @return a list possibly empty of {@link File}s that satisfy the archive path.
*/
@NonNull
private List<File> getAllFiles(@NonNull final String archivePath) {
final ImmutableList.Builder<File> matchingFiles = ImmutableList.builder();
forEachExpansionFolder(new FolderAction() {
@Nullable
@Override
public File on(File folder) {
File expandedFile = new File(folder, archivePath);
if (expandedFile.exists()) {
matchingFiles.add(expandedFile);
}
return null;
}
});
return matchingFiles.build();
}
/**
* An action on a folder.
*/
private interface FolderAction {
/**
* Perform an action on a folder and stop the processing if something is returned
* @param folder the folder to perform the action on.
* @return a file to stop processing or null to continue to the next expansion folder
* if any.
*/
@Nullable
File on(File folder);
}
/**
* Perform the passed action on each expansion folder.
* @param action the action to perform on each folder.
* @return a file if any action returned a value, or null if none returned a value.
*/
@Nullable
private File forEachExpansionFolder(@NonNull FolderAction action) {
for (File expansionParentFolder : owner.getExpandedFolders()) {
File[] expansionFolders = expansionParentFolder.listFiles();
if (expansionFolders != null) {
for (File expansionFolder : expansionFolders) {
if (expansionFolder.isDirectory()) {
File value = action.on(expansionFolder);
if (value != null) {
return value;
}
}
}
}
}
return null;
}
/**
* Returns the expansion folder for an expanded file. This represents the location
* where the packaged jar our source directories java resources were extracted into.
* @param expandedFile the java resource file.
* @return the expansion folder used to extract the java resource into.
*/
@NonNull
private File getExpansionFolder(@NonNull final File expandedFile) {
File expansionFolder = forEachExpansionFolder(new FolderAction() {
@Nullable
@Override
public File on(File folder) {
return expandedFile.getAbsolutePath().startsWith(folder.getAbsolutePath())
? folder : null;
}
});
if (expansionFolder == null) {
throw new RuntimeException("Cannot determine expansion folder for " + expandedFile
+ " with folders " + Joiner.on(",").join(owner.getExpandedFolders()));
}
return expansionFolder;
}
/**
* Determines the archive entry path relative to its expansion folder. The archive entry
* path is the path that was used to save the entry in the original .jar file that got
* expanded in the expansion folder.
* @param expandedFile the expanded file to find the relative archive entry from.
* @return the expanded file relative path to its expansion folder.
*/
@NonNull
private String getArchivePath(@NonNull File expandedFile) {
File expansionFolder = getExpansionFolder(expandedFile);
return expandedFile.getAbsolutePath()
.substring(expansionFolder.getAbsolutePath().length() + 1);
}
/**
* Determine the user's intention for a particular archive entry.
* @param archivePath the archive entry
* @return a {@link FileFilter.PackagingOption} as provided by the user in the build.gradle
*/
@NonNull
private PackagingOption getPackagingAction(@NonNull String archivePath) {
if (packagingOptions != null) {
if (pickFirsts.contains(archivePath)) {
return PackagingOption.PICK_FIRST;
}
if (packagingOptions.getMerges().contains(archivePath)) {
return PackagingOption.MERGE;
}
if (excludes.contains(archivePath)) {
return PackagingOption.EXCLUDE;
}
}
return PackagingOption.NONE;
}
}
@NonNull
@Override
public ImmutableList<JavaResourcesLocation> getJavaResourcesLocations() {
return ImmutableList.of(new JavaResourcesLocation(Type.FOLDER, getOutputDir()));
}
public static class Config implements TaskConfigAction<MergeJavaResourcesTask> {
private final VariantScope scope;
public Config(VariantScope variantScope) {
this.scope = variantScope;
}
@Override
public String getName() {
return scope.getTaskName("merge", "JavaResources");
}
@Override
public Class<MergeJavaResourcesTask> getType() {
return MergeJavaResourcesTask.class;
}
@Override
public void execute(MergeJavaResourcesTask mergeJavaResourcesTask) {
mergeJavaResourcesTask.setVariantName(scope.getVariantConfiguration().getFullName());
ConventionMappingHelper.map(mergeJavaResourcesTask, "sourceJavaResourcesFolder",
new Callable<File>() {
@Override
public File call() throws Exception {
return scope.getSourceFoldersJavaResDestinationDir().exists()
? scope.getSourceFoldersJavaResDestinationDir()
: null;
}
});
ConventionMappingHelper.map(mergeJavaResourcesTask, "packagedJarsJavaResourcesFolder",
new Callable<File>() {
@Override
public File call() throws Exception {
return scope.getPackagedJarsJavaResDestinationDir().exists()
? scope.getPackagedJarsJavaResDestinationDir()
: null;
}
});
File outputDir = scope.getJavaResourcesDestinationDir();
if (!outputDir.exists() && !outputDir.mkdirs()) {
throw new RuntimeException("Cannot create output directory " + outputDir);
}
mergeJavaResourcesTask.outputDir = outputDir;
PackagingOptions packagingOptions =
scope.getGlobalScope().getExtension().getPackagingOptions();
mergeJavaResourcesTask.packagingOptionsFilter =
new FileFilter(mergeJavaResourcesTask, packagingOptions);
mergeJavaResourcesTask.packagingOptions = packagingOptions;
scope.setPackagingOptionsFilter(mergeJavaResourcesTask.packagingOptionsFilter);
}
}
}