/*
 * Copyright (C) 2016 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.tasks;

import static com.google.common.base.Preconditions.checkNotNull;

import com.android.SdkConstants;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.build.gradle.internal.annotations.PackageFile;
import com.android.build.gradle.internal.dsl.AbiSplitOptions;
import com.android.build.gradle.internal.dsl.CoreSigningConfig;
import com.android.build.gradle.internal.dsl.PackagingOptions;
import com.android.build.gradle.internal.incremental.DexPackagingPolicy;
import com.android.build.gradle.internal.incremental.InstantRunBuildContext;
import com.android.build.gradle.internal.incremental.InstantRunPatchingPolicy;
import com.android.build.gradle.internal.packaging.ApkCreatorFactories;
import com.android.build.gradle.internal.scope.ConventionMappingHelper;
import com.android.build.gradle.internal.scope.PackagingScope;
import com.android.build.gradle.internal.scope.TaskConfigAction;
import com.android.build.gradle.internal.tasks.FileSupplier;
import com.android.build.gradle.internal.tasks.IncrementalTask;
import com.android.build.gradle.internal.transforms.InstantRunSlicer;
import com.android.build.gradle.internal.variant.SplitHandlingPolicy;
import com.android.builder.files.FileCacheByPath;
import com.android.builder.files.IncrementalRelativeFileSets;
import com.android.builder.files.RelativeFile;
import com.android.builder.internal.packaging.IncrementalPackager;
import com.android.builder.internal.utils.CachedFileContents;
import com.android.builder.internal.utils.IOExceptionWrapper;
import com.android.builder.model.AaptOptions;
import com.android.builder.model.ApiVersion;
import com.android.builder.packaging.ApkCreatorFactory;
import com.android.builder.packaging.PackagerException;
import com.android.builder.packaging.PackagingUtils;
import com.android.ide.common.res2.FileStatus;
import com.android.ide.common.signing.CertificateInfo;
import com.android.ide.common.signing.KeystoreHelper;
import com.android.ide.common.signing.KeytoolException;
import com.android.utils.FileUtils;
import com.google.common.base.Functions;
import com.google.common.base.MoreObjects;
import com.google.common.base.Predicate;
import com.google.common.base.Predicates;
import com.google.common.base.Verify;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import com.google.common.collect.Maps;
import com.google.common.collect.Sets;
import com.google.common.io.Closer;
import com.google.common.io.Files;

import org.gradle.api.Task;
import org.gradle.api.tasks.Input;
import org.gradle.api.tasks.InputDirectory;
import org.gradle.api.tasks.InputFile;
import org.gradle.api.tasks.InputFiles;
import org.gradle.api.tasks.Nested;
import org.gradle.api.tasks.Optional;
import org.gradle.api.tasks.OutputFile;
import org.gradle.tooling.BuildException;

import java.io.BufferedOutputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.security.PrivateKey;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Properties;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

/**
 * Abstract task to package an Android artifact.
 */
public abstract class PackageAndroidArtifact extends IncrementalTask implements FileSupplier {

    public static final String INSTANT_RUN_PACKAGES_PREFIX = "instant-run";

    // ----- PUBLIC TASK API -----

    @InputFile
    public File getResourceFile() {
        return resourceFile;
    }

    public void setResourceFile(File resourceFile) {
        this.resourceFile = resourceFile;
    }

    @OutputFile
    public File getOutputFile() {
        return outputFile;
    }

    public void setOutputFile(File outputFile) {
        this.outputFile = outputFile;
    }

    @Input
    public Set<String> getAbiFilters() {
        return abiFilters;
    }

    public void setAbiFilters(Set<String> abiFilters) {
        this.abiFilters = abiFilters;
    }

    // ----- PRIVATE TASK API -----

    @InputFiles
    @Optional
    public Collection<File> getJavaResourceFiles() {
        return javaResourceFiles;
    }

    @InputFiles
    @Optional
    public Collection<File> getJniFolders() {
        return jniFolders;
    }

    private File resourceFile;

    private Set<File> dexFolders;

    private File assets;

    @InputFiles
    @Optional
    public Set<File> getDexFolders() {
        return dexFolders;
    }

    public void setDexFolders(Set<File> dexFolders) {
        this.dexFolders = dexFolders;
    }

    @InputDirectory
    public File getAssets() {
        return assets;
    }

    public void setAssets(File assets) {
        this.assets = assets;
    }

    /** list of folders and/or jars that contain the merged java resources. */
    private Set<File> javaResourceFiles;
    private Set<File> jniFolders;

    @PackageFile
    private File outputFile;

    private Set<String> abiFilters;

    private boolean debugBuild;
    private boolean jniDebugBuild;

    private CoreSigningConfig signingConfig;

    private PackagingOptions packagingOptions;

