/*
 * Copyright (C) 2011 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.tradefed.targetprep;

import static com.android.tradefed.targetprep.UserHelper.RUN_TESTS_AS_USER_KEY;
import static com.android.tradefed.targetprep.VisibleBackgroundUserPreparer.INSTALL_TEST_APK_FOR_ALL_USERS;

import com.android.annotations.VisibleForTesting;
import com.android.incfs.install.IncrementalInstallSession;
import com.android.incfs.install.IncrementalInstallSession.Builder;
import com.android.incfs.install.PendingBlock;
import com.android.incfs.install.adb.ddmlib.DeviceConnection;
import com.android.incfs.install.adb.ddmlib.DeviceLogger;
import com.android.tradefed.build.IBuildInfo;
import com.android.tradefed.build.IDeviceBuildInfo;
import com.android.tradefed.config.Option;
import com.android.tradefed.config.Option.Importance;
import com.android.tradefed.config.OptionClass;
import com.android.tradefed.device.DeviceNotAvailableException;
import com.android.tradefed.device.ITestDevice;
import com.android.tradefed.device.NativeDevice;
import com.android.tradefed.invoker.IInvocationContext;
import com.android.tradefed.invoker.InvocationContext;
import com.android.tradefed.invoker.TestInformation;
import com.android.tradefed.log.LogUtil.CLog;
import com.android.tradefed.observatory.IDiscoverDependencies;
import com.android.tradefed.result.error.DeviceErrorIdentifier;
import com.android.tradefed.result.error.InfraErrorIdentifier;
import com.android.tradefed.targetprep.incremental.ApkChangeDetector;
import com.android.tradefed.targetprep.incremental.IIncrementalSetup;
import com.android.tradefed.testtype.IAbi;
import com.android.tradefed.testtype.IAbiReceiver;
import com.android.tradefed.util.AaptParser;
import com.android.tradefed.util.AaptParser.AaptVersion;
import com.android.tradefed.util.AbiFormatter;
import com.android.tradefed.util.BuildTestsZipUtils;
import com.android.utils.StdLogger;

import com.google.common.collect.ImmutableList;
import com.google.common.collect.ImmutableListMultimap;
import com.google.common.collect.Multimaps;

import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.security.SecureRandom;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * A {@link ITargetPreparer} that installs one or more apps from a {@link
 * IDeviceBuildInfo#getTestsDir()} folder onto device.
 *
 * <p>This preparer will look in alternate directories if the tests zip does not exist or does not
 * contain the required apk. The search will go in order from the last alternative dir specified to
 * the first.
 */