    private ApiVersion minSdkVersion;

    protected InstantRunBuildContext instantRunContext;

    protected File instantRunSupportDir;

    protected DexPackagingPolicy dexPackagingPolicy;

    protected File manifest;

    protected AaptOptions aaptOptions;

    protected InstantRunBuildContext.FileType instantRunFileType =
            InstantRunBuildContext.FileType.MAIN;

    /**
     * Name of directory, inside the intermediate directory, where zip caches are kept.
     */
    private static final String ZIP_DIFF_CACHE_DIR = "zip-cache";

    /**
     * Zip caches to allow incremental updates.
     */
    protected FileCacheByPath cacheByPath;

    @Input
    public boolean getJniDebugBuild() {
        return jniDebugBuild;
    }

    public void setJniDebugBuild(boolean jniDebugBuild) {
        this.jniDebugBuild = jniDebugBuild;
    }

    @Input
    public boolean getDebugBuild() {
        return debugBuild;
    }

    public void setDebugBuild(boolean debugBuild) {
        this.debugBuild = debugBuild;
    }

    @Nested
    @Optional
    public CoreSigningConfig getSigningConfig() {
        return signingConfig;
    }

    public void setSigningConfig(CoreSigningConfig signingConfig) {
        this.signingConfig = signingConfig;
    }

    @Nested
    public PackagingOptions getPackagingOptions() {
        return packagingOptions;
    }

    public void setPackagingOptions(PackagingOptions packagingOptions) {
        this.packagingOptions = packagingOptions;
    }

    @Input
    public int getMinSdkVersion() {
        return this.minSdkVersion.getApiLevel();
    }

    public void setMinSdkVersion(ApiVersion version) {
        this.minSdkVersion = version;
    }

    @Input
    String getDexPackagingPolicy() {
        return dexPackagingPolicy.toString();
    }

    /*
     * We don't really use this. But this forces a full build if the native packaging mode changes.
     */
    @Input
    public String getNativeLibrariesPackagingModeName() {
        return PackagingUtils.getNativeLibrariesLibrariesPackagingMode(manifest).toString();
    }

    @Input
    public Collection<String> getNoCompressExtensions() {
        return MoreObjects.firstNonNull(aaptOptions.getNoCompress(), Collections.<String>emptyList());
    }

    protected Predicate<String> getNoCompressPredicate() {
        return PackagingUtils.getNoCompressPredicate(aaptOptions, manifest);
    }

    @Override
    protected void doFullTaskAction() throws IOException {
        /*
         * Clear the cache to make sure we have do not do an incremental build.
         */
        cacheByPath.clear();

        /*
         * Also clear the intermediate build directory. We don't know if anything is in there and
         * since this is a full build, we don't want to get any interference from previous state.
         */
        FileUtils.deleteDirectoryContents(getIncrementalFolder());

        Set<File> androidResources = new HashSet<>();
        File androidResourceFile = getResourceFile();
        if (androidResourceFile != null) {
            androidResources.add(androidResourceFile);
        }

        /*
         * Additionally, make sure we have no previous package, if it exists.
         */
        getOutputFile().delete();

        ImmutableMap<RelativeFile, FileStatus> updatedDex =
                IncrementalRelativeFileSets.fromZipsAndDirectories(getDexFolders());
        ImmutableMap<RelativeFile, FileStatus> updatedJavaResources =
                    IncrementalRelativeFileSets.fromZipsAndDirectories(getJavaResourceFiles());
        ImmutableMap<RelativeFile, FileStatus> updatedAssets =
                    IncrementalRelativeFileSets.fromZipsAndDirectories(
                            Collections.singleton(getAssets()));
        ImmutableMap<RelativeFile, FileStatus> updatedAndroidResources =
                IncrementalRelativeFileSets.fromZipsAndDirectories(androidResources);
        ImmutableMap<RelativeFile, FileStatus> updatedJniResources=
                IncrementalRelativeFileSets.fromZipsAndDirectories(getJniFolders());

        doTask(
                updatedDex,
                updatedJavaResources,
                updatedAssets,
                updatedAndroidResources,
                updatedJniResources);

        /*
         * Update the known files.
         */
        KnownFilesSaveData saveData = KnownFilesSaveData.make(getIncrementalFolder());
        saveData.setInputSet(updatedDex.keySet(), InputSet.DEX);
        saveData.setInputSet(updatedJavaResources.keySet(), InputSet.JAVA_RESOURCE);
        saveData.setInputSet(updatedAssets.keySet(), InputSet.ASSET);
        saveData.setInputSet(updatedAndroidResources.keySet(), InputSet.ANDROID_RESOURCE);
        saveData.setInputSet(updatedJniResources.keySet(), InputSet.NATIVE_RESOURCE);
        saveData.saveCurrentData();
    }

    /**
     * Packages the application incrementally. In case of instant run packaging, this is not a
     * perfectly incremental task as some files are always rewritten even if no change has
     * occurred.
     *
     * @param changedDex incremental dex packaging data
     * @param changedJavaResources incremental java resources
     * @param changedAssets incremental assets
     * @param changedAndroidResources incremental Android resource
     * @param changedNLibs incremental native libraries changed
     * @throws IOException failed to package the APK
     */
    private void doTask(
            @NonNull ImmutableMap<RelativeFile, FileStatus> changedDex,
            @NonNull ImmutableMap<RelativeFile, FileStatus> changedJavaResources,
            @NonNull ImmutableMap<RelativeFile, FileStatus> changedAssets,
            @NonNull ImmutableMap<RelativeFile, FileStatus> changedAndroidResources,
            @NonNull ImmutableMap<RelativeFile, FileStatus> changedNLibs)
            throws IOException {

        ImmutableMap.Builder<RelativeFile, FileStatus> javaResourcesForApk =
                ImmutableMap.builder();
        javaResourcesForApk.putAll(changedJavaResources);

        Collection<File> instantRunDexBaseFiles;
        switch(dexPackagingPolicy) {
            case INSTANT_RUN_SHARDS_IN_SINGLE_APK:
                /*
                 * If we're doing instant run, then we don't want to treat all dex archives
                 * as dex archives for packaging. We will package some of the dex files as
                 * resources.
                 *
                 * All dex files in directories whose name contains INSTANT_RUN_PACKAGES_PREFIX
                 * are kept in the apk as dex files. All other dex files are placed as
                 * resources as defined by makeInstantRunResourcesFromDex.
                 */
                instantRunDexBaseFiles = getDexFolders()
                        .stream()
                        .filter(input -> input.getName().contains(INSTANT_RUN_PACKAGES_PREFIX))
                        .collect(Collectors.toSet());
                Iterable<File> nonInstantRunDexBaseFiles = getDexFolders()
                        .stream()
                        .filter(f -> !instantRunDexBaseFiles.contains(f))
                        .collect(Collectors.toSet());

                ImmutableMap<RelativeFile, FileStatus> newInstantRunResources =
                        makeInstantRunResourcesFromDex(nonInstantRunDexBaseFiles);

                @SuppressWarnings("unchecked")
                ImmutableMap<RelativeFile, FileStatus> updatedChangedResources =
                        IncrementalRelativeFileSets.union(
                                Sets.newHashSet(changedJavaResources, newInstantRunResources));
                changedJavaResources = updatedChangedResources;

                changedDex = ImmutableMap.copyOf(
                        Maps.filterKeys(
                                changedDex,
                                Predicates.compose(
                                        Predicates.in(instantRunDexBaseFiles),
                                        RelativeFile.EXTRACT_BASE
                                )));

                break;
            case INSTANT_RUN_MULTI_APK:
                instantRunDexBaseFiles = getDexFolders()
                        .stream()
                        .filter(input -> input.getName().contains(InstantRunSlicer.MAIN_SLICE_NAME))
                        .collect(Collectors.toSet());
                changedDex = ImmutableMap.copyOf(
                        Maps.filterKeys(
                                changedDex,
                                Predicates.compose(
                                        Predicates.in(instantRunDexBaseFiles),
                                        RelativeFile.EXTRACT_BASE
                                )));

            case STANDARD:
                break;
            default:
                throw new RuntimeException(
                        "Unhandled DexPackagingPolicy : " + getDexPackagingPolicy());
        }

        PrivateKey key;
        X509Certificate certificate;
        boolean v1SigningEnabled;
        boolean v2SigningEnabled;

        try {
            if (signingConfig != null && signingConfig.isSigningReady()) {
                CertificateInfo certificateInfo =
                        KeystoreHelper.getCertificateInfo(
                                signingConfig.getStoreType(),
                                checkNotNull(signingConfig.getStoreFile()),
                                checkNotNull(signingConfig.getStorePassword()),
                                checkNotNull(signingConfig.getKeyPassword()),
                                checkNotNull(signingConfig.getKeyAlias()));
                key = certificateInfo.getKey();
                certificate = certificateInfo.getCertificate();
                v1SigningEnabled = signingConfig.isV1SigningEnabled();
                v2SigningEnabled = signingConfig.isV2SigningEnabled();
            } else {
                key = null;
                certificate = null;
                v1SigningEnabled = false;
                v2SigningEnabled = false;
            }

            ApkCreatorFactory.CreationData creationData =
                    new ApkCreatorFactory.CreationData(
                            getOutputFile(),
                            key,
                            certificate,
                            v1SigningEnabled,
                            v2SigningEnabled,
                            null, // BuiltBy
                            getBuilder().getCreatedBy(),
                            getMinSdkVersion(),
                            PackagingUtils.getNativeLibrariesLibrariesPackagingMode(manifest),
                            getNoCompressPredicate()::apply);

            try (IncrementalPackager packager = createPackager(creationData)) {
                packager.updateDex(changedDex);
                packager.updateJavaResources(changedJavaResources);
                packager.updateAssets(changedAssets);
                packager.updateAndroidResources(changedAndroidResources);
                packager.updateNativeLibraries(changedNLibs);
            }
        } catch (PackagerException | KeytoolException e) {
            throw new RuntimeException(e);
        }

        /*
         * Save all used zips in the cache.
         */
        Stream.concat(
            changedDex.keySet().stream(),
            Stream.concat(
                    changedJavaResources.keySet().stream(),
                    Stream.concat(
                            changedAndroidResources.keySet().stream(),
                            changedNLibs.keySet().stream())))
            .map(RelativeFile::getBase)
            .filter(File::isFile)
            .distinct()
            .forEach((File f) -> {
                try {
                    cacheByPath.add(f);
                } catch (IOException e) {
                    throw new IOExceptionWrapper(e);
                }
            });

        // Mark this APK production, this will eventually be saved when instant-run is enabled.
        // this might get overridden if the apk is signed/aligned.
        try {
            instantRunContext.addChangedFile(instantRunFileType, getOutputFile());
        } catch (IOException e) {
            throw new BuildException(e.getMessage(), e);
        }
    }