@OptionClass(alias = "tests-zip-app")
public class TestAppInstallSetup extends BaseTargetPreparer
        implements IAbiReceiver, IDiscoverDependencies, IIncrementalSetup {

    /** The mode the apk should be install in. */
    private enum InstallMode {
        FULL,
        INSTANT,
    }

    // An error message that occurs when a test APK is already present on the DUT,
    // but cannot be updated. When this occurs, the package is removed from the
    // device so that installation can continue like normal.
    private static final String INSTALL_FAILED_UPDATE_INCOMPATIBLE =
            "INSTALL_FAILED_UPDATE_INCOMPATIBLE";

    @VisibleForTesting static final String TEST_FILE_NAME_OPTION = "test-file-name";

    @Option(
            name = TEST_FILE_NAME_OPTION,
            description =
                    "the name of an apk file to be installed on device. Can be repeated. Items "
                            + "that are directories will have any APKs contained therein, "
                            + "including subdirectories, grouped by package name and installed.",
            importance = Importance.IF_UNSET)
    private List<File> mTestFiles = new ArrayList<>();

    // A string made of split apk file names divided by ",".
    // See "https://developer.android.com/studio/build/configure-apk-splits" on how to split
    // apk to several files.
    @Option(
            name = "split-apk-file-names",
            description =
                    "the split apk file names separted by comma that will be installed on device."
                        + " Can be repeated for multiple split apk sets. See"
                        + " https://developer.android.com/studio/build/configure-apk-splits on how"
                        + " to split apk to several files")
    private List<String> mSplitApkFileNames = new ArrayList<>();

    @VisibleForTesting static final String THROW_IF_NOT_FOUND_OPTION = "throw-if-not-found";

    @Option(
            name = THROW_IF_NOT_FOUND_OPTION,
            description = "Throw exception if the specified file is not found.")
    private boolean mThrowIfNoFile = true;

    @Option(name = AbiFormatter.FORCE_ABI_STRING,
            description = AbiFormatter.FORCE_ABI_DESCRIPTION,
            importance = Importance.IF_UNSET)
    private String mForceAbi = null;

    @Option(name = "install-arg",
            description = "Additional arguments to be passed to install command, "
                    + "including leading dash, e.g. \"-d\"")
    private Collection<String> mInstallArgs = new ArrayList<>();

    @Option(
            name = "force-queryable",
            description = "Whether apks should be installed as force queryable.")
    private Boolean mForceQueryable = null;

    @Option(
            name = "cleanup-apks",
            description =
                    "Whether apks installed should be uninstalled after test. Note that the "
                            + "preparer does not verify if the apks are successfully removed.")
    private boolean mCleanup = true;

    @VisibleForTesting static final String CHECK_MIN_SDK_OPTION = "check-min-sdk";

    @Option(
            name = CHECK_MIN_SDK_OPTION,
            description =
                    "check app's min sdk prior to install and skip if device api level is too low.")
    private boolean mCheckMinSdk = false;

    /** @deprecated use test-file-name instead now that it is a File. */
    @Deprecated
    @Option(
            name = "alt-dir",
            description =
                    "Alternate directory to look for the apk if the apk is not in the tests "
                            + "zip file. For each alternate dir, will look in //, //data/app, "
                            + "//DATA/app, //DATA/app/apk_name/ and //DATA/priv-app/apk_name/. "
                            + "Can be repeated. Look for apks in last alt-dir first.")
    private List<File> mAltDirs = new ArrayList<>();

    /** @deprecated goes in pair with alt-dir which is deprecated */
    @Deprecated
    @Option(
            name = "alt-dir-behavior",
            description =
                    "The order of alternate directory to be used when searching for apks to "
                            + "install")
    private AltDirBehavior mAltDirBehavior = AltDirBehavior.FALLBACK;

    @Option(name = "instant-mode", description = "Whether or not to install apk in instant mode.")
    private boolean mInstantMode = false;

    @Option(name = "aapt-version", description = "The version of AAPT for APK parsing.")
    private AaptVersion mAaptVersion = AaptVersion.AAPT2;

    @Option(
            name = "force-install-mode",
            description =
                    "Force the preparer to ignore instant-mode option, and install in the"
                            + " requested mode.")
    private InstallMode mInstallationMode = null;

    @Option(
            name = "incremental",
            description =
                    "Performs an installation using incremental streaming. Given the"
                            + " non-deterministic nature of an incremental installation, it is not"
                            + " guaranteed that a test run with this option will yield the same"
                            + " results of previous or future invocations.")
    @VisibleForTesting
    protected boolean mIncrementalInstallation = false;

    @Option(
            name = "incremental-block-filter",
            description =
                    "Decimal representation of the percentage of data blocks"
                            + " to be filtered out during an incremental"
                            + " installation.")
    protected double mBlockFilterPercentage = 0.0;

    @Option(
            name = "incremental-install-timeout-secs",
            description =
                    "Specifies the maximum permitted duration of" + " an incremental installation.")
    protected int mIncrementalInstallTimeout = 1800;

    private IAbi mAbi = null;
    private Integer mUserId = null;
    private Boolean mGrantPermission = null;
    // TODO: b/367468564 - Remove this flag once we have fixed the tests so that installation
    // for the system user is no longer required when conducting tests for
    // the secondary_user_on_secondary_display user type.
    private boolean mInstallForAllUsers  = false;

    private Set<String> mPackagesInstalled = new HashSet<>();
    private TestInformation mTestInfo;
    @VisibleForTesting protected IncrementalInstallSession incrementalInstallSession;
    private ApkChangeDetector mApkChangeDetector = null;

    protected void setTestInformation(TestInformation testInfo) {
        mTestInfo = testInfo;
    }

    /** Adds a file or directory to the list of apks to installed. */
    public void addTestFile(File file) {
        mTestFiles.add(file);
    }

    /** Adds a file name to the list of apks to installed. */
    public void addTestFileName(String fileName) {
        addTestFile(new File(fileName));
    }

    /** Helper to parse an apk file with aapt. */
    @VisibleForTesting
    AaptParser doAaptParse(File apkFile) {
        return AaptParser.parse(apkFile, mAaptVersion);
    }

    @VisibleForTesting
    void clearTestFile() {
        mTestFiles.clear();
    }

    /**
     * Adds a set of file names divided by ',' in a string to be installed as split apks
     *
     * @param fileNames a string of file names divided by ','
     */
    public void addSplitApkFileNames(String fileNames) {
        mSplitApkFileNames.add(fileNames);
    }

    @VisibleForTesting
    void clearSplitApkFileNames() {
        mSplitApkFileNames.clear();
    }

    /** Returns a copy of the list of specified test apk names. */
    public List<File> getTestsFileName() {
        return mTestFiles;
    }

    /** Sets whether or not the installed apk should be cleaned on tearDown */
    public void setCleanApk(boolean shouldClean) {
        mCleanup = shouldClean;
    }

    /**
     * If the apk should be installed for a particular user, sets the id of the user to install for.
     */
    public void setUserId(int userId) {
        mUserId = userId;
    }

    /** If a userId is provided, grantPermission can be set for the apk installation. */
    public void setShouldGrantPermission(boolean shouldGrant) {
        mGrantPermission = shouldGrant;
    }

    /** Sets the version of AAPT for APK parsing. */
    public void setAaptVersion(AaptVersion aaptVersion) {
        mAaptVersion = aaptVersion;
    }

    /** Adds one apk installation arg to be used. */
    public void addInstallArg(String arg) {
        mInstallArgs.add(arg);
    }

    /**
     * The default value of the force queryable is true. Update it to false if the apk to be
     * installed should not be queryable.
     */
    public void setForceQueryable(boolean forceQueryable) {
        mForceQueryable = forceQueryable;
    }

    /**
     * Resolve the actual apk path based on testing artifact information inside build info.
     *
     * @param testInfo The {@link TestInformation} for the invocation.
     * @param apkFileName filename of the apk to install
     * @return a {@link File} representing the physical apk file on host or {@code null} if the file
     *     does not exist.
     */
    protected File getLocalPathForFilename(TestInformation testInfo, String apkFileName)
            throws TargetSetupError {
        try {
            return BuildTestsZipUtils.getApkFile(
                    testInfo.getBuildInfo(),
                    apkFileName,
                    mAltDirs,
                    mAltDirBehavior,
                    false /* use resource as fallback */,
                    null /* device signing key */);
        } catch (IOException ioe) {
            throw new TargetSetupError(
                    String.format(
                            "failed to resolve apk path for apk %s in build %s",
                            apkFileName, testInfo.getBuildInfo().toString()),
                    ioe,
                    testInfo.getDevice().getDeviceDescriptor(),
                    InfraErrorIdentifier.CONFIGURED_ARTIFACT_NOT_FOUND);
        }
    }

    /** @deprecated Temporary backward compatible callback. */
    @Deprecated
    @Override
    public void setUp(ITestDevice device, IBuildInfo buildInfo)
            throws TargetSetupError, BuildError, DeviceNotAvailableException {
        IInvocationContext context = new InvocationContext();
        context.addAllocatedDevice("device", device);
        context.addDeviceBuildInfo("device", buildInfo);
        TestInformation backwardCompatible =
                TestInformation.newBuilder().setInvocationContext(context).build();
        setUp(backwardCompatible);
    }

    /** {@inheritDoc} */
    @Override
    public void setUp(TestInformation testInfo)
            throws TargetSetupError, BuildError, DeviceNotAvailableException {
        mTestInfo = testInfo;
        if (mTestFiles.isEmpty() && mSplitApkFileNames.isEmpty()) {
            CLog.i("No test apps to install, skipping");
            return;
        }
        // resolve abi flags
        if (mAbi != null && mForceAbi != null) {
            throw new IllegalStateException("cannot specify both abi flags: --abi and --force-abi");
        }

        // We are going to need several "ro.build" props, save some time (0.4 sec) by prefetching
        if (getDevice() instanceof NativeDevice) {
            ((NativeDevice) getDevice()).batchPrefetchStartupBuildProps();
        }
        String abiName = null;
        if (mAbi != null) {
            abiName = mAbi.getName();
        } else if (mForceAbi != null) {
            abiName = AbiFormatter.getDefaultAbi(getDevice(), mForceAbi);
        }
        // Set all the extra install args outside the loop to avoid adding them several times.
        if (abiName != null && testInfo.getDevice().getApiLevel() > 20) {
            mInstallArgs.add(String.format("--abi %s", abiName));
        }
        // Handle instant mode: if we are forced in one installation mode or not.
        // Some preparer are locked in one installation mode or another, they ignore the
        // 'instant-mode' option and stays in their mode.
        if (mInstallationMode != null) {
            if (InstallMode.INSTANT.equals(mInstallationMode)) {
                mInstallArgs.add("--instant");
            }
        } else {
            if (mInstantMode) {
                mInstallArgs.add("--instant");
            }
        }

        if (mUserId == null && testInfo.properties().get(RUN_TESTS_AS_USER_KEY) != null) {
            mUserId = Integer.parseInt(testInfo.properties().get(RUN_TESTS_AS_USER_KEY));
            if (!testInfo.getDevice().getUserInfos().containsKey(mUserId)) {
                CLog.w("User requested: %s doesn't exist on device. Ignoring it.", mUserId);
                mUserId = null;
            } else {
                CLog.d("Using user %s from testInfo properties.", mUserId);
            }
        }

        if (testInfo.properties().get(INSTALL_TEST_APK_FOR_ALL_USERS) != null) {
            mInstallForAllUsers = testInfo.properties().get(INSTALL_TEST_APK_FOR_ALL_USERS)
                    .equals("true");
        }

        if (mForceQueryable == null) {
            // Do not add --force-queryable if the device api level >= 34. Ideally,
            // checkApiLevelAgainstNextRelease(34) should only return true for api 34 devices. But,
            // it also returns true for branches like the tm-xx-plus-aosp. Adding another condition
            // ro.build.id==TM to handle this special case.
            mForceQueryable =
                    !getDevice().checkApiLevelAgainstNextRelease(34)
                            || "TM".equals(getDevice().getBuildAlias());
        }
        if (mForceQueryable && getDevice().isAppEnumerationSupported()) {
            mInstallArgs.add("--force-queryable");
        }

        // Add bypass flag for low target sdk apps when installing on U+ devices
        if (getDevice().isBypassLowTargetSdkBlockSupported()) {
            mInstallArgs.add("--bypass-low-target-sdk-block");
        }

        for (File testAppName : mTestFiles) {
            Map<File, String> appFilesAndPackages =
                    resolveApkFiles(testInfo, findApkFiles(testAppName));
            installer(testInfo, appFilesAndPackages);
        }

        for (String testAppNames : mSplitApkFileNames) {
            List<String> apkNames = Arrays.asList(testAppNames.split(","));
            List<File> apkFileNames =
                    apkNames.stream().map(a -> new File(a)).collect(Collectors.toList());
            Map<File, String> appFilesAndPackages = resolveApkFiles(testInfo, apkFileNames);
            installer(testInfo, appFilesAndPackages);
        }
    }

    /**
     * Returns the device that the preparer should apply to.
     *
     * @throws TargetSetupError
     */
    public ITestDevice getDevice() throws TargetSetupError {
        return mTestInfo.getDevice();
    }

    public TestInformation getTestInfo() {
        return mTestInfo;
    }

    @Override
    public void setAbi(IAbi abi) {
        mAbi = abi;
    }

    @Override
    public IAbi getAbi() {
        return mAbi;
    }

    /**
     * Sets whether or not --instant should be used when installing the apk. Will have no effect if
     * force-install-mode is set.
     */
    public final void setInstantMode(boolean mode) {
        mInstantMode = mode;
    }

    /** Returns whether or not instant mode installation has been enabled. */
    public final boolean isInstantMode() {
        return mInstantMode;
    }

    /** {@inheritDoc} */
    @Override
    public void tearDown(TestInformation testInfo, Throwable e) throws DeviceNotAvailableException {
        mTestInfo = testInfo;
        if (mCleanup && !(e instanceof DeviceNotAvailableException)) {
            for (String packageName : mPackagesInstalled) {
                try {
                    if (mApkChangeDetector != null
                        && mApkChangeDetector.handlePackageCleanup(
                            packageName, getDevice(), mUserId, mInstallForAllUsers)) {
                        continue;
                    }
                    uninstallPackage(getDevice(), packageName);
                } catch (TargetSetupError tse) {
                    CLog.e(tse);
                }
            }
        }
    }

    /**
     * Set an alternate directory.
     */
    public void setAltDir(File altDir) {
        mAltDirs.add(altDir);
    }

    /**
     * Set an alternate directory behaviors.
     */
    public void setAltDirBehavior(AltDirBehavior altDirBehavior) {
        mAltDirBehavior = altDirBehavior;
    }

    /** Returns True if Apks will be cleaned up during tear down. */
    public boolean isCleanUpEnabled() {
        return mCleanup;
    }

    /** {@inheritDoc} */
    @Override
    public void setIncrementalSetupEnabled(boolean shouldEnable) {
        if (shouldEnable) {
            mApkChangeDetector = new ApkChangeDetector();
        } else {
            mApkChangeDetector = null;
        }
    }

    /**
     * Attempt to install an package or split package on the device.
     *
     * @param testInfo the {@link TestInformation} for the invocation
     * @param appFilesAndPackages The apks and their package to be installed.
     */
    protected void installer(TestInformation testInfo, Map<File, String> appFilesAndPackages)
            throws TargetSetupError, DeviceNotAvailableException {

        ITestDevice device = testInfo.getDevice();

        // TODO(hzalek): Consider changing resolveApkFiles's return to a Multimap to avoid building
        // it here.
        ImmutableListMultimap<String, File> packageToFiles =
                ImmutableListMultimap.copyOf(appFilesAndPackages.entrySet()).inverse();

        Builder builder = null;
        if (mIncrementalInstallation) {
            builder = getIncrementalInstallSessionBuilder();
        }

        for (Map.Entry<String, List<File>> e : Multimaps.asMap(packageToFiles).entrySet()) {
            if (mApkChangeDetector != null
                && mApkChangeDetector.handleTestAppsPreinstall(e.getKey(), e.getValue(), getDevice())) {
                continue;
            }

            if (mIncrementalInstallation) {
                CLog.d(
                        "Performing incremental installation of apk %s with %s ...",
                        e.getKey(), e.getValue());
                addPackageToIncrementalInstallSession(builder, e.getKey(), e.getValue());
                if (mCleanup) {
                    mPackagesInstalled.add(e.getKey());
                }
            } else {
                installSinglePackage(device, e.getKey(), e.getValue());
            }
        }

        if (mIncrementalInstallation && builder != null) {
            installPackageIncrementally(builder);
        }
    }

    private void installSinglePackage(
            ITestDevice testDevice, String packageName, List<File> apkFiles)
            throws TargetSetupError, DeviceNotAvailableException {

        if (apkFiles.isEmpty()) {
            return;
        }

        CLog.d("Installing apk %s with %s ...", packageName, apkFiles);
        String result = installPackage(testDevice, apkFiles);

        if (result != null) {
            if (result.startsWith(INSTALL_FAILED_UPDATE_INCOMPATIBLE)) {
                // Try to uninstall package and reinstall.
                uninstallPackage(testDevice, packageName);
                result = installPackage(testDevice, apkFiles);
            }
        }

        if (result != null) {
            throw new TargetSetupError(
                    String.format(
                            "Failed to install %s with %s on %s. Reason: '%s'",
                            packageName, apkFiles, testDevice.getSerialNumber(), result),
                    testDevice.getDeviceDescriptor(),
                    DeviceErrorIdentifier.APK_INSTALLATION_FAILED);
        }

        if (mCleanup) {
            mPackagesInstalled.add(packageName);
        }
    }

    /** Helper to resolve some apk to their File and Package. */
    @VisibleForTesting
    protected Map<File, String> resolveApkFiles(TestInformation testInfo, List<File> apkFiles)
            throws TargetSetupError, DeviceNotAvailableException {
        Map<File, String> appFiles = new LinkedHashMap<>();
        ITestDevice device = testInfo.getDevice();
        for (File apkFile : apkFiles) {
            File testAppFile = null;
            if (apkFile.isAbsolute()) {
                testAppFile = apkFile;
            }
            if (testAppFile == null) {
                testAppFile = getLocalPathForFilename(testInfo, apkFile.getName());
            }
            if (testAppFile == null) {
                if (mThrowIfNoFile) {
                    throw new TargetSetupError(
                            String.format("Test app %s was not found.", apkFile.getName()),
                            device.getDeviceDescriptor(),
                            InfraErrorIdentifier.CONFIGURED_ARTIFACT_NOT_FOUND);
                } else {
                    CLog.d("Test app %s was not found.", apkFile.getName());
                    continue;
                }
            }
            if (!testAppFile.canRead()) {
                if (mThrowIfNoFile) {
                    throw new TargetSetupError(
                            String.format("Could not read file %s.", testAppFile.toString()),
                            device.getDeviceDescriptor());
                } else {
                    CLog.d("Could not read file %s.", testAppFile.toString());
                    continue;
                }
            }

            if (mCheckMinSdk) {
                AaptParser aaptParser = doAaptParse(testAppFile);
                if (aaptParser == null) {
                    throw new TargetSetupError(
                            String.format(
                                    "Failed to extract info from `%s` using "
                                        + (mAaptVersion == AaptVersion.AAPT
                                        ? "aapt" : "aapt2"),
                                    testAppFile.getAbsoluteFile().getName()),
                            device.getDeviceDescriptor());
                }
                if (device.getApiLevel() < aaptParser.getSdkVersion()) {
                    CLog.w(
                            "Skipping installing apk %s on device %s because "
                                    + "SDK level require is %d, but device SDK level is %d",
                            apkFile.toString(),
                            device.getSerialNumber(),
                            aaptParser.getSdkVersion(),
                            device.getApiLevel());
                } else {
                    appFiles.put(testAppFile, parsePackageName(testAppFile));
                }
            } else {
                appFiles.put(testAppFile, parsePackageName(testAppFile));
            }
        }
        return appFiles;
    }

    /**
     * Returns the provided file if not a directory or all APK files contained in the directory tree
     * rooted at the provided path otherwise.
     */
    private List<File> findApkFiles(File fileOrDirectory) throws TargetSetupError {

        if (!fileOrDirectory.isDirectory()) {
            return ImmutableList.of(fileOrDirectory);
        }

        List<File> apkFiles;

        try (Stream<Path> paths = Files.walk(fileOrDirectory.toPath())) {
            apkFiles =
                    paths.filter(p -> p.toString().endsWith(".apk"))
                            .filter(Files::isRegularFile)
                            .map(Path::toFile)
                            .collect(Collectors.toList());
        } catch (IOException e) {
            throw new TargetSetupError(
                    String.format(
                            "Could not list files of specified directory: %s", fileOrDirectory),
                    e,
                    null,
                    false,
                    InfraErrorIdentifier.CONFIGURED_ARTIFACT_NOT_FOUND);
        }

        if (mThrowIfNoFile && apkFiles.isEmpty()) {
            throw new TargetSetupError(
                    String.format(
                            "Could not find any files in specified directory: %s", fileOrDirectory),
                    null,
                    null,
                    false,
                    InfraErrorIdentifier.CONFIGURED_ARTIFACT_NOT_FOUND);
        }

        return apkFiles;
    }

    /**
     * Attempt to install a package or split package on the device.
     *
     * @param device the {@link ITestDevice} to install package
     * @param appFiles List of Files. If apkFiles contains only one apk file, the app will be
     *     installed as a whole package with single file. If apkFiles contains more than one name,
     *     the app will be installed as split apk with multiple files.
     */
    private String installPackage(ITestDevice device, List<File> appFiles)
            throws DeviceNotAvailableException {
        // Handle the different install use cases (with or without a user)
        if (mUserId == null || mInstallForAllUsers) {
            if (appFiles.size() == 1) {
                return device.installPackage(
                        appFiles.get(0), true, mInstallArgs.toArray(new String[] {}));
            } else {
                return device.installPackages(
                        appFiles, true, mInstallArgs.toArray(new String[] {}));
            }
        } else if (mGrantPermission != null) {
            if (appFiles.size() == 1) {
                return device.installPackageForUser(
                        appFiles.get(0),
                        true,
                        mGrantPermission,
                        mUserId,
                        mInstallArgs.toArray(new String[] {}));
            } else {
                return device.installPackagesForUser(
                        appFiles,
                        true,
                        mGrantPermission,
                        mUserId,
                        mInstallArgs.toArray(new String[] {}));
            }
        } else {
            if (appFiles.size() == 1) {
                return device.installPackageForUser(
                        appFiles.get(0), true, mUserId, mInstallArgs.toArray(new String[] {}));
            } else {
                return device.installPackagesForUser(
                        appFiles, true, mUserId, mInstallArgs.toArray(new String[] {}));
            }
        }
    }

    /** Attempt to remove the package from the device. */
    protected void uninstallPackage(ITestDevice device, String packageName)
            throws DeviceNotAvailableException {
        String msg;
        if (mUserId == null || mInstallForAllUsers) {
            msg = device.uninstallPackage(packageName);
        } else {
            msg = device.uninstallPackageForUser(packageName, mUserId);
        }
        if (msg != null) {
            CLog.w(String.format("error uninstalling package '%s': %s", packageName, msg));
        }
        if (mIncrementalInstallation) {
            incrementalInstallSession.close();
        }
    }

    /** Get the package name from the test app. */
    protected String parsePackageName(File testAppFile) throws TargetSetupError {
        AaptParser parser = AaptParser.parse(testAppFile, mAaptVersion);
        if (parser == null) {
            throw new TargetSetupError(
                    String.format(
                            "AaptParser failed for file %s. The APK won't be installed",
                            testAppFile.getName()),
                    null,
                    null,
                    false, // Not device side error, doesn't need descriptor
                    DeviceErrorIdentifier.AAPT_PARSER_FAILED);
        }
        return parser.getPackageName();
    }

    /**
     * Add APKs from package to incremental installation session builder object.
     *
     * @param builder The Builder object for the incremental install session.
     * @param packageName The name of the package to be added.
     * @param packageFiles List of files to be added to builder object.
     * @throws TargetSetupError
     */
    private void addPackageToIncrementalInstallSession(
            Builder builder, String packageName, List<File> packageFiles) throws TargetSetupError {
        for (File apk : packageFiles) {
            Path apkPath = apk.toPath();
            Path apkSignaturePath = Paths.get(String.format("%s.idsig", apkPath.toString()));
            if (!apkSignaturePath.toFile().exists()) {
                throw new TargetSetupError(
                        String.format(
                                "Unable to retrieve v4 signature for file: %s",
                                apkPath.getFileName()),
                        getDevice().getDeviceDescriptor(),
                        InfraErrorIdentifier.CONFIGURED_ARTIFACT_NOT_FOUND);
            }
            builder.addApk(apkPath, apkSignaturePath);
        }
    }

    /**
     * Start the incremental installation session for a test app.
     *
     * @param builder The Builder object for the incremental install session.
     * @throws TargetSetupError
     */
    @VisibleForTesting
    protected void installPackageIncrementally(Builder builder) throws TargetSetupError {
        try {
            incrementalInstallSession = builder.build();
            String deviceSerialNumber = getDevice().getSerialNumber();
            DeviceConnection.Factory deviceConnection =
                    DeviceConnection.getFactory(deviceSerialNumber);
            incrementalInstallSession.start(Executors.newCachedThreadPool(), deviceConnection);
            incrementalInstallSession.waitForInstallCompleted(
                    mIncrementalInstallTimeout, TimeUnit.SECONDS);
        } catch (InterruptedException | IOException e) {
            throw new TargetSetupError(
                    String.format("Failed to start incremental install session."),
                    e,
                    getDevice().getDeviceDescriptor(),
                    DeviceErrorIdentifier.APK_INSTALLATION_FAILED);
        }
    }

    /** Initialize the session builder for installing a test app incrementally. */
    @VisibleForTesting
    protected Builder getIncrementalInstallSessionBuilder() {
        if (mGrantPermission != null && mGrantPermission) {
            mInstallArgs.add("-g");
        }

        if (mUserId != null) {
            mInstallArgs.add("--user");
            mInstallArgs.add(Integer.toString(mUserId));
        }

        Builder incrementalInstallSessionBuilder =
                new Builder()
                        .setLogger(new DeviceLogger(new StdLogger(StdLogger.Level.ERROR)))
                        .addExtraArgs(mInstallArgs.toArray(new String[] {}));

        // Add block filter to installation if a block filter percentage is specified.
        if (mBlockFilterPercentage > 0) {
            long randomSeed = new SecureRandom().nextLong();
            Random randomBlock = new Random(randomSeed);
            Map<Path, Set<Integer>> apkBlockMappings = new HashMap<>();

            CLog.i("Block filter seed: %d.", randomSeed);

            incrementalInstallSessionBuilder.setBlockFilter(
                    (PendingBlock b) -> {
                        Path apkPath = b.getPath();
                        synchronized (apkBlockMappings) {
                            // Generate block indexs to filter for APK installation.
                            if (!apkBlockMappings.containsKey(apkPath)) {
                                int blockCount = b.getFileBlockCount();
                                int numBlocks = (int) (blockCount * mBlockFilterPercentage);
                                Set<Integer> blocksToFilter = new HashSet<Integer>(numBlocks);
                                while (blocksToFilter.size() < numBlocks) {
                                    int blockIndex = randomBlock.nextInt(blockCount);
                                    blocksToFilter.add(blockIndex);
                                }
                                apkBlockMappings.put(apkPath, blocksToFilter);
                            }

                            return !apkBlockMappings.get(apkPath).contains(b.getBlockIndex());
                        }
                    });
        }

        return incrementalInstallSessionBuilder;
    }

    @Override
    public Set<String> reportDependencies() {
        Set<String> deps = new HashSet<String>();
        for (File f : getTestsFileName()) {
            if (!f.exists()) deps.add(f.getName());
        }
        for (String testAppNames : mSplitApkFileNames) {
            List<String> apkNames = Arrays.asList(testAppNames.split(","));
            deps.addAll(apkNames);
        }
        return deps;
    }
}