    @NonNull
    private IncrementalPackager createPackager(ApkCreatorFactory.CreationData creationData)
            throws PackagerException, IOException {
        return new IncrementalPackager(
                creationData,
                getIncrementalFolder(),
                ApkCreatorFactories.fromProjectProperties(getProject(), getDebugBuild()),
                getAbiFilters(),
                getJniDebugBuild());
    }

    @Override
    protected boolean isIncremental() {
        return true;
    }

    @Override
    protected void doIncrementalTaskAction(Map<File, FileStatus> changedInputs) throws IOException {
        checkNotNull(changedInputs, "changedInputs == null");

        Set<File> androidResources = new HashSet<>();
        File androidResourceFile = getResourceFile();
        if (androidResourceFile != null) {
            androidResources.add(androidResourceFile);
        }

        KnownFilesSaveData saveData = KnownFilesSaveData.make(getIncrementalFolder());

        ImmutableMap<RelativeFile, FileStatus> changedDexFiles =
                getChangedInputs(
                        changedInputs,
                        saveData,
                        InputSet.DEX,
                        getDexFolders(),
                        cacheByPath);

        ImmutableMap<RelativeFile, FileStatus> changedJavaResources =
                getChangedInputs(
                        changedInputs,
                        saveData,
                        InputSet.JAVA_RESOURCE,
                        getJavaResourceFiles(),
                        cacheByPath);

        ImmutableMap<RelativeFile, FileStatus> changedAssets =
                getChangedInputs(
                        changedInputs,
                        saveData,
                        InputSet.ASSET,
                        Collections.singleton(getAssets()),
                        cacheByPath);

        ImmutableMap<RelativeFile, FileStatus> changedAndroidResources =
                getChangedInputs(
                        changedInputs,
                        saveData,
                        InputSet.ANDROID_RESOURCE,
                        androidResources,
                        cacheByPath);

        ImmutableMap<RelativeFile, FileStatus> changedNLibs =
                getChangedInputs(
                        changedInputs,
                        saveData,
                        InputSet.NATIVE_RESOURCE,
                        getJniFolders(),
                        cacheByPath);


        doTask(
                changedDexFiles,
                changedJavaResources,
                changedAssets,
                changedAndroidResources,
                changedNLibs);

        /*
         * Removed cached versions of deleted zip files because we no longer need to compute diffs.
         */
        changedInputs.keySet().stream()
                .filter(f -> !f.exists())
                .forEach(f -> {
                    try {
                        cacheByPath.remove(f);
                    } catch (IOException e) {
                        throw new IOExceptionWrapper(e);
                    }
                });

        /*
         * Update the save data keep files.
         */
        ImmutableMap<RelativeFile, FileStatus> allDex =
                IncrementalRelativeFileSets.fromZipsAndDirectories(getDexFolders());
        ImmutableMap<RelativeFile, FileStatus> allJavaResources =
                IncrementalRelativeFileSets.fromZipsAndDirectories(getJavaResourceFiles());
        ImmutableMap<RelativeFile, FileStatus> allAndroidResources =
                IncrementalRelativeFileSets.fromZipsAndDirectories(androidResources);
        ImmutableMap<RelativeFile, FileStatus> allJniResources=
                IncrementalRelativeFileSets.fromZipsAndDirectories(getJniFolders());

        saveData.setInputSet(allDex.keySet(), InputSet.DEX);
        saveData.setInputSet(allJavaResources.keySet(), InputSet.JAVA_RESOURCE);
        saveData.setInputSet(allAndroidResources.keySet(), InputSet.ANDROID_RESOURCE);
        saveData.setInputSet(allJniResources.keySet(), InputSet.NATIVE_RESOURCE);
        saveData.saveCurrentData();
    }

    /**
     * Obtains all changed inputs of a given input set. Given a set of files mapped to their
     * changed status, this method returns a list of changes computed as follows:
     *
     * <ol>
     *     <li>Changed inputs are split into deleted and non-deleted inputs. This separation is
     *     needed because deleted inputs may no longer be mappable to any {@link InputSet} just
     *     by looking at the file path, without using {@link KnownFilesSaveData}.
     *     <li>Deleted inputs are filtered through {@link KnownFilesSaveData} to get only those
     *     whose input set matches {@code inputSet}.
     *     <li>Non-deleted inputs are processed through
     *     {@link IncrementalRelativeFileSets#makeFromBaseFiles(Collection, Map, FileCacheByPath)}
     *     to obtain the incremental file changes.
     *     <li>The results of processed deleted and non-deleted are merged and returned.
     * </ol>
     *
     * @param changedInputs all changed inputs
     * @param saveData the save data with all input sets from last run
     * @param inputSet the input set to filter
     * @param baseFiles the base files of the input set
     * @param cacheByPath where to cache files
     * @return the status of all relative files in the input set
     */
    @NonNull
    private ImmutableMap<RelativeFile, FileStatus> getChangedInputs(
            @NonNull Map<File, FileStatus> changedInputs,
            @NonNull KnownFilesSaveData saveData,
            @NonNull InputSet inputSet,
            @NonNull Collection<File> baseFiles,
            @NonNull FileCacheByPath cacheByPath)
            throws IOException {

        /*
         * Figure out changes to deleted files.
         */
        Set<File> deletedFiles =
                Maps.filterValues(changedInputs, Predicates.equalTo(FileStatus.REMOVED)).keySet();
        Set<RelativeFile> deletedRelativeFiles = saveData.find(deletedFiles, inputSet);

        /*
         * Figure out changes to non-deleted files.
         */
        Map<File, FileStatus> nonDeletedFiles =
                Maps.filterValues(
                        changedInputs,
                        Predicates.not(Predicates.equalTo(FileStatus.REMOVED)));
        Map<RelativeFile, FileStatus> nonDeletedRelativeFiles =
                IncrementalRelativeFileSets.makeFromBaseFiles(
                        baseFiles,
                        nonDeletedFiles,
                        cacheByPath);

        /*
         * Merge everything.
         */
        return new ImmutableMap.Builder<RelativeFile, FileStatus>()
                .putAll(Maps.asMap(deletedRelativeFiles, Functions.constant(FileStatus.REMOVED)))
                .putAll(nonDeletedRelativeFiles)
                .build();
    }

    /**
     * Creates the new instant run resources from the dex files. This method is not
     * incremental. It will ignore updates and look at all dex files and always rebuild the
     * instant run resources.
     *
     * <p>The instant run resources are resources that package dex files.
     *
     * @param dexBaseFiles the base files to dex
     * @return the instant run resources
     * @throws IOException failed to create the instant run resources
     */
    @NonNull
    private ImmutableMap<RelativeFile, FileStatus> makeInstantRunResourcesFromDex(
            @NonNull Iterable<File> dexBaseFiles) throws IOException {

        File tmpZipFile = new File(instantRunSupportDir, "instant-run.zip");
        boolean existedBefore = tmpZipFile.exists();

        Files.createParentDirs(tmpZipFile);
        ZipOutputStream zipFile = new ZipOutputStream(
                new BufferedOutputStream(new FileOutputStream(tmpZipFile)));
        // no need to compress a zip, the APK itself gets compressed.
        zipFile.setLevel(0);

        try {
            for (File dexFolder : dexBaseFiles) {
                for (File file : Files.fileTreeTraverser().breadthFirstTraversal(dexFolder)) {
                    if (file.isFile() && file.getName().endsWith(SdkConstants.DOT_DEX)) {
                        // There are several pieces of code in the runtime library that depend
                        // on this exact pattern, so it should not be changed without thorough
                        // testing (it's basically part of the contract).
                        String entryName =
                                file.getParentFile().getName() + "-" + file.getName();
                        zipFile.putNextEntry(new ZipEntry(entryName));
                        try {
                            Files.copy(file, zipFile);
                        } finally {
                            zipFile.closeEntry();
                        }
                    }
                }
            }
        } finally {
            zipFile.close();
        }

        RelativeFile resourcesFile = new RelativeFile(instantRunSupportDir, tmpZipFile);
        return ImmutableMap.of(resourcesFile, existedBefore? FileStatus.CHANGED : FileStatus.NEW);
    }

    // ----- FileSupplierTask -----

    @Override
    public File get() {
        return getOutputFile();
    }

    @NonNull
    @Override
    public Task getTask() {
        return this;
    }

    /**
     * Class that keeps track of which files are known in incremental builds. Gradle tells us
     * which files were modified, but doesn't tell us which inputs the files come from so when a
     * file is marked as deleted, we don't know which input set it was deleted from. This class
     * maintains the list of files and their source locations and can be saved to the intermediate
     * directory.
     *
     * <p>File data is loaded on creation and saved on close.
     *
     * <p><i>Implementation note:</i> the actual data is saved in a property file with the
     * file name mapped to the name of the {@link InputSet} enum defining its input set.
     */
    private static class KnownFilesSaveData {

        /**
         * Name of the file with the save data.
         */
        private static final String SAVE_DATA_FILE_NAME = "file-input-save-data.txt";

        /**
         * Property with the number of files in the property file.
         */
        private static final String COUNT_PROPERTY = "count";

        /**
         * Suffix for property with the base file.
         */
        private static final String BASE_SUFFIX = ".base";

        /**
         * Suffix for property with the file.
         */
        private static final String FILE_SUFFIX = ".file";

        /**
         * Suffix for property with the input set.
         */
        private static final String INPUT_SET_SUFFIX = ".set";

        /**
         * Cache with all known cached files.
         */
        private static final Map<File, CachedFileContents<KnownFilesSaveData>> mCache =
                Maps.newHashMap();

        /**
         * File contents cache.
         */
        @NonNull
        private final CachedFileContents<KnownFilesSaveData> mFileContentsCache;

        /**
         * Maps all files in the last build to their input set.
         */
        @NonNull
        private final Map<RelativeFile, InputSet> mFiles;

        /**
         * Has the data been modified?
         */
        private boolean mDirty;

        /**
         * Creates a new file save data and reads it one exists. To create new instances, the
         * factory method {@link #make(File)} should be used.
         *
         * @param cache the cache used
         * @throws IOException failed to read the file (not thrown if the file does not exist)
         */
        private KnownFilesSaveData(@NonNull CachedFileContents<KnownFilesSaveData> cache)
                throws IOException {
            mFileContentsCache = cache;
            mFiles = Maps.newHashMap();
            if (cache.getFile().isFile()) {
                readCurrentData();
            }

            mDirty = false;
        }

        /**
         * Creates a new {@link KnownFilesSaveData}, or obtains one from cache if there already
         * exists a cached entry.
         *
         * @param intermediateDir the intermediate directory where the cache is stored
         * @return the save data
         * @throws IOException save data file exists but there was an error reading it (not thrown
         * if the file does not exist)
         */
        @NonNull
        private static synchronized KnownFilesSaveData make(@NonNull File intermediateDir)
                throws IOException {
            File saveFile = computeSaveFile(intermediateDir);
            CachedFileContents<KnownFilesSaveData> cached = mCache.get(saveFile);
            if (cached == null) {
                cached = new CachedFileContents<>(saveFile);
                mCache.put(saveFile, cached);
            }

            KnownFilesSaveData saveData = cached.getCache();
            if (saveData == null) {
                saveData = new KnownFilesSaveData(cached);
                cached.closed(saveData);
            }

            return saveData;
        }

        /**
         * Computes what is the save file for the provided intermediate directory.
         *
         * @param intermediateDir the intermediate directory
         * @return the file
         */
        private static File computeSaveFile(@NonNull File intermediateDir) {
            return new File(intermediateDir, SAVE_DATA_FILE_NAME);
        }

        /**
         * Reads the save file data into the in-memory data structures.
         *
         * @throws IOException failed to read the file
         */
        private void readCurrentData() throws IOException {
            Closer closer = Closer.create();

            File saveFile = mFileContentsCache.getFile();

            Properties properties = new Properties();
            try {
                Reader saveDataReader = closer.register(new FileReader(saveFile));
                properties.load(saveDataReader);
            } catch (Throwable t) {
                throw closer.rethrow(t);
            } finally {
                closer.close();
            }

            String fileCountText = null;
            int fileCount;
            try {
                fileCountText = properties.getProperty(COUNT_PROPERTY);
                if (fileCountText == null) {
                    throw new IOException("Invalid data stored in file '" + saveFile + "' ("
                            + "property '" + COUNT_PROPERTY + "' has no value).");
                }

                fileCount = Integer.parseInt(fileCountText);
                if (fileCount < 0) {
                    throw new IOException("Invalid data stored in file '" + saveFile + "' ("
                            + "property '" + COUNT_PROPERTY + "' has value " + fileCount + ").");
                }
            } catch (NumberFormatException e) {
                throw new IOException("Invalid data stored in file '" + saveFile + "' ("
                        + "property '" + COUNT_PROPERTY + "' has value '" + fileCountText + "').",
                        e);
            }

            for (int i = 0; i < fileCount; i++) {
                String baseName = properties.getProperty(i + BASE_SUFFIX);
                if (baseName == null) {
                    throw new IOException("Invalid data stored in file '" + saveFile + "' ("
                            + "property '" + i + BASE_SUFFIX + "' has no value).");
                }

                String fileName = properties.getProperty(i + FILE_SUFFIX);
                if (fileName == null) {
                    throw new IOException("Invalid data stored in file '" + saveFile + "' ("
                            + "property '" + i + FILE_SUFFIX + "' has no value).");
                }

                String inputSetName = properties.getProperty(i + INPUT_SET_SUFFIX);
                if (inputSetName == null) {
                    throw new IOException("Invalid data stored in file '" + saveFile + "' ("
                            + "property '" + i + INPUT_SET_SUFFIX + "' has no value).");
                }

                InputSet is;
                try {
                    is = InputSet.valueOf(InputSet.class, inputSetName);
                } catch (IllegalArgumentException e) {
                    throw new IOException("Invalid data stored in file '" + saveFile + "' ("
                            + "property '" + i + INPUT_SET_SUFFIX + "' has invalid value '"
                            + inputSetName + "').");
                }

                mFiles.put(new RelativeFile(new File(baseName), new File(fileName)), is);
            }
        }

        /**
         * Saves current in-memory data structures to file.
         *
         * @throws IOException failed to save the data
         */
        private void saveCurrentData() throws IOException {
            if (!mDirty) {
                return;
            }

            Closer closer = Closer.create();

            Properties properties = new Properties();
            properties.put(COUNT_PROPERTY, Integer.toString(mFiles.size()));
            int idx = 0;
            for (Map.Entry<RelativeFile, InputSet> e : mFiles.entrySet()) {
                RelativeFile rf = e.getKey();

                String basePath = Verify.verifyNotNull(rf.getBase().getPath());
                Verify.verify(!basePath.isEmpty());

                String filePath = Verify.verifyNotNull(rf.getFile().getPath());
                Verify.verify(!filePath.isEmpty());

                properties.put(idx + BASE_SUFFIX, basePath);
                properties.put(idx + FILE_SUFFIX, filePath);
                properties.put(idx + INPUT_SET_SUFFIX, e.getValue().name());

                idx++;
            }

            try {
                Writer saveDataWriter = closer.register(new FileWriter(
                        mFileContentsCache.getFile()));
                properties.store(saveDataWriter, "Internal package file, do not edit.");
                mFileContentsCache.closed(this);
            } catch (Throwable t) {
                throw closer.rethrow(t);
            } finally {
                closer.close();
            }
        }

        /**
         * Obtains all relative files stored in the save data that have the provided input set and
         * whose files are included in the provided set of files. This method allows retrieving
         * the original relative files from the files, while filtering for the desired input set.
         *
         * @param files the files to filter
         * @param inputSet the input set to filter
         * @return all saved relative files that have the given input set and whose files exist
         * in the provided set
         */
        @NonNull
        private ImmutableSet<RelativeFile> find(@NonNull Set<File> files,
                @NonNull InputSet inputSet) {
            Set<RelativeFile> found = Sets.newHashSet();
            for (RelativeFile rf :
                    Maps.filterValues(mFiles, Predicates.equalTo(inputSet)).keySet()) {
                if (files.contains(rf.getFile())) {
                    found.add(rf);
                }
            }

            return ImmutableSet.copyOf(found);
        }

        /**
         * Obtains a predicate that checks if a file is in an input set.
         *
         * @param inputSet the input set
         * @return the predicate
         */
        @NonNull
        private Function<File, RelativeFile> inInputSet(@NonNull InputSet inputSet) {
            Map<File, RelativeFile> inverseFiltered = mFiles.entrySet().stream()
                    .filter(e -> e.getValue() == inputSet)
                    .map(Map.Entry::getKey)
                    .collect(
                            HashMap::new,
                            (m, rf) -> m.put(rf.getFile(), rf),
                            Map::putAll);

            return inverseFiltered::get;
        }

        /**
         * Sets all files in an input set, replacing whatever existed previously.
         *
         * @param files the files
         * @param set the input set
         */
        private void setInputSet(@NonNull Collection<RelativeFile> files, @NonNull InputSet set) {
            for (Iterator<Map.Entry<RelativeFile, InputSet>> it = mFiles.entrySet().iterator();
                    it.hasNext(); ) {
                Map.Entry<RelativeFile, InputSet> next = it.next();
                if (next.getValue() == set && !files.contains(next.getKey())) {
                    it.remove();
                    mDirty = true;
                }
            }

            files.forEach(f -> {
                if (!mFiles.containsKey(f)) {
                    mFiles.put(f, set);
                    mDirty = true;
                }
            });
        }
    }

    /**
     * Input sets for files for save data (see {@link KnownFilesSaveData}).
     */
    private enum InputSet {
        /**
         * File belongs to the dex file set.
         */
        DEX,

        /**
         * File belongs to the java resources file set.
         */
        JAVA_RESOURCE,

        /**
         * File belongs to the native resources file set.
         */
        NATIVE_RESOURCE,

        /**
         * File belongs to the android resources file set.
         */
        ANDROID_RESOURCE,

        /**
         * File belongs to the assets file set.
         */
        ASSET
    }

    // ----- ConfigAction -----

    public abstract static class ConfigAction<T extends PackageAndroidArtifact>
            implements TaskConfigAction<T> {

        protected final PackagingScope packagingScope;
        protected final DexPackagingPolicy dexPackagingPolicy;

        public ConfigAction(
                @NonNull PackagingScope packagingScope,
                @Nullable InstantRunPatchingPolicy patchingPolicy) {
            this.packagingScope = checkNotNull(packagingScope);
            dexPackagingPolicy = patchingPolicy == null
                    ? DexPackagingPolicy.STANDARD
                    : patchingPolicy.getDexPatchingPolicy();
        }

        @Override
        public void execute(@NonNull final T packageAndroidArtifact) {
            packageAndroidArtifact.setAndroidBuilder(packagingScope.getAndroidBuilder());
            packageAndroidArtifact.setVariantName(packagingScope.getFullVariantName());
            packageAndroidArtifact.setMinSdkVersion(packagingScope.getMinSdkVersion());
            packageAndroidArtifact.instantRunContext =
                    packagingScope.getInstantRunBuildContext();
            packageAndroidArtifact.dexPackagingPolicy = dexPackagingPolicy;
            packageAndroidArtifact.instantRunSupportDir =
                    packagingScope.getInstantRunSupportDir();
            packageAndroidArtifact.setIncrementalFolder(
                    packagingScope.getIncrementalDir(packageAndroidArtifact.getName()));

            packageAndroidArtifact.aaptOptions = packagingScope.getAaptOptions();
            packageAndroidArtifact.manifest = packagingScope.getManifestFile();

            File cacheByPathDir = new File(packageAndroidArtifact.getIncrementalFolder(),
                    ZIP_DIFF_CACHE_DIR);
            FileUtils.mkdirs(cacheByPathDir);
            packageAndroidArtifact.cacheByPath = new FileCacheByPath(cacheByPathDir);

            ConventionMappingHelper.map(
                    packageAndroidArtifact, "resourceFile", packagingScope::getFinalResourcesFile);

            ConventionMappingHelper.map(
                    packageAndroidArtifact, "dexFolders", packagingScope::getDexFolders);

            ConventionMappingHelper.map(
                    packageAndroidArtifact,
                    "javaResourceFiles",
                    packagingScope::getJavaResources);

            packageAndroidArtifact.setAssets(packagingScope.getAssetsDir());

            ConventionMappingHelper.map(packageAndroidArtifact, "jniFolders", () -> {
                if (packagingScope.getSplitHandlingPolicy() ==
                        SplitHandlingPolicy.PRE_21_POLICY) {
                    return packagingScope.getJniFolders();
                }

                Set<String> filters =
                        AbiSplitOptions.getAbiFilters(packagingScope.getAbiFilters());

                return filters.isEmpty()
                        ? packagingScope.getJniFolders()
                        : Collections.emptySet();
            });

            ConventionMappingHelper.map(packageAndroidArtifact, "abiFilters", () -> {
                String filter = packagingScope.getMainOutputFile().getFilter(
                        com.android.build.OutputFile.ABI);
                if (filter != null) {
                    return ImmutableSet.of(filter);
                }
                Set<String> supportedAbis = packagingScope.getSupportedAbis();
                // TODO: nullability
                if (supportedAbis != null) {
                    return supportedAbis;
                }

                return ImmutableSet.of();
            });

            ConventionMappingHelper.map(
                    packageAndroidArtifact, "jniDebugBuild", packagingScope::isJniDebuggable);

            ConventionMappingHelper.map(
                    packageAndroidArtifact, "debugBuild", packagingScope::isDebuggable);

            packageAndroidArtifact.setSigningConfig(packagingScope.getSigningConfig());

            ConventionMappingHelper.map(
                    packageAndroidArtifact, "packagingOptions", packagingScope::getPackagingOptions);
        }
    }

}
