Merge Android R

Bug: 168057903
Merged-In: Ide482c0b5b58c9b846a1bb9f33a870f77be7b551
Change-Id: Ib1881c8c913b0d66e6d5050cb4ba378530ffe05f
diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg
new file mode 100644
index 0000000..948fa79
--- /dev/null
+++ b/PREUPLOAD.cfg
@@ -0,0 +1,10 @@
+[Builtin Hooks]
+bpfmt = true
+google_java_format = true
+pylint = true
+xmllint = true
+
+[Hook Scripts]
+checkstyle_hook = ${REPO_ROOT}/prebuilts/checkstyle/checkstyle.py
+                  --sha ${PREUPLOAD_COMMIT}
+                  --config_xml config/checkstyle/checkstyle.xml
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..955adc1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,4 @@
+# Android App Compatibility Test Suite (C-Suite)
+
+C-Suite consists of the testing framework and test cases, designed to detect and report app
+compatibility issues.
diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml
new file mode 100644
index 0000000..1eb7457
--- /dev/null
+++ b/config/checkstyle/checkstyle.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE module PUBLIC "-//Puppy Crawl//DTD Check Configuration 1.3//EN" "http://www.puppycrawl.com/dtds/configuration_1_3.dtd" [
+  <!ENTITY defaultCopyrightCheck SYSTEM "../../../../../prebuilts/checkstyle/default-copyright-check.xml">
+  <!ENTITY defaultJavadocChecks SYSTEM "../../../../../prebuilts/checkstyle/default-javadoc-checks.xml">
+  <!ENTITY defaultTreewalkerChecks SYSTEM "../../../../../prebuilts/checkstyle/default-treewalker-checks.xml">
+  <!ENTITY defaultModuleChecks SYSTEM "../../../../../prebuilts/checkstyle/default-module-checks.xml">
+]>
+
+<module name="Checker">
+  &defaultModuleChecks;
+  &defaultCopyrightCheck;
+  <module name="TreeWalker">
+    &defaultJavadocChecks;
+    &defaultTreewalkerChecks;
+  </module>
+  <module name="SuppressionFilter">
+    <property name="file" value="config/checkstyle/suppressions.xml" />
+  </module>
+</module>
diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml
new file mode 100644
index 0000000..218bc02
--- /dev/null
+++ b/config/checkstyle/suppressions.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE suppressions PUBLIC "-//Puppy Crawl//DTD Suppressions 1.1//EN" "http://www.puppycrawl.com/dtds/suppressions_1_1.dtd">
+<suppressions>
+
+    <!-- Let google-java-format handle the indentation. -->
+    <suppress files=".*" checks="Indentation" />
+
+</suppressions>
diff --git a/harness/Android.bp b/harness/Android.bp
new file mode 100644
index 0000000..2dec437
--- /dev/null
+++ b/harness/Android.bp
@@ -0,0 +1,43 @@
+// Copyright (C) 2018 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.
+
+java_library_host {
+    name: "csuite-harness",
+    srcs: [
+        "src/main/java/**/*.java",
+    ],
+    java_resource_dirs: [
+        "src/main/resources",
+    ],
+    libs: [
+        "tradefed",
+    ],
+}
+
+java_test_host {
+    name: "csuite-harness-tests",
+    srcs: [
+        "src/test/java/**/*.java",
+    ],
+    libs: [
+        "csuite-harness",
+        "tradefed",
+    ],
+    static_libs: [
+        "mockito-host",
+        "objenesis",
+        "testng",
+    ],
+    test_suites: ["general-tests"],
+}
diff --git a/harness/AndroidTest.xml b/harness/AndroidTest.xml
new file mode 100644
index 0000000..f3fb637
--- /dev/null
+++ b/harness/AndroidTest.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2019 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.
+-->
+<configuration description="Executes the C-Suite harness unit tests">
+  <test class="com.android.tradefed.testtype.HostTest" >
+    <option name="class" value="com.android.compatibility.CSuiteUnitTests" />
+  </test>
+</configuration>
diff --git a/harness/TEST_MAPPING b/harness/TEST_MAPPING
new file mode 100644
index 0000000..50ed1cd
--- /dev/null
+++ b/harness/TEST_MAPPING
@@ -0,0 +1,8 @@
+{
+  "postsubmit": [
+    {
+      "name": "csuite-harness-tests",
+      "host": true
+    }
+  ]
+}
diff --git a/harness/src/main/java/com/android/compatibility/AppCompatibilityTest.java b/harness/src/main/java/com/android/compatibility/AppCompatibilityTest.java
new file mode 100644
index 0000000..74c4907
--- /dev/null
+++ b/harness/src/main/java/com/android/compatibility/AppCompatibilityTest.java
@@ -0,0 +1,671 @@
+/*
+ * Copyright (C) 2012 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.compatibility;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.IConfigurationReceiver;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionCopier;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.DeviceUnresponsiveException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.device.LogcatReceiver;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.ByteArrayInputStreamSource;
+import com.android.tradefed.result.CompatibilityTestResult;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.InputStreamSource;
+import com.android.tradefed.result.LogDataType;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.testtype.IDeviceTest;
+import com.android.tradefed.testtype.IRemoteTest;
+import com.android.tradefed.testtype.IShardableTest;
+import com.android.tradefed.testtype.ITestFilterReceiver;
+import com.android.tradefed.testtype.InstrumentationTest;
+import com.android.tradefed.util.AaptParser;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.PublicApkUtil;
+import com.android.tradefed.util.PublicApkUtil.ApkInfo;
+import com.android.tradefed.util.RunUtil;
+import com.android.tradefed.util.StreamUtil;
+
+import com.google.common.base.Strings;
+
+import org.json.JSONException;
+import org.junit.Assert;
+
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Test that determines application compatibility. The test iterates through the apks in a given
+ * directory. The test installs, launches, and uninstalls each apk.
+ */
+public abstract class AppCompatibilityTest
+        implements IDeviceTest,
+                IRemoteTest,
+                IShardableTest,
+                IConfigurationReceiver,
+                ITestFilterReceiver {
+
+    @Option(
+            name = "product",
+            description = "The product, corresponding to the borgcron job product arg.")
+    private String mProduct;
+
+    @Option(
+            name = "base-dir",
+            description = "The directory of the results excluding the date.",
+            importance = Option.Importance.ALWAYS)
+    // TODO(b/36786754): Add `mandatory = true` when cmdfiles are moved over
+    private File mBaseDir;
+
+    @Option(
+            name = "date",
+            description =
+                    "The date to run, in the form YYYYMMDD. If not set, then the latest "
+                            + "results will be used.")
+    private String mDate;
+
+    @Option(name = "test-label", description = "Unique test identifier label.")
+    private String mTestLabel = "AppCompatibility";
+
+    @Option(
+            name = "reboot-after-apks",
+            description = "Reboot the device after a centain number of apks. 0 means no reboot.")
+    private int mRebootNumber = 100;
+
+    @Option(
+            name = "fallback-to-apk-scan",
+            description =
+                    "Fallback to scanning for apks in base directory if ranking information "
+                            + "is missing.")
+    private boolean mFallbackToApkScan = false;
+
+    @Option(
+            name = "retry-count",
+            description = "Number of times to retry a failed test case. 0 means no retry.")
+    private int mRetryCount = 5;
+
+    @Option(name = "include-filter", description = "The include filter of the test names to run.")
+    protected Set<String> mIncludeFilters = new HashSet<>();
+
+    @Option(name = "exclude-filter", description = "The exclude filter of the test names to run.")
+    protected Set<String> mExcludeFilters = new HashSet<>();
+
+    private static final long DOWNLOAD_TIMEOUT_MS = 60 * 1000;
+    private static final int DOWNLOAD_RETRIES = 3;
+    private static final long JOIN_TIMEOUT_MS = 5 * 60 * 1000;
+    private static final int LOGCAT_SIZE_BYTES = 20 * 1024 * 1024;
+
+    private ITestDevice mDevice;
+    private LogcatReceiver mLogcat;
+    private IConfiguration mConfiguration;
+
+    // The number of tests run so far
+    private int mTestCount = 0;
+
+    // indicates the current sharding setup
+    private int mShardCount = 1;
+    private int mShardIndex = 0;
+
+    protected final String mLauncherPackage;
+    protected final String mRunnerClass;
+    protected final String mPackageBeingTestedKey;
+
+    protected AppCompatibilityTest(
+            String launcherPackage, String runnerClass, String packageBeingTestedKey) {
+        this.mLauncherPackage = launcherPackage;
+        this.mRunnerClass = runnerClass;
+        this.mPackageBeingTestedKey = packageBeingTestedKey;
+    }
+
+    /**
+     * Creates and sets up an instrumentation test with information about the test runner as well as
+     * the package being tested (provided as a parameter).
+     */
+    protected abstract InstrumentationTest createInstrumentationTest(String packageBeingTested);
+
+    /** Sets up some default aspects of the instrumentation test. */
+    protected final InstrumentationTest createDefaultInstrumentationTest(
+            String packageBeingTested) {
+        InstrumentationTest instrTest = new InstrumentationTest();
+        instrTest.setPackageName(mLauncherPackage);
+        instrTest.setConfiguration(mConfiguration);
+        instrTest.addInstrumentationArg(mPackageBeingTestedKey, packageBeingTested);
+        instrTest.setRunnerName(mRunnerClass);
+        return instrTest;
+    }
+
+    /*
+     * {@inheritDoc}
+     */
+    @Override
+    public final void run(ITestInvocationListener listener) throws DeviceNotAvailableException {
+        CLog.d("Start of launch test run method. base-dir: %s", mBaseDir);
+        Assert.assertNotNull("Base dir cannot be null", mBaseDir);
+        Assert.assertTrue("Base dir should be a directory", mBaseDir.isDirectory());
+
+        if (mProduct == null) {
+            mProduct = mDevice.getProductType();
+            CLog.i("\"--product\" not specified, using property from device instead: %s", mProduct);
+        }
+        Assert.assertTrue(
+                String.format(
+                        "Shard index out of range: expected [0, %d), got %d",
+                        mShardCount, mShardIndex),
+                mShardIndex >= 0 && mShardIndex < mShardCount);
+
+        File apkDir = null;
+        try {
+            apkDir = PublicApkUtil.constructApkDir(mBaseDir.getPath(), mDate);
+        } catch (IOException e) {
+            CLog.e(e);
+            throw new RuntimeException(e);
+        }
+        CLog.d("apkDir: %s.", apkDir);
+        Assert.assertNotNull("Could not find the output dir", apkDir);
+        List<ApkInfo> apkList = null;
+        try {
+            apkList = shardApkList(PublicApkUtil.getApkList(mProduct, apkDir, mFallbackToApkScan));
+        } catch (IOException e) {
+            CLog.e(e);
+            throw new RuntimeException(e);
+        }
+        CLog.d("Completed sharding apkList. Number of items: %s", apkList.size());
+        Assert.assertNotNull("Could not download apk list", apkList);
+
+        apkList = filterApk(apkList);
+        CLog.d("Completed filtering apkList. Number of items: %s", apkList.size());
+
+        long start = System.currentTimeMillis();
+        listener.testRunStarted(mTestLabel, apkList.size());
+        mLogcat = new LogcatReceiver(getDevice(), LOGCAT_SIZE_BYTES, 0);
+        mLogcat.start();
+
+        try {
+            downloadAndTestApks(listener, apkDir, apkList);
+        } catch (InterruptedException e) {
+            CLog.e(e);
+            throw new RuntimeException(e);
+        } finally {
+            mLogcat.stop();
+            listener.testRunEnded(
+                    System.currentTimeMillis() - start, new HashMap<String, Metric>());
+        }
+    }
+
+    /**
+     * Downloads and tests all the APKs in the apk list.
+     *
+     * @param listener The {@link ITestInvocationListener}.
+     * @param kharonDir The {@link File} of the CNS dir containing the APKs.
+     * @param apkList The sharded list of {@link ApkInfo} objects.
+     * @throws DeviceNotAvailableException
+     * @throws InterruptedException if a download thread was interrupted.
+     */
+    private void downloadAndTestApks(
+            ITestInvocationListener listener, File kharonDir, List<ApkInfo> apkList)
+            throws DeviceNotAvailableException, InterruptedException {
+        CLog.d("Started downloading and testing apks.");
+        ApkInfo testingApk = null;
+        File testingFile = null;
+        for (ApkInfo downloadingApk : apkList) {
+            ApkDownloadRunnable downloader = new ApkDownloadRunnable(kharonDir, downloadingApk);
+            Thread downloadThread = new Thread(downloader);
+            downloadThread.start();
+
+            testApk(listener, testingApk, testingFile);
+
+            try {
+                downloadThread.join(JOIN_TIMEOUT_MS);
+            } catch (InterruptedException e) {
+                FileUtil.deleteFile(downloader.getDownloadedFile());
+                throw e;
+            }
+            testingApk = downloadingApk;
+            testingFile = downloader.getDownloadedFile();
+        }
+        // One more time since the first time through the loop we don't test
+        testApk(listener, testingApk, testingFile);
+        CLog.d("Completed downloading and testing apks.");
+    }
+
+    /**
+     * Attempts to install and launch an APK and reports the results.
+     *
+     * @param listener The {@link ITestInvocationListener}.
+     * @param apkInfo The {@link ApkInfo} to run the test against.
+     * @param apkFile The downloaded {@link File}.
+     * @throws DeviceNotAvailableException
+     */
+    private void testApk(ITestInvocationListener listener, ApkInfo apkInfo, File apkFile)
+            throws DeviceNotAvailableException {
+        if (apkInfo == null || apkFile == null) {
+            CLog.d("apkInfo or apkFile is null.");
+            FileUtil.deleteFile(apkFile);
+            return;
+        }
+        CLog.d(
+                "Started testing package: %s, apk file: %s.",
+                apkInfo.packageName, apkFile.getAbsolutePath());
+
+        mTestCount++;
+        if (mRebootNumber != 0 && mTestCount % mRebootNumber == 0) {
+            mDevice.reboot();
+        }
+        mLogcat.clear();
+
+        TestDescription testId = createTestDescription(apkInfo.packageName);
+        listener.testStarted(testId, System.currentTimeMillis());
+
+        CompatibilityTestResult result = new CompatibilityTestResult();
+        result.rank = apkInfo.rank;
+        // Default to package name since name is a required field. This will be replaced by
+        // AaptParser in installApk()
+        result.name = apkInfo.packageName;
+        result.packageName = apkInfo.packageName;
+        result.versionString = apkInfo.versionString;
+        result.versionCode = apkInfo.versionCode;
+
+        try {
+            // Install the app, and also skip aapt check if we've fell back to apk scan
+            installApk(result, apkFile, mFallbackToApkScan);
+            boolean installationSuccess = result.status == null;
+
+            for (int i = 0; i <= mRetryCount; i++) {
+                if (installationSuccess) {
+                    // Clear test result between retries
+                    result.status = null;
+                    result.message = null;
+                    launchApk(result);
+                    mDevice.executeShellCommand(
+                            String.format("am force-stop %s", apkInfo.packageName));
+                }
+                if (result.status == null) {
+                    result.status = CompatibilityTestResult.STATUS_SUCCESS;
+                    break;
+                }
+            }
+
+            if (installationSuccess) {
+                mDevice.uninstallPackage(result.packageName);
+            }
+        } finally {
+            reportResult(listener, testId, result);
+            try {
+                postLogcat(result, listener);
+            } catch (JSONException e) {
+                CLog.w("Posting failed: %s.", e.getMessage());
+            }
+            listener.testEnded(
+                    testId, System.currentTimeMillis(), Collections.<String, String>emptyMap());
+            FileUtil.deleteFile(apkFile);
+            CLog.d("Completed testing package: %s.", apkInfo.packageName);
+        }
+    }
+
+    /**
+     * Checks that the file is correct and attempts to install it.
+     *
+     * <p>Will set the result status to error if the APK could not be installed or if it contains
+     * conflicting information.
+     *
+     * @param result the {@link CompatibilityTestResult} containing the APK info.
+     * @param apkFile the APK file to install.
+     * @throws DeviceNotAvailableException
+     */
+    private void installApk(CompatibilityTestResult result, File apkFile, boolean skipAaptCheck)
+            throws DeviceNotAvailableException {
+        if (!skipAaptCheck) {
+            CLog.d("Parsing apk file: %s.", apkFile.getAbsolutePath());
+            AaptParser parser = AaptParser.parse(apkFile);
+            if (parser == null) {
+                CLog.d(
+                        "Failed to parse apk file: %s, package: %s, error: %s.",
+                        apkFile.getAbsolutePath(), result.packageName, result.message);
+                result.status = CompatibilityTestResult.STATUS_ERROR;
+                result.message = "aapt fail";
+                return;
+            }
+
+            result.name = parser.getLabel();
+
+            if (!equalsOrNull(result.packageName, parser.getPackageName())
+                    || !equalsOrNull(result.versionString, parser.getVersionName())
+                    || !equalsOrNull(result.versionCode, parser.getVersionCode())) {
+                CLog.d(
+                        "Package info mismatch: want %s v%s (%s), got %s v%s (%s)",
+                        result.packageName,
+                        result.versionCode,
+                        result.versionString,
+                        parser.getPackageName(),
+                        parser.getVersionCode(),
+                        parser.getVersionName());
+                result.status = CompatibilityTestResult.STATUS_ERROR;
+                result.message = "package info mismatch";
+                return;
+            }
+            CLog.d("Completed parsing apk file: %s.", apkFile.getAbsolutePath());
+        }
+
+        try {
+            String error = mDevice.installPackage(apkFile, true);
+            if (error != null) {
+                result.status = CompatibilityTestResult.STATUS_ERROR;
+                result.message = error;
+                CLog.d(
+                        "Failed to install apk file: %s, package: %s, error: %s.",
+                        apkFile.getAbsolutePath(), result.packageName, result.message);
+                return;
+            }
+        } catch (DeviceUnresponsiveException e) {
+            result.status = CompatibilityTestResult.STATUS_ERROR;
+            result.message = "install timeout";
+            CLog.d(
+                    "Installing apk file %s timed out, package: %s, error: %s.",
+                    apkFile.getAbsolutePath(), result.packageName, result.message);
+            return;
+        }
+        CLog.d("Completed installing apk file %s.", apkFile.getAbsolutePath());
+    }
+
+    /**
+     * Method which attempts to launch an APK.
+     *
+     * <p>Will set the result status to failure if the APK could not be launched.
+     *
+     * @param result the {@link CompatibilityTestResult} containing the APK info.
+     * @throws DeviceNotAvailableException
+     */
+    private void launchApk(CompatibilityTestResult result) throws DeviceNotAvailableException {
+        CLog.d("Lauching package: %s.", result.packageName);
+        InstrumentationTest instrTest = createInstrumentationTest(result.packageName);
+        instrTest.setDevice(mDevice);
+
+        FailureCollectingListener failureListener = new FailureCollectingListener();
+        instrTest.run(failureListener);
+
+        if (failureListener.getStackTrace() != null) {
+            CLog.w("Failed to launch package: %s.", result.packageName);
+            result.status = CompatibilityTestResult.STATUS_FAILURE;
+            result.message = failureListener.getStackTrace();
+        }
+
+        CLog.d("Completed launching package: %s", result.packageName);
+    }
+
+    /** Helper method which reports a test failed if the status is either a failure or an error. */
+    private void reportResult(
+            ITestInvocationListener listener, TestDescription id, CompatibilityTestResult result) {
+        String message = result.message != null ? result.message : "unknown";
+        if (CompatibilityTestResult.STATUS_ERROR.equals(result.status)) {
+            listener.testFailed(id, "ERROR:" + message);
+        } else if (CompatibilityTestResult.STATUS_FAILURE.equals(result.status)) {
+            listener.testFailed(id, "FAILURE:" + message);
+        }
+    }
+
+    /** Helper method which posts the logcat. */
+    private void postLogcat(CompatibilityTestResult result, ITestInvocationListener listener)
+            throws JSONException {
+        InputStreamSource stream = null;
+        String header =
+                String.format(
+                        "%s%s%s\n",
+                        CompatibilityTestResult.SEPARATOR,
+                        result.toJsonString(),
+                        CompatibilityTestResult.SEPARATOR);
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (InputStreamSource logcatData = mLogcat.getLogcatData()) {
+            try {
+                baos.write(header.getBytes());
+                StreamUtil.copyStreams(logcatData.createInputStream(), baos);
+                stream = new ByteArrayInputStreamSource(baos.toByteArray());
+                baos.flush();
+                baos.close();
+            } catch (IOException e) {
+                CLog.e("error inserting compatibility test result into logcat");
+                CLog.e(e);
+                // fallback to logcat data
+                stream = logcatData;
+            }
+            listener.testLog("logcat_" + result.packageName, LogDataType.LOGCAT, stream);
+        } finally {
+            StreamUtil.cancel(stream);
+        }
+    }
+
+    /** Helper method which takes a list of {@link ApkInfo} objects and returns the sharded list. */
+    private List<ApkInfo> shardApkList(List<ApkInfo> apkList) {
+        List<ApkInfo> shardedList = new ArrayList<>(apkList.size() / mShardCount + 1);
+        for (int i = mShardIndex; i < apkList.size(); i += mShardCount) {
+            shardedList.add(apkList.get(i));
+        }
+        return shardedList;
+    }
+
+    /**
+     * Helper method which takes a list of {@link ApkInfo} objects and returns the filtered list.
+     */
+    protected List<ApkInfo> filterApk(List<ApkInfo> apkList) {
+        List<ApkInfo> filteredList = new ArrayList<>();
+
+        for (ApkInfo apk : apkList) {
+            if (filterTest(apk.packageName)) {
+                filteredList.add(apk);
+            }
+        }
+
+        return filteredList;
+    }
+
+    /**
+     * Return true if a test matches one or more of the include filters AND does not match any of
+     * the exclude filters. If no include filters are given all tests should return true as long as
+     * they do not match any of the exclude filters.
+     */
+    protected boolean filterTest(String testName) {
+        if (mExcludeFilters.contains(testName)) {
+            return false;
+        }
+        if (mIncludeFilters.size() == 0 || mIncludeFilters.contains(testName)) {
+            return true;
+        }
+        return false;
+    }
+
+    /** Returns true if either object is null or if both objects are equal. */
+    private static boolean equalsOrNull(Object a, Object b) {
+        return a == null || b == null || a.equals(b);
+    }
+
+    /** Helper {@link Runnable} which downloads a file, and can be used in another thread. */
+    private class ApkDownloadRunnable implements Runnable {
+        private final File mKharonDir;
+        private final ApkInfo mApkInfo;
+
+        private File mDownloadedFile = null;
+
+        ApkDownloadRunnable(File kharonDir, ApkInfo apkInfo) {
+            mKharonDir = kharonDir;
+            mApkInfo = apkInfo;
+        }
+
+        @Override
+        public void run() {
+            // No-op if mApkInfo is null
+            if (mApkInfo == null) {
+                CLog.d("ApkInfo is null.");
+                return;
+            }
+
+            File sourceFile = new File(mKharonDir, mApkInfo.fileName);
+            try {
+                mDownloadedFile =
+                        PublicApkUtil.downloadFile(
+                                sourceFile, DOWNLOAD_TIMEOUT_MS, DOWNLOAD_RETRIES);
+            } catch (IOException e) {
+                // Log and ignore
+                CLog.e("Could not download apk from %s.", sourceFile);
+                CLog.e(e);
+            }
+            CLog.d("Completed downloading apk file: %s.", mDownloadedFile.getAbsolutePath());
+        }
+
+        public File getDownloadedFile() {
+            return mDownloadedFile;
+        }
+    }
+
+    @Override
+    public void setConfiguration(IConfiguration configuration) {
+        mConfiguration = configuration;
+    }
+
+    /*
+     * {@inheritDoc}
+     */
+    @Override
+    public void setDevice(ITestDevice device) {
+        mDevice = device;
+    }
+
+    /*
+     * {@inheritDoc}
+     */
+    @Override
+    public ITestDevice getDevice() {
+        return mDevice;
+    }
+
+    /** Return a {@link IRunUtil} instance to execute commands with. */
+    IRunUtil getRunUtil() {
+        return RunUtil.getDefault();
+    }
+
+    private IRemoteTest getTestShard(int shardCount, int shardIndex) {
+        AppCompatibilityTest shard;
+        try {
+            shard = getClass().newInstance();
+        } catch (InstantiationException | IllegalAccessException e) {
+            throw new IllegalStateException(
+                    "The class "
+                            + getClass().getName()
+                            + " has no public constructor with no arguments, but all subclasses of "
+                            + AppCompatibilityTest.class.getName()
+                            + " should",
+                    e);
+        }
+        try {
+            OptionCopier.copyOptions(this, shard);
+        } catch (ConfigurationException e) {
+            CLog.e("Failed to copy test options: %s.", e.getMessage());
+        }
+        shard.mShardIndex = shardIndex;
+        shard.mShardCount = shardCount;
+        return shard;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Collection<IRemoteTest> split(int shardCountHint) {
+        if (shardCountHint <= 1) {
+            // cannot shard or already sharded
+            return null;
+        }
+        Collection<IRemoteTest> shards = new ArrayList<>(shardCountHint);
+        for (int index = 0; index < shardCountHint; index++) {
+            shards.add(getTestShard(shardCountHint, index));
+        }
+        return shards;
+    }
+
+    /**
+     * Get a test description for use in logging. For compatibility with logs, this should be
+     * TestDescription(launcher package, package being run).
+     */
+    private TestDescription createTestDescription(String packageBeingTested) {
+        return new TestDescription(mLauncherPackage, packageBeingTested);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void addIncludeFilter(String filter) {
+        checkArgument(!Strings.isNullOrEmpty(filter), "Include filter cannot be null or empty.");
+        mIncludeFilters.add(filter);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void addAllIncludeFilters(Set<String> filters) {
+        checkNotNull(filters, "Include filters cannot be null.");
+        mIncludeFilters.addAll(filters);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void clearIncludeFilters() {
+        mIncludeFilters.clear();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<String> getIncludeFilters() {
+        return Collections.unmodifiableSet(mIncludeFilters);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void addExcludeFilter(String filter) {
+        checkArgument(!Strings.isNullOrEmpty(filter), "Exclude filter cannot be null or empty.");
+        mExcludeFilters.add(filter);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void addAllExcludeFilters(Set<String> filters) {
+        checkNotNull(filters, "Exclude filters cannot be null.");
+        mExcludeFilters.addAll(filters);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void clearExcludeFilters() {
+        mExcludeFilters.clear();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<String> getExcludeFilters() {
+        return Collections.unmodifiableSet(mExcludeFilters);
+    }
+}
diff --git a/harness/src/main/java/com/android/compatibility/AppCrawlerCompatibilityTest.java b/harness/src/main/java/com/android/compatibility/AppCrawlerCompatibilityTest.java
new file mode 100644
index 0000000..5c0fe11
--- /dev/null
+++ b/harness/src/main/java/com/android/compatibility/AppCrawlerCompatibilityTest.java
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2019 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.compatibility;
+
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.testtype.InstrumentationTest;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+
+@OptionClass(alias = "app-compatibility-crawler")
+public final class AppCrawlerCompatibilityTest extends AppCompatibilityTest {
+    private static final String WALKMAN_RUN_MS_LABEL = "maxDuration";
+    private static final String WALKMAN_STEPS_LABEL = "maxSteps";
+
+    @Option(
+            name = "walkman-run-ms",
+            description = "Time to run walkman in msecs (only used if test-strategy=walkman).")
+    private int mWalkmanRunMs = 60 * 1000;
+
+    @Option(
+            name = "walkman-steps",
+            description =
+                    "Max number of steps to run walkman (only used if test-strategy=walkman)."
+                            + " -1 for no limit")
+    private int mWalkmanSteps = -1;
+
+    public AppCrawlerCompatibilityTest() {
+        super(
+                "com.google.android.apps.common.walkman.apps",
+                "com.google.android.apps.common.testing.testrunner"
+                        + ".Google3InstrumentationTestRunner",
+                /*
+                 * We are using /google/data/ro/teams/walkman/walkman.apk which has parameter
+                 * "packages" unlike the up-to-date version in source which uses "package"
+                 * see: com.google.android.apps.common.walkman.apps.EngineFactory::getCrawlEngine.
+                 * This currently works with the up-to-date version in source, as well.
+                 *
+                 * Neither of these should be confused with "package_to_launch", which is used by
+                 * AppCompatibilityRunner
+                 */
+                "packages");
+    }
+
+    @Override
+    public InstrumentationTest createInstrumentationTest(String packageBeingTested) {
+        InstrumentationTest instrTest = createDefaultInstrumentationTest(packageBeingTested);
+
+        instrTest.addInstrumentationArg(WALKMAN_RUN_MS_LABEL, Integer.toString(mWalkmanRunMs));
+        instrTest.addInstrumentationArg(WALKMAN_STEPS_LABEL, Integer.toString(mWalkmanSteps));
+
+        String launcherClass = mLauncherPackage + ".WalkmanInstrumentationEntry";
+        instrTest.setClassName(launcherClass);
+        /*
+         * InstrumentationTest can't deduce the exact test to run, so we specify it
+         * manually. Note that the TestDescription we use here is a different one from
+         * the one returned by {@link TestStrategy#createTestDescription}.
+         *
+         * This list is required to be mutable, so we wrap in ArrayList.
+         */
+        instrTest.setTestsToRun(
+                new ArrayList<>(Arrays.asList(new TestDescription(launcherClass, "testEntry"))));
+
+        return instrTest;
+    }
+}
diff --git a/harness/src/main/java/com/android/compatibility/AppLaunchCompatibilityTest.java b/harness/src/main/java/com/android/compatibility/AppLaunchCompatibilityTest.java
new file mode 100644
index 0000000..dd1f648
--- /dev/null
+++ b/harness/src/main/java/com/android/compatibility/AppLaunchCompatibilityTest.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2019 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.compatibility;
+
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.testtype.InstrumentationTest;
+
+/** Uses AppCompatibilityRunner to check if the app starts correctly. */
+@OptionClass(alias = "app-compatibility")
+public final class AppLaunchCompatibilityTest extends AppCompatibilityTest {
+    private static final String APP_LAUNCH_TIMEOUT_LABEL = "app_launch_timeout_ms";
+    private static final String WORKSPACE_LAUNCH_TIMEOUT_LABEL = "workspace_launch_timeout_ms";
+
+    @Option(
+            name = "app-launch-timeout-ms",
+            description = "Time to wait for app to launch in msecs.")
+    private int mAppLaunchTimeoutMs = 15000;
+
+    @Option(
+            name = "workspace-launch-timeout-ms",
+            description = "Time to wait when launched back into the workspace in msecs.")
+    private int mWorkspaceLaunchTimeoutMs = 2000;
+
+    public AppLaunchCompatibilityTest() {
+        super(
+                "com.android.compatibilitytest",
+                "com.android.compatibilitytest.AppCompatibilityRunner",
+                "package_to_launch");
+    }
+
+    @Override
+    public InstrumentationTest createInstrumentationTest(String packageBeingTested) {
+        InstrumentationTest instrTest = createDefaultInstrumentationTest(packageBeingTested);
+        instrTest.addInstrumentationArg(
+                APP_LAUNCH_TIMEOUT_LABEL, Integer.toString(mAppLaunchTimeoutMs));
+        instrTest.addInstrumentationArg(
+                WORKSPACE_LAUNCH_TIMEOUT_LABEL, Integer.toString(mWorkspaceLaunchTimeoutMs));
+        return instrTest;
+    }
+}
diff --git a/harness/src/main/java/com/android/compatibility/FailureCollectingListener.java b/harness/src/main/java/com/android/compatibility/FailureCollectingListener.java
new file mode 100644
index 0000000..d022231
--- /dev/null
+++ b/harness/src/main/java/com/android/compatibility/FailureCollectingListener.java
@@ -0,0 +1,58 @@
+/*
+ * Copyright (C) 2012 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.compatibility;
+
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.TestDescription;
+
+public final class FailureCollectingListener implements ITestInvocationListener {
+    private String mTestTrace = null;
+
+    @Override
+    public void testFailed(TestDescription test, String trace) {
+        setStackTrace(trace != null ? trace : "unknown failure");
+    }
+
+    @Override
+    public void testAssumptionFailure(TestDescription test, String trace) {
+        setStackTrace(trace != null ? trace : "unknown assumption failure");
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void testRunFailed(String errorMessage) {
+        setStackTrace(errorMessage);
+    }
+
+    /**
+     * Fetches the stack trace if any.
+     *
+     * @return the stack trace.
+     */
+    public String getStackTrace() {
+        return mTestTrace;
+    }
+
+    /**
+     * Sets the stack trace.
+     *
+     * @param stackTrace {@link String} stack trace to set.
+     */
+    public void setStackTrace(String stackTrace) {
+        this.mTestTrace = stackTrace;
+    }
+}
diff --git a/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparer.java b/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparer.java
new file mode 100644
index 0000000..b742c2f
--- /dev/null
+++ b/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparer.java
@@ -0,0 +1,115 @@
+/*
+ * Copyright (C) 2020 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.compatibility.targetprep;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.targetprep.BuildError;
+import com.android.tradefed.targetprep.ITargetPreparer;
+import com.android.tradefed.targetprep.TargetSetupError;
+import com.android.tradefed.targetprep.TestAppInstallSetup;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.stream.Collectors;
+
+/** A Tradefed preparer that downloads and installs an app on the target device. */
+public final class AppSetupPreparer implements ITargetPreparer {
+
+    public static final String OPTION_GCS_APK_DIR = "gcs-apk-dir";
+
+    @Option(name = "package-name", description = "Package name of the app being tested.")
+    private String mPackageName;
+
+    private final TestAppInstallSetup mAppInstallSetup;
+
+    public AppSetupPreparer() {
+        this(null, new TestAppInstallSetup());
+    }
+
+    @VisibleForTesting
+    public AppSetupPreparer(String packageName, TestAppInstallSetup appInstallSetup) {
+        this.mPackageName = packageName;
+        this.mAppInstallSetup = appInstallSetup;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void setUp(ITestDevice device, IBuildInfo buildInfo)
+            throws DeviceNotAvailableException, BuildError, TargetSetupError {
+        // TODO(b/147159584): Use a utility to get dynamic options.
+        String gcsApkDirOption = buildInfo.getBuildAttributes().get(OPTION_GCS_APK_DIR);
+        checkNotNull(gcsApkDirOption, "Option %s is not set.", OPTION_GCS_APK_DIR);
+
+        File apkDir = new File(gcsApkDirOption);
+        checkArgument(
+                apkDir.isDirectory(),
+                String.format("GCS Apk Directory %s is not a directory", apkDir));
+
+        File packageDir = new File(apkDir.getPath(), mPackageName);
+        checkArgument(
+                packageDir.isDirectory(),
+                String.format("Package directory %s is not a directory", packageDir));
+
+        mAppInstallSetup.setAltDir(packageDir);
+
+        List<String> apkFilePaths;
+        try {
+            apkFilePaths = listApkFilePaths(packageDir);
+        } catch (IOException e) {
+            throw new TargetSetupError(
+                    String.format("Failed to access files in %s.", packageDir), e);
+        }
+
+        if (apkFilePaths.isEmpty()) {
+            throw new TargetSetupError(
+                    String.format("Failed to find apk files in %s.", packageDir));
+        }
+
+        if (apkFilePaths.size() == 1) {
+            mAppInstallSetup.addTestFileName(apkFilePaths.get(0));
+        } else {
+            mAppInstallSetup.addSplitApkFileNames(String.join(",", apkFilePaths));
+        }
+
+        mAppInstallSetup.setUp(device, buildInfo);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void tearDown(TestInformation testInfo, Throwable e) throws DeviceNotAvailableException {
+        mAppInstallSetup.tearDown(testInfo, e);
+    }
+
+    private List<String> listApkFilePaths(File downloadDir) throws IOException {
+        return Files.walk(Paths.get(downloadDir.getPath()))
+                .map(x -> x.getFileName().toString())
+                .filter(s -> s.endsWith(".apk"))
+                .collect(Collectors.toList());
+    }
+}
diff --git a/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiver.java b/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiver.java
new file mode 100644
index 0000000..7e50f3f
--- /dev/null
+++ b/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiver.java
@@ -0,0 +1,56 @@
+/*
+ * Copyright (C) 2020 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.compatibility.targetprep;
+
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.targetprep.ITargetPreparer;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+
+/**
+ * A Tradefed preparer that receives module preparer options and stores the values in IBuildInfo.
+ */
+public final class AppSetupPreparerConfigurationReceiver implements ITargetPreparer {
+
+    @Option(
+            name = AppSetupPreparer.OPTION_GCS_APK_DIR,
+            description = "GCS path where the test apk files are located.")
+    private File mOptionGcsApkDir;
+
+    public AppSetupPreparerConfigurationReceiver() {
+        this(null);
+    }
+
+    @VisibleForTesting
+    public AppSetupPreparerConfigurationReceiver(File optionGcsApkDir) {
+        mOptionGcsApkDir = optionGcsApkDir;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void setUp(ITestDevice device, IBuildInfo buildInfo) {
+        if (mOptionGcsApkDir == null) {
+            return;
+        }
+        buildInfo.addBuildAttribute(
+                AppSetupPreparer.OPTION_GCS_APK_DIR, mOptionGcsApkDir.getPath());
+    }
+}
diff --git a/harness/src/main/java/com/android/compatibility/testtype/AppLaunchTest.java b/harness/src/main/java/com/android/compatibility/testtype/AppLaunchTest.java
new file mode 100644
index 0000000..fb34dbf
--- /dev/null
+++ b/harness/src/main/java/com/android/compatibility/testtype/AppLaunchTest.java
@@ -0,0 +1,416 @@
+/*
+ * Copyright (C) 2012 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.compatibility.testtype;
+
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.common.base.Preconditions.checkNotNull;
+
+import com.android.compatibility.FailureCollectingListener;
+import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.IConfigurationReceiver;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.device.LogcatReceiver;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.ByteArrayInputStreamSource;
+import com.android.tradefed.result.CompatibilityTestResult;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.InputStreamSource;
+import com.android.tradefed.result.LogDataType;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.testtype.IDeviceTest;
+import com.android.tradefed.testtype.IRemoteTest;
+import com.android.tradefed.testtype.ITestFilterReceiver;
+import com.android.tradefed.testtype.InstrumentationTest;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.StreamUtil;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+
+import org.json.JSONException;
+import org.junit.Assert;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Set;
+
+/** A test that verifies that a single app can be successfully launched. */
+public class AppLaunchTest
+        implements IDeviceTest, IRemoteTest, IConfigurationReceiver, ITestFilterReceiver {
+
+    @Option(name = "package-name", description = "Package name of testing app.")
+    private String mPackageName;
+
+    @Option(name = "test-label", description = "Unique test identifier label.")
+    private String mTestLabel = "AppCompatibility";
+
+    /** @deprecated */
+    @Deprecated
+    @Option(
+            name = "retry-count",
+            description = "Number of times to retry a failed test case. 0 means no retry.")
+    private int mRetryCount = 0;
+
+    @Option(name = "include-filter", description = "The include filter of the test type.")
+    protected Set<String> mIncludeFilters = new HashSet<>();
+
+    @Option(name = "exclude-filter", description = "The exclude filter of the test type.")
+    protected Set<String> mExcludeFilters = new HashSet<>();
+
+    @Option(
+            name = "app-launch-timeout-ms",
+            description = "Time to wait for app to launch in msecs.")
+    private int mAppLaunchTimeoutMs = 15000;
+
+    private static final String LAUNCH_TEST_RUNNER =
+            "com.android.compatibilitytest.AppCompatibilityRunner";
+    private static final String LAUNCH_TEST_PACKAGE = "com.android.compatibilitytest";
+    private static final String PACKAGE_TO_LAUNCH = "package_to_launch";
+    private static final String APP_LAUNCH_TIMEOUT_LABEL = "app_launch_timeout_ms";
+    private static final int LOGCAT_SIZE_BYTES = 20 * 1024 * 1024;
+
+    private ITestDevice mDevice;
+    private LogcatReceiver mLogcat;
+    private IConfiguration mConfiguration;
+
+    public AppLaunchTest() {}
+
+    @VisibleForTesting
+    public AppLaunchTest(String packageName) {
+        mPackageName = packageName;
+    }
+
+    @VisibleForTesting
+    public AppLaunchTest(String packageName, int retryCount) {
+        mPackageName = packageName;
+        mRetryCount = retryCount;
+    }
+
+    /**
+     * Creates and sets up an instrumentation test with information about the test runner as well as
+     * the package being tested (provided as a parameter).
+     */
+    protected InstrumentationTest createInstrumentationTest(String packageBeingTested) {
+        InstrumentationTest instrTest = new InstrumentationTest();
+
+        instrTest.setPackageName(LAUNCH_TEST_PACKAGE);
+        instrTest.setConfiguration(mConfiguration);
+        instrTest.addInstrumentationArg(PACKAGE_TO_LAUNCH, packageBeingTested);
+        instrTest.setRunnerName(LAUNCH_TEST_RUNNER);
+        instrTest.setDevice(mDevice);
+        instrTest.addInstrumentationArg(
+                APP_LAUNCH_TIMEOUT_LABEL, Integer.toString(mAppLaunchTimeoutMs));
+
+        return instrTest;
+    }
+
+    /*
+     * {@inheritDoc}
+     */
+    @Override
+    public void run(final TestInformation testInfo, final ITestInvocationListener listener)
+            throws DeviceNotAvailableException {
+        CLog.d("Start of run method.");
+        CLog.d("Include filters: %s", mIncludeFilters);
+        CLog.d("Exclude filters: %s", mExcludeFilters);
+
+        Assert.assertNotNull("Package name cannot be null", mPackageName);
+
+        TestDescription testDescription = createTestDescription();
+
+        if (!inFilter(testDescription.toString())) {
+            CLog.d("Test case %s doesn't match any filter", testDescription);
+            return;
+        }
+        CLog.d("Complete filtering test case: %s", testDescription);
+
+        long start = System.currentTimeMillis();
+        listener.testRunStarted(mTestLabel, 1);
+        mLogcat = new LogcatReceiver(getDevice(), LOGCAT_SIZE_BYTES, 0);
+        mLogcat.start();
+
+        try {
+            testPackage(testInfo, testDescription, listener);
+        } catch (InterruptedException e) {
+            CLog.e(e);
+            throw new RuntimeException(e);
+        } finally {
+            mLogcat.stop();
+            listener.testRunEnded(
+                    System.currentTimeMillis() - start, new HashMap<String, Metric>());
+        }
+    }
+
+    /**
+     * Attempts to test a package and reports the results.
+     *
+     * @param listener The {@link ITestInvocationListener}.
+     * @throws DeviceNotAvailableException
+     */
+    private void testPackage(
+            final TestInformation testInfo,
+            TestDescription testDescription,
+            ITestInvocationListener listener)
+            throws DeviceNotAvailableException, InterruptedException {
+        CLog.d("Started testing package: %s.", mPackageName);
+
+        listener.testStarted(testDescription, System.currentTimeMillis());
+
+        CompatibilityTestResult result = createCompatibilityTestResult();
+        result.packageName = mPackageName;
+
+        try {
+            for (int i = 0; i <= mRetryCount; i++) {
+                result.status = null;
+                result.message = null;
+                // Clear test result between retries.
+                launchPackage(testInfo, result);
+                if (result.status == CompatibilityTestResult.STATUS_SUCCESS) {
+                    return;
+                }
+            }
+        } finally {
+            reportResult(listener, testDescription, result);
+            stopPackage();
+            try {
+                postLogcat(result, listener);
+            } catch (JSONException e) {
+                CLog.w("Posting failed: %s.", e.getMessage());
+            }
+            listener.testEnded(
+                    testDescription,
+                    System.currentTimeMillis(),
+                    Collections.<String, String>emptyMap());
+
+            CLog.d("Completed testing package: %s.", mPackageName);
+        }
+    }
+
+    /**
+     * Method which attempts to launch a package.
+     *
+     * <p>Will set the result status to success if the package could be launched. Otherwise the
+     * result status will be set to failure.
+     *
+     * @param result the {@link CompatibilityTestResult} containing the package info.
+     * @throws DeviceNotAvailableException
+     */
+    private void launchPackage(final TestInformation testInfo, CompatibilityTestResult result)
+            throws DeviceNotAvailableException {
+        CLog.d("Launching package: %s.", result.packageName);
+
+        CommandResult resetResult = resetPackage();
+        if (resetResult.getStatus() != CommandStatus.SUCCESS) {
+            result.status = CompatibilityTestResult.STATUS_ERROR;
+            result.message = resetResult.getStatus() + resetResult.getStderr();
+            return;
+        }
+
+        InstrumentationTest instrTest = createInstrumentationTest(result.packageName);
+
+        FailureCollectingListener failureListener = createFailureListener();
+        instrTest.run(testInfo, failureListener);
+        CLog.d("Stack Trace: %s", failureListener.getStackTrace());
+
+        if (failureListener.getStackTrace() != null) {
+            CLog.w("Failed to launch package: %s.", result.packageName);
+            result.status = CompatibilityTestResult.STATUS_FAILURE;
+            result.message = failureListener.getStackTrace();
+        } else {
+            result.status = CompatibilityTestResult.STATUS_SUCCESS;
+        }
+
+        CLog.d("Completed launching package: %s", result.packageName);
+    }
+
+    /** Helper method which reports a test failed if the status is either a failure or an error. */
+    private void reportResult(
+            ITestInvocationListener listener, TestDescription id, CompatibilityTestResult result) {
+        String message = result.message != null ? result.message : "unknown";
+        String tag = errorStatusToTag(result.status);
+        if (tag != null) {
+            listener.testFailed(id, result.status + ":" + message);
+        }
+    }
+
+    private String errorStatusToTag(String status) {
+        if (status.equals(CompatibilityTestResult.STATUS_ERROR)) {
+            return "ERROR";
+        }
+        if (status.equals(CompatibilityTestResult.STATUS_FAILURE)) {
+            return "FAILURE";
+        }
+        return null;
+    }
+
+    /** Helper method which posts the logcat. */
+    private void postLogcat(CompatibilityTestResult result, ITestInvocationListener listener)
+            throws JSONException {
+        InputStreamSource stream = null;
+        String header =
+                String.format(
+                        "%s%s%s\n",
+                        CompatibilityTestResult.SEPARATOR,
+                        result.toJsonString(),
+                        CompatibilityTestResult.SEPARATOR);
+
+        try (InputStreamSource logcatData = mLogcat.getLogcatData()) {
+            try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
+                baos.write(header.getBytes());
+                StreamUtil.copyStreams(logcatData.createInputStream(), baos);
+                stream = new ByteArrayInputStreamSource(baos.toByteArray());
+            } catch (IOException e) {
+                CLog.e("error inserting compatibility test result into logcat");
+                CLog.e(e);
+                // fallback to logcat data
+                stream = logcatData;
+            }
+            listener.testLog("logcat_" + result.packageName, LogDataType.LOGCAT, stream);
+        } finally {
+            StreamUtil.cancel(stream);
+        }
+    }
+
+    /**
+     * Return true if a test matches one or more of the include filters AND does not match any of
+     * the exclude filters. If no include filters are given all tests should return true as long as
+     * they do not match any of the exclude filters.
+     */
+    protected boolean inFilter(String testName) {
+        if (mExcludeFilters.contains(testName)) {
+            return false;
+        }
+        if (mIncludeFilters.size() == 0 || mIncludeFilters.contains(testName)) {
+            return true;
+        }
+        return false;
+    }
+
+    protected CommandResult resetPackage() throws DeviceNotAvailableException {
+        return mDevice.executeShellV2Command(String.format("pm clear %s", mPackageName));
+    }
+
+    private void stopPackage() throws DeviceNotAvailableException {
+        mDevice.executeShellCommand(String.format("am force-stop %s", mPackageName));
+    }
+
+    @Override
+    public void setConfiguration(IConfiguration configuration) {
+        mConfiguration = configuration;
+    }
+
+    /*
+     * {@inheritDoc}
+     */
+    @Override
+    public void setDevice(ITestDevice device) {
+        mDevice = device;
+    }
+
+    /*
+     * {@inheritDoc}
+     */
+    @Override
+    public ITestDevice getDevice() {
+        return mDevice;
+    }
+
+    public int getmRetryCount() {
+        return mRetryCount;
+    }
+
+    /**
+     * Get a test description for use in logging. For compatibility with logs, this should be
+     * TestDescription(test class name, test type).
+     */
+    private TestDescription createTestDescription() {
+        return new TestDescription(getClass().getSimpleName(), mPackageName);
+    }
+
+    /** Get a FailureCollectingListener for failure listening. */
+    private FailureCollectingListener createFailureListener() {
+        return new FailureCollectingListener();
+    }
+
+    /**
+     * Get a CompatibilityTestResult for encapsulating compatibility run results for a single app
+     * package tested.
+     */
+    private CompatibilityTestResult createCompatibilityTestResult() {
+        return new CompatibilityTestResult();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void addIncludeFilter(String filter) {
+        checkArgument(!Strings.isNullOrEmpty(filter), "Include filter cannot be null or empty.");
+        mIncludeFilters.add(filter);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void addAllIncludeFilters(Set<String> filters) {
+        checkNotNull(filters, "Include filters cannot be null.");
+        mIncludeFilters.addAll(filters);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void clearIncludeFilters() {
+        mIncludeFilters.clear();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<String> getIncludeFilters() {
+        return Collections.unmodifiableSet(mIncludeFilters);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void addExcludeFilter(String filter) {
+        checkArgument(!Strings.isNullOrEmpty(filter), "Exclude filter cannot be null or empty.");
+        mExcludeFilters.add(filter);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void addAllExcludeFilters(Set<String> filters) {
+        checkNotNull(filters, "Exclude filters cannot be null.");
+        mExcludeFilters.addAll(filters);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void clearExcludeFilters() {
+        mExcludeFilters.clear();
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public Set<String> getExcludeFilters() {
+        return Collections.unmodifiableSet(mExcludeFilters);
+    }
+}
diff --git a/harness/src/main/java/com/android/tradefed/result/CompatibilityTestResult.java b/harness/src/main/java/com/android/tradefed/result/CompatibilityTestResult.java
new file mode 100644
index 0000000..e1f92b9
--- /dev/null
+++ b/harness/src/main/java/com/android/tradefed/result/CompatibilityTestResult.java
@@ -0,0 +1,98 @@
+/*
+ * 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.tradefed.result;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.regex.Pattern;
+
+/** encapsulates compatibility run results for a single app package tested */
+public class CompatibilityTestResult {
+
+    public static final String KEY_PACKAGE = "app_package";
+    // Keep app_version for backwards compatibility
+    public static final String KEY_VERSION_CODE = "app_version_code";
+    public static final String KEY_VERSION_STRING = "app_version_string";
+    public static final String KEY_NAME = "app_name";
+    public static final String KEY_RANK = "app_rank";
+    public static final String KEY_STATUS = "status";
+    public static final String KEY_MESSAGE = "message";
+
+    public static final String SEPARATOR = "=@ppcomp@t=";
+    public static final Pattern REGEX =
+            Pattern.compile(String.format("^%s(.*?)%s", SEPARATOR, SEPARATOR));
+
+    public static final String STATUS_SUCCESS = "success";
+    public static final String STATUS_ERROR = "error"; // installation errors etc
+    public static final String STATUS_FAILURE = "failure"; // app launch failures
+
+    public String packageName = null;
+    public String versionString = null;
+    public String versionCode = null;
+    public String name = null;
+    public Integer rank = null;
+    public String status = null;
+    public String message = null;
+
+    /**
+     * Return the Serialized fields into JSON string
+     *
+     * @throws JSONException
+     */
+    public String toJsonString() throws JSONException {
+        JSONObject o = new JSONObject();
+        o.put(KEY_PACKAGE, packageName);
+        o.put(KEY_VERSION_STRING, versionString);
+        o.put(KEY_VERSION_CODE, versionString);
+        o.put(KEY_NAME, name);
+        o.put(KEY_RANK, rank);
+        o.put(KEY_STATUS, status);
+        o.put(KEY_MESSAGE, message);
+        return o.toString();
+    }
+
+    /**
+     * Reconstructs an instance from a JSON string
+     *
+     * @param json
+     * @return the {@link CompatibilityTestResult} instance from the JSON serialized string.
+     * @throws JSONException
+     */
+    public static CompatibilityTestResult fromJsonString(String json) throws JSONException {
+        JSONObject o = new JSONObject(json);
+        CompatibilityTestResult result = new CompatibilityTestResult();
+        result.packageName = o.getString(KEY_PACKAGE);
+        if (o.has(KEY_VERSION_STRING)) {
+            result.versionString = o.getString(KEY_VERSION_STRING);
+        }
+        if (o.has(KEY_VERSION_CODE)) {
+            result.versionString = o.getString(KEY_VERSION_CODE);
+        }
+        if (o.has(KEY_NAME)) {
+            result.name = o.getString(KEY_NAME);
+        }
+        if (o.has(KEY_RANK)) {
+            result.rank = o.getInt(KEY_RANK);
+        }
+        result.status = o.getString(KEY_STATUS);
+        if (o.has(KEY_MESSAGE)) {
+            result.message = o.getString(KEY_MESSAGE);
+        }
+        return result;
+    }
+}
diff --git a/harness/src/main/java/com/android/tradefed/util/PublicApkUtil.java b/harness/src/main/java/com/android/tradefed/util/PublicApkUtil.java
new file mode 100644
index 0000000..3e2915e
--- /dev/null
+++ b/harness/src/main/java/com/android/tradefed/util/PublicApkUtil.java
@@ -0,0 +1,225 @@
+/*
+ * 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.tradefed.util;
+
+import com.android.tradefed.log.LogUtil.CLog;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.FilenameFilter;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.regex.Pattern;
+
+/**
+ * Utility class to download files from x20 to the corp TF environment. In particular, it is used in
+ * conjunction with the <a href="http://go/app-compatibility-readme">App Compatibility pipeline</a>.
+ */
+public class PublicApkUtil {
+    private static final Pattern DATE_FORMAT = Pattern.compile("\\d{8}");
+    private static final long DOWNLOAD_TIMEOUT_MS = 60 * 1000;
+    private static final int DOWNLOAD_RETRIES = 3;
+    private static final String LATEST_FILE = "latest.txt";
+
+    /**
+     * Helper method which constructs the dated CNS directory from the base directory and either the
+     * supplied date option or the most recent directory.
+     *
+     * @param baseDir The base directory with the "latest" file and dated subdirectories.
+     * @param subDir A specific target directory, or null if using the latest file.
+     * @return The {@link File} of the x20 dir where the APKs are stored.
+     * @throws IOException
+     */
+    public static File constructApkDir(String baseDir, String subDir) throws IOException {
+        if (subDir != null) {
+            return new File(baseDir, subDir);
+        }
+        File latestFile = null;
+        try {
+            latestFile =
+                    downloadFile(
+                            new File(baseDir, LATEST_FILE), DOWNLOAD_TIMEOUT_MS, DOWNLOAD_RETRIES);
+            String date = FileUtil.readStringFromFile(latestFile).trim();
+            if (DATE_FORMAT.matcher(date).matches()) {
+                return new File(baseDir, date);
+            }
+            return null;
+        } finally {
+            FileUtil.deleteFile(latestFile);
+        }
+    }
+
+    /**
+     * A configurable helper method for downloading a remote file.
+     *
+     * @param remoteFile The remote {@link File} location.
+     * @param downloadTimeout The download timeout in milliseconds.
+     * @param downloadRetries The download retry count, in case of failure.
+     * @return The local {@link File} that was downloaded.
+     * @throws IOException
+     */
+    public static File downloadFile(File remoteFile, long downloadTimeout, int downloadRetries)
+            throws IOException {
+        CLog.i("Attempting to download %s", remoteFile);
+        File tmpFile = FileUtil.createTempFile(remoteFile.getName(), null);
+        FileUtil.copyFile(remoteFile, tmpFile);
+        return tmpFile;
+    }
+
+    /**
+     * Helper method which downloads the ranking file and returns the list of apks.
+     *
+     * @param flavor The APK variant to pick.
+     * @param dir The {@link File} of the dated x20 dir.
+     * @param fallbackToApkScan fallback to scan for apk files in folder if no ranking csv file
+     * @return The list of {@link ApkInfo} objects.
+     * @throws IOException
+     */
+    public static List<ApkInfo> getApkList(String flavor, File dir, boolean fallbackToApkScan)
+            throws IOException {
+        File apkFile = new File(dir, String.format("%s_ranking.csv", flavor));
+        if (!apkFile.exists() && fallbackToApkScan) {
+            return getApkListFromDirectory(dir);
+        } else {
+            return getApkListFromRankingInfo(apkFile);
+        }
+    }
+
+    /**
+     * Constructs a list of degenerate {@link ApkInfo} based on apks files found in provided base
+     * directory. The {@link ApkInfo} instance only contains relative filename, without the ranking
+     * information.
+     *
+     * @param baseDir
+     * @return
+     * @throws IOException
+     */
+    private static List<ApkInfo> getApkListFromDirectory(File baseDir) throws IOException {
+        List<ApkInfo> apkList = new ArrayList<>();
+        File[] apks =
+                baseDir.listFiles(
+                        new FilenameFilter() {
+                            @Override
+                            public boolean accept(File dir, String name) {
+                                // filters out all apk files
+                                return name.endsWith(".apk");
+                            }
+                        });
+        for (File apk : apks) {
+            AaptParser parser = AaptParser.parse(apk);
+            if (parser == null) {
+                throw new IOException(
+                        String.format("Failed to parse apk file %s", apk.getCanonicalPath()));
+            }
+            ApkInfo apkInfo =
+                    new ApkInfo(
+                            -1,
+                            parser.getPackageName(),
+                            parser.getVersionName(),
+                            parser.getVersionCode(),
+                            apk.getName());
+            apkList.add(apkInfo);
+        }
+        return apkList;
+    }
+
+    /**
+     * Parses ranking information csv file into the data structure representing a list of apks with
+     * ranking and package information
+     *
+     * @param rankingInfo the path to ranking csv file
+     * @return
+     * @throws IOException
+     */
+    private static List<ApkInfo> getApkListFromRankingInfo(File rankingInfo) throws IOException {
+        List<ApkInfo> apkList = new ArrayList<>();
+        File copiedFile = null;
+        BufferedReader br = null;
+        try {
+            copiedFile = downloadFile(rankingInfo, DOWNLOAD_TIMEOUT_MS, DOWNLOAD_RETRIES);
+            br = new BufferedReader(new FileReader(copiedFile));
+            String line;
+            boolean firstLine = true;
+            while ((line = br.readLine()) != null) {
+                if (firstLine) {
+                    firstLine = false;
+                } else {
+                    try {
+                        apkList.add(ApkInfo.fromCsvLine(line));
+                    } catch (IllegalArgumentException e) {
+                        CLog.e("Ranking file not formatted properly, skipping.");
+                        CLog.e(e);
+                    }
+                }
+            }
+        } finally {
+            StreamUtil.close(br);
+            FileUtil.deleteFile(copiedFile);
+        }
+        return apkList;
+    }
+
+    /**
+     * Helper class which holds information about the ranking list such as rank, package name, etc.
+     */
+    public static class ApkInfo {
+        public final int rank;
+        public final String packageName;
+        public final String versionString;
+        public final String versionCode;
+        public final String fileName;
+
+        public ApkInfo(
+                int rank,
+                String packageName,
+                String versionString,
+                String versionCode,
+                String fileName) {
+            this.rank = rank;
+            this.packageName = packageName;
+            this.versionString = versionString;
+            this.versionCode = versionCode;
+            this.fileName = fileName;
+        }
+
+        public static ApkInfo fromCsvLine(String line) {
+            String[] cols = QuotationAwareTokenizer.tokenizeLine(line, ",");
+            int rank = -1;
+            try {
+                rank = Integer.parseInt(cols[0]);
+            } catch (NumberFormatException e) {
+                // rethrow as IAE with content of problematic line
+                throw new IllegalArgumentException(
+                        String.format("Invalid line (rank field not a number): %s", line), e);
+            }
+            if (cols.length != 5) {
+                throw new IllegalArgumentException(
+                        String.format("Invalid line (expected 5 data columns): %s", line));
+            }
+            return new ApkInfo(rank, cols[1], cols[2], cols[3], cols[4]);
+        }
+
+        @Override
+        public String toString() {
+            return String.format(
+                    "Package: %s v%s (%s), rank: %d, file: %s",
+                    packageName, versionCode, versionString, rank, fileName);
+        }
+    }
+}
diff --git a/harness/src/main/resources/config/csuite-base.xml b/harness/src/main/resources/config/csuite-base.xml
new file mode 100644
index 0000000..6431154
--- /dev/null
+++ b/harness/src/main/resources/config/csuite-base.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2019 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.
+-->
+<configuration description="CSuite Main Test Plan">
+  <include name="everything" />
+  <device_recovery class="com.android.tradefed.device.WaitDeviceRecovery" />
+  <build_provider class="com.android.compatibility.common.tradefed.build.CompatibilityBuildProvider" />
+
+  <option name="enable-root" value="true" />
+  <option name="max-log-size" value="200" />
+
+  <option name="compatibility:primary-abi-only" value="true" />
+
+  <template-include name="reporters" default="basic-reporters" />
+  <result_reporter class="com.android.compatibility.common.tradefed.result.ConsoleReporter" />
+  <result_reporter class="com.android.compatibility.common.tradefed.result.suite.CompatibilityProtoResultReporter" />
+  <result_reporter class="com.android.tradefed.result.suite.SuiteResultReporter" />
+
+  <target_preparer class="com.android.compatibility.targetprep.AppSetupPreparerConfigurationReceiver" />
+</configuration>
diff --git a/harness/src/main/resources/config/launch.xml b/harness/src/main/resources/config/launch.xml
new file mode 100644
index 0000000..a1a158d
--- /dev/null
+++ b/harness/src/main/resources/config/launch.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2019 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.
+-->
+<configuration description="C-Suite Compatibility Launch Test Plan">
+  <include name="csuite-base" />
+
+  <option name="plan" value="launch" />
+
+  <option name="compatibility:module-metadata-include-filter" key="plan" value="app-launch" />
+</configuration>
diff --git a/harness/src/test/java/com/android/compatibility/AppCompatibilityTestTest.java b/harness/src/test/java/com/android/compatibility/AppCompatibilityTestTest.java
new file mode 100644
index 0000000..18f3368
--- /dev/null
+++ b/harness/src/test/java/com/android/compatibility/AppCompatibilityTestTest.java
@@ -0,0 +1,334 @@
+/*
+ * Copyright (C) 2019 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.compatibility;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+import com.android.tradefed.testtype.InstrumentationTest;
+import com.android.tradefed.util.PublicApkUtil.ApkInfo;
+
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import java.util.TreeSet;
+
+@RunWith(JUnit4.class)
+public final class AppCompatibilityTestTest {
+
+    private ConcreteAppCompatibilityTest mSut;
+
+    private class ConcreteAppCompatibilityTest extends AppCompatibilityTest {
+
+        ConcreteAppCompatibilityTest() {
+            super(null, null, null);
+        }
+
+        @Override
+        protected InstrumentationTest createInstrumentationTest(String packageBeingTested) {
+            return null;
+        }
+    }
+
+    @Before
+    public void setUp() {
+        mSut = new ConcreteAppCompatibilityTest();
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void addIncludeFilter_nullIncludeFilter_throwsException() {
+        mSut.addIncludeFilter(null);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void addIncludeFilter_emptyIncludeFilter_throwsException() {
+        mSut.addIncludeFilter("");
+    }
+
+    @Test
+    public void addIncludeFilter_validIncludeFilter() {
+        mSut.addIncludeFilter("test_filter");
+
+        assertTrue(mSut.mIncludeFilters.contains("test_filter"));
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void addAllIncludeFilters_nullIncludeFilter_throwsException() {
+        mSut.addAllIncludeFilters(null);
+    }
+
+    @Test
+    public void addAllIncludeFilters_validIncludeFilters() {
+        Set<String> test_filters = new TreeSet<>();
+        test_filters.add("filter_one");
+        test_filters.add("filter_two");
+
+        mSut.addAllIncludeFilters(test_filters);
+
+        assertTrue(mSut.mIncludeFilters.contains("filter_one"));
+        assertTrue(mSut.mIncludeFilters.contains("filter_two"));
+    }
+
+    @Test
+    public void clearIncludeFilters() {
+        mSut.addIncludeFilter("filter_test");
+
+        mSut.clearIncludeFilters();
+
+        assertTrue(mSut.mIncludeFilters.isEmpty());
+    }
+
+    @Test
+    public void getIncludeFilters() {
+        mSut.addIncludeFilter("filter_test");
+
+        assertEquals(mSut.mIncludeFilters, mSut.getIncludeFilters());
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void addExcludeFilter_nullExcludeFilter_throwsException() {
+        mSut.addExcludeFilter(null);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void addExcludeFilter_emptyExcludeFilter_throwsException() {
+        mSut.addExcludeFilter("");
+    }
+
+    @Test
+    public void addExcludeFilter_validExcludeFilter() {
+        mSut.addExcludeFilter("test_filter");
+
+        assertTrue(mSut.mExcludeFilters.contains("test_filter"));
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void addAllExcludeFilters_nullExcludeFilters_throwsException() {
+        mSut.addAllExcludeFilters(null);
+    }
+
+    @Test
+    public void addAllExcludeFilters_validExcludeFilters() {
+        Set<String> test_filters = new TreeSet<>();
+        test_filters.add("filter_one");
+        test_filters.add("filter_two");
+
+        mSut.addAllExcludeFilters(test_filters);
+
+        assertTrue(mSut.mExcludeFilters.contains("filter_one"));
+        assertTrue(mSut.mExcludeFilters.contains("filter_two"));
+    }
+
+    @Test
+    public void clearExcludeFilters() {
+        mSut.addExcludeFilter("filter_test");
+
+        mSut.clearExcludeFilters();
+
+        assertTrue(mSut.mExcludeFilters.isEmpty());
+    }
+
+    @Test
+    public void getExcludeFilters() {
+        mSut.addExcludeFilter("filter_test");
+
+        assertEquals(mSut.mExcludeFilters, mSut.getExcludeFilters());
+    }
+
+    @Test
+    public void filterApk_withNoFilter() {
+        List<ApkInfo> testList = createApkList();
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertEquals(filteredList, testList);
+    }
+
+    @Test
+    public void filterApk_withRelatedIncludeFilters() {
+        List<ApkInfo> testList = createApkList();
+        mSut.addIncludeFilter("filter_one");
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertEquals(convertList(filteredList), Arrays.asList("filter_one"));
+    }
+
+    @Test
+    public void filterApk_withUnrelatedIncludeFilters() {
+        List<ApkInfo> testList = createApkList();
+        mSut.addIncludeFilter("filter_three");
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertTrue(filteredList.isEmpty());
+    }
+
+    @Test
+    public void filterApk_withRelatedExcludeFilters() {
+        List<ApkInfo> testList = createApkList();
+        mSut.addExcludeFilter("filter_one");
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertEquals(convertList(filteredList), Arrays.asList("filter_two"));
+    }
+
+    @Test
+    public void filterApk_withUnrelatedExcludeFilters() {
+        List<ApkInfo> testList = createApkList();
+        mSut.addExcludeFilter("filter_three");
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertEquals(filteredList, testList);
+    }
+
+    @Test
+    public void filterApk_withSameIncludeAndExcludeFilters() {
+        List<ApkInfo> testList = createApkList();
+        mSut.addIncludeFilter("filter_one");
+        mSut.addExcludeFilter("filter_one");
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertTrue(filteredList.isEmpty());
+    }
+
+    @Test
+    public void filterApk_withDifferentIncludeAndExcludeFilter() {
+        List<ApkInfo> testList = createApkList();
+        mSut.addIncludeFilter("filter_one");
+        mSut.addIncludeFilter("filter_two");
+        mSut.addExcludeFilter("filter_two");
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertEquals(convertList(filteredList), Arrays.asList("filter_one"));
+    }
+
+    @Test
+    public void filterApk_withUnrelatedIncludeFilterAndRelatedExcludeFilter() {
+        List<ApkInfo> testList = createApkList();
+        mSut.addIncludeFilter("filter_three");
+        mSut.addExcludeFilter("filter_two");
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertTrue(filteredList.isEmpty());
+    }
+
+    @Test
+    public void filterApk_withRelatedIncludeFilterAndUnrelatedExcludeFilter() {
+        List<ApkInfo> testList = createApkList();
+        mSut.addIncludeFilter("filter_one");
+        mSut.addExcludeFilter("filter_three");
+
+        List<ApkInfo> filteredList = mSut.filterApk(testList);
+
+        assertEquals(convertList(filteredList), Arrays.asList("filter_one"));
+    }
+
+    private List<ApkInfo> createApkList() {
+        List<ApkInfo> testList = new ArrayList<>();
+        ApkInfo apk_info_one = new ApkInfo(0, "filter_one", "", "", "");
+        ApkInfo apk_info_two = new ApkInfo(0, "filter_two", "", "", "");
+        testList.add(apk_info_one);
+        testList.add(apk_info_two);
+        return testList;
+    }
+
+    private List<String> convertList(List<ApkInfo> apkList) {
+        List<String> convertedList = new ArrayList<>();
+        for (ApkInfo apkInfo : apkList) {
+            convertedList.add(apkInfo.packageName);
+        }
+        return convertedList;
+    }
+
+    @Test
+    public void filterTest_withEmptyFilter() {
+        assertTrue(mSut.filterTest("filter_one"));
+    }
+
+    @Test
+    public void filterTest_withRelatedIncludeFilter() {
+        mSut.addIncludeFilter("filter_one");
+
+        assertTrue(mSut.filterTest("filter_one"));
+    }
+
+    @Test
+    public void filterTest_withUnrelatedIncludeFilter() {
+        mSut.addIncludeFilter("filter_two");
+
+        assertFalse(mSut.filterTest("filter_one"));
+    }
+
+    @Test
+    public void filterTest_withRelatedExcludeFilter() {
+        mSut.addExcludeFilter("filter_one");
+
+        assertFalse(mSut.filterTest("filter_one"));
+    }
+
+    @Test
+    public void filterTest_withUnrelatedExcludeFilter() {
+        mSut.addExcludeFilter("filter_two");
+
+        assertTrue(mSut.filterTest("filter_one"));
+    }
+
+    @Test
+    public void filterTest_withSameIncludeAndExcludeFilters() {
+        mSut.addIncludeFilter("filter_one");
+        mSut.addExcludeFilter("filter_one");
+
+        assertFalse(mSut.filterTest("filter_one"));
+    }
+
+    @Test
+    public void filterTest_withUnrelatedIncludeFilterAndRelatedExcludeFilter() {
+        mSut.addIncludeFilter("filter_one");
+        mSut.addExcludeFilter("filter_two");
+
+        assertFalse(mSut.filterTest("filter_two"));
+    }
+
+    @Test
+    public void filterTest_withRelatedIncludeFilterAndUnrelatedExcludeFilter() {
+        mSut.addIncludeFilter("filter_one");
+        mSut.addExcludeFilter("filter_two");
+
+        assertTrue(mSut.filterTest("filter_one"));
+    }
+
+    @Test
+    public void filterTest_withUnRelatedIncludeFilterAndUnrelatedExcludeFilter() {
+        mSut.addIncludeFilter("filter_one");
+        mSut.addExcludeFilter("filter_two");
+
+        assertFalse(mSut.filterTest("filter_three"));
+    }
+}
diff --git a/harness/src/test/java/com/android/compatibility/CSuiteUnitTests.java b/harness/src/test/java/com/android/compatibility/CSuiteUnitTests.java
new file mode 100644
index 0000000..b87402e
--- /dev/null
+++ b/harness/src/test/java/com/android/compatibility/CSuiteUnitTests.java
@@ -0,0 +1,35 @@
+/*
+ * Copyright (C) 2019 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.compatibility;
+
+import com.android.compatibility.targetprep.AppSetupPreparerConfigurationReceiverTest;
+import com.android.compatibility.targetprep.AppSetupPreparerTest;
+import com.android.compatibility.testtype.AppLaunchTestTest;
+
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+import org.junit.runners.Suite.SuiteClasses;
+
+@RunWith(Suite.class)
+@SuiteClasses({
+    AppCompatibilityTestTest.class,
+    AppLaunchTestTest.class,
+    AppSetupPreparerTest.class,
+    AppSetupPreparerConfigurationReceiverTest.class,
+})
+public final class CSuiteUnitTests {
+    // Intentionally empty.
+}
diff --git a/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiverTest.java b/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiverTest.java
new file mode 100644
index 0000000..e708a67
--- /dev/null
+++ b/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiverTest.java
@@ -0,0 +1,57 @@
+/*
+ * Copyright (C) 2020 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.compatibility.targetprep;
+
+import com.android.tradefed.build.BuildInfo;
+import com.android.tradefed.build.IBuildInfo;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+
+@RunWith(JUnit4.class)
+public final class AppSetupPreparerConfigurationReceiverTest {
+
+    @Test
+    public void setUp_noneNullGcsApkDirOption_putsInBuildInfo() {
+        File optionGcsApkDir = new File("dir");
+        AppSetupPreparerConfigurationReceiver preparer =
+                new AppSetupPreparerConfigurationReceiver(optionGcsApkDir);
+        IBuildInfo buildInfo = new BuildInfo();
+
+        preparer.setUp(null, buildInfo);
+
+        assertThat(buildInfo.getBuildAttributes())
+                .containsEntry(AppSetupPreparer.OPTION_GCS_APK_DIR, optionGcsApkDir.getPath());
+    }
+
+    @Test
+    public void setUp_nullGcsApkDirOption_doesNotPutInBuildInfo() {
+        File optionGcsApkDir = null;
+        AppSetupPreparerConfigurationReceiver preparer =
+                new AppSetupPreparerConfigurationReceiver(optionGcsApkDir);
+        IBuildInfo buildInfo = new BuildInfo();
+
+        preparer.setUp(null, buildInfo);
+
+        assertThat(buildInfo.getBuildAttributes())
+                .doesNotContainKey(AppSetupPreparer.OPTION_GCS_APK_DIR);
+    }
+}
diff --git a/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerTest.java b/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerTest.java
new file mode 100644
index 0000000..7614f05
--- /dev/null
+++ b/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerTest.java
@@ -0,0 +1,139 @@
+/*
+ * Copyright (C) 2020 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.compatibility.targetprep;
+
+import com.android.tradefed.build.BuildInfo;
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.targetprep.TargetSetupError;
+import com.android.tradefed.targetprep.TestAppInstallSetup;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.testng.Assert.assertThrows;
+
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+
+@RunWith(JUnit4.class)
+public class AppSetupPreparerTest {
+
+    private static final String OPTION_GCS_APK_DIR = "gcs-apk-dir";
+    public static final ITestDevice NULL_DEVICE = null;
+
+    @Rule public final TemporaryFolder tempFolder = new TemporaryFolder();
+
+    private final IBuildInfo mBuildInfo = new BuildInfo();
+    private final TestAppInstallSetup mMockAppInstallSetup = mock(TestAppInstallSetup.class);
+    private final AppSetupPreparer mPreparer =
+            new AppSetupPreparer("package_name", mMockAppInstallSetup);
+
+    @Test
+    public void setUp_gcsApkDirIsNull_throwsException()
+            throws DeviceNotAvailableException, TargetSetupError {
+        mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, null);
+
+        assertThrows(NullPointerException.class, () -> mPreparer.setUp(NULL_DEVICE, mBuildInfo));
+    }
+
+    @Test
+    public void setUp_gcsApkDirIsNotDir_throwsException()
+            throws IOException, DeviceNotAvailableException, TargetSetupError {
+        File tempFile = tempFolder.newFile("temp_file_name");
+        mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, tempFile.getPath());
+
+        assertThrows(
+                IllegalArgumentException.class, () -> mPreparer.setUp(NULL_DEVICE, mBuildInfo));
+    }
+
+    @Test
+    public void setUp_packageDirDoesNotExist_throwsError()
+            throws IOException, DeviceNotAvailableException, TargetSetupError {
+        File gcsApkDir = tempFolder.newFolder("gcs_apk_dir");
+        mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, gcsApkDir.getPath());
+
+        assertThrows(
+                IllegalArgumentException.class, () -> mPreparer.setUp(NULL_DEVICE, mBuildInfo));
+    }
+
+    @Test
+    public void setUp_apkDoesNotExist() throws Exception {
+        File gcsApkDir = tempFolder.newFolder("gcs_apk_dir");
+        createPackageFile(gcsApkDir, "package_name", "non_apk_file");
+        mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, gcsApkDir.getPath());
+
+        assertThrows(TargetSetupError.class, () -> mPreparer.setUp(NULL_DEVICE, mBuildInfo));
+    }
+
+    @Test
+    public void setUp_installSplitApk() throws Exception {
+        File gcsApkDir = tempFolder.newFolder("gcs_apk_dir");
+        File packageDir = new File(gcsApkDir.getPath(), "package_name");
+        createPackageFile(gcsApkDir, "package_name", "apk_name_1.apk");
+        createPackageFile(gcsApkDir, "package_name", "apk_name_2.apk");
+        mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, gcsApkDir.getPath());
+
+        mPreparer.setUp(NULL_DEVICE, mBuildInfo);
+
+        verify(mMockAppInstallSetup).setAltDir(packageDir);
+        verify(mMockAppInstallSetup).addSplitApkFileNames("apk_name_2.apk,apk_name_1.apk");
+        verify(mMockAppInstallSetup).setUp(any(), any());
+    }
+
+    @Test
+    public void setUp_installNonSplitApk() throws Exception {
+        File gcsApkDir = tempFolder.newFolder("gcs_apk_dir");
+        File packageDir = new File(gcsApkDir.getPath(), "package_name");
+        createPackageFile(gcsApkDir, "package_name", "apk_name_1.apk");
+        mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, gcsApkDir.getPath());
+
+        mPreparer.setUp(NULL_DEVICE, mBuildInfo);
+
+        verify(mMockAppInstallSetup).setAltDir(packageDir);
+        verify(mMockAppInstallSetup).addTestFileName("apk_name_1.apk");
+        verify(mMockAppInstallSetup).setUp(any(), any());
+    }
+
+    @Test
+    public void tearDown() throws Exception {
+        TestInformation testInfo = TestInformation.newBuilder().build();
+
+        mPreparer.tearDown(testInfo, null);
+
+        verify(mMockAppInstallSetup, times(1)).tearDown(testInfo, null);
+    }
+
+    private File createPackageFile(File parentDir, String packageName, String apkName)
+            throws IOException {
+        File packageDir =
+                Files.createDirectories(Paths.get(parentDir.getAbsolutePath(), packageName))
+                        .toFile();
+
+        return Files.createFile(Paths.get(packageDir.getAbsolutePath(), apkName)).toFile();
+    }
+}
diff --git a/harness/src/test/java/com/android/compatibility/testtype/AppLaunchTestTest.java b/harness/src/test/java/com/android/compatibility/testtype/AppLaunchTestTest.java
new file mode 100644
index 0000000..4f9aa35
--- /dev/null
+++ b/harness/src/test/java/com/android/compatibility/testtype/AppLaunchTestTest.java
@@ -0,0 +1,433 @@
+/*
+ * Copyright (C) 2019 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.compatibility.testtype;
+
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.TestDescription;
+import com.android.tradefed.testtype.InstrumentationTest;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.anyObject;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.inOrder;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.when;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.InOrder;
+
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Set;
+
+@RunWith(JUnit4.class)
+public final class AppLaunchTestTest {
+
+    private final ITestInvocationListener mMockListener = mock(ITestInvocationListener.class);
+    private static final String TEST_PACKAGE_NAME = "package_name";
+    private static final TestInformation NULL_TEST_INFORMATION = null;
+
+    @Test
+    public void run_testFailed() throws DeviceNotAvailableException {
+        InstrumentationTest instrumentationTest = createFailingInstrumentationTest();
+        AppLaunchTest appLaunchTest = createLaunchTestWithInstrumentation(instrumentationTest);
+
+        appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener);
+
+        verifyFailedAndEndedCall(mMockListener);
+    }
+
+    @Test
+    public void run_testPassed() throws DeviceNotAvailableException {
+        InstrumentationTest instrumentationTest = createPassingInstrumentationTest();
+        AppLaunchTest appLaunchTest = createLaunchTestWithInstrumentation(instrumentationTest);
+
+        appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener);
+
+        verifyPassedAndEndedCall(mMockListener);
+    }
+
+    @Test
+    public void run_packageResetSuccess() throws DeviceNotAvailableException {
+        ITestDevice mMockDevice = mock(ITestDevice.class);
+        when(mMockDevice.executeShellV2Command(String.format("pm clear %s", TEST_PACKAGE_NAME)))
+                .thenReturn(new CommandResult(CommandStatus.SUCCESS));
+        AppLaunchTest appLaunchTest = createLaunchTestWithMockDevice(mMockDevice);
+
+        appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener);
+
+        verifyPassedAndEndedCall(mMockListener);
+    }
+
+    @Test
+    public void run_packageResetError() throws DeviceNotAvailableException {
+        ITestDevice mMockDevice = mock(ITestDevice.class);
+        when(mMockDevice.executeShellV2Command(String.format("pm clear %s", TEST_PACKAGE_NAME)))
+                .thenReturn(new CommandResult(CommandStatus.FAILED));
+        AppLaunchTest appLaunchTest = createLaunchTestWithMockDevice(mMockDevice);
+
+        appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener);
+
+        verifyFailedAndEndedCall(mMockListener);
+    }
+
+    @Test
+    public void run_testRetry_passedAfterTwoFailings() throws Exception {
+        InstrumentationTest instrumentationTest = createPassingInstrumentationTestAfterFailing(2);
+        AppLaunchTest appLaunchTest = createLaunchTestWithRetry(instrumentationTest, 2);
+
+        appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener);
+
+        verifyPassedAndEndedCall(mMockListener);
+    }
+
+    @Test
+    public void run_testRetry_failedAfterThreeFailings() throws Exception {
+        InstrumentationTest instrumentationTest = createPassingInstrumentationTestAfterFailing(3);
+        AppLaunchTest appLaunchTest = createLaunchTestWithRetry(instrumentationTest, 2);
+
+        appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener);
+
+        verifyFailedAndEndedCall(mMockListener);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void addIncludeFilter_nullIncludeFilter_throwsException() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter(null);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void addIncludeFilter_emptyIncludeFilter_throwsException() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter("");
+    }
+
+    @Test
+    public void addIncludeFilter_validIncludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter("test_filter");
+
+        assertTrue(sut.mIncludeFilters.contains("test_filter"));
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void addAllIncludeFilters_nullIncludeFilter_throwsException() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addAllIncludeFilters(null);
+    }
+
+    @Test
+    public void addAllIncludeFilters_validIncludeFilters() {
+        AppLaunchTest sut = new AppLaunchTest();
+        Set<String> test_filters = new HashSet<>();
+        test_filters.add("filter_one");
+        test_filters.add("filter_two");
+
+        sut.addAllIncludeFilters(test_filters);
+
+        assertTrue(sut.mIncludeFilters.contains("filter_one"));
+        assertTrue(sut.mIncludeFilters.contains("filter_two"));
+    }
+
+    @Test
+    public void clearIncludeFilters() {
+        AppLaunchTest sut = new AppLaunchTest();
+        sut.addIncludeFilter("filter_test");
+
+        sut.clearIncludeFilters();
+
+        assertTrue(sut.mIncludeFilters.isEmpty());
+    }
+
+    @Test
+    public void getIncludeFilters() {
+        AppLaunchTest sut = new AppLaunchTest();
+        sut.addIncludeFilter("filter_test");
+
+        assertEquals(sut.mIncludeFilters, sut.getIncludeFilters());
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void addExcludeFilter_nullExcludeFilter_throwsException() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addExcludeFilter(null);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void addExcludeFilter_emptyExcludeFilter_throwsException() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addExcludeFilter("");
+    }
+
+    @Test
+    public void addExcludeFilter_validExcludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addExcludeFilter("test_filter");
+
+        assertTrue(sut.mExcludeFilters.contains("test_filter"));
+    }
+
+    @Test(expected = NullPointerException.class)
+    public void addAllExcludeFilters_nullExcludeFilters_throwsException() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addAllExcludeFilters(null);
+    }
+
+    @Test
+    public void addAllExcludeFilters_validExcludeFilters() {
+        AppLaunchTest sut = new AppLaunchTest();
+        Set<String> test_filters = new HashSet<>();
+        test_filters.add("filter_one");
+        test_filters.add("filter_two");
+
+        sut.addAllExcludeFilters(test_filters);
+
+        assertTrue(sut.mExcludeFilters.contains("filter_one"));
+        assertTrue(sut.mExcludeFilters.contains("filter_two"));
+    }
+
+    @Test
+    public void clearExcludeFilters() {
+        AppLaunchTest sut = new AppLaunchTest();
+        sut.addExcludeFilter("filter_test");
+
+        sut.clearExcludeFilters();
+
+        assertTrue(sut.mExcludeFilters.isEmpty());
+    }
+
+    @Test
+    public void getExcludeFilters() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addExcludeFilter("filter_test");
+
+        assertEquals(sut.mExcludeFilters, sut.getExcludeFilters());
+    }
+
+    @Test
+    public void inFilter_withEmptyFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        assertTrue(sut.inFilter("filter_one"));
+    }
+
+    @Test
+    public void inFilter_withRelatedIncludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter("filter_one");
+
+        assertTrue(sut.inFilter("filter_one"));
+    }
+
+    @Test
+    public void inFilter_withUnrelatedIncludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter("filter_two");
+
+        assertFalse(sut.inFilter("filter_one"));
+    }
+
+    @Test
+    public void inFilter_withRelatedExcludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addExcludeFilter("filter_one");
+
+        assertFalse(sut.inFilter("filter_one"));
+    }
+
+    @Test
+    public void inFilter_withUnrelatedExcludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addExcludeFilter("filter_two");
+
+        assertTrue(sut.inFilter("filter_one"));
+    }
+
+    @Test
+    public void inFilter_withSameIncludeAndExcludeFilters() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter("filter_one");
+        sut.addExcludeFilter("filter_one");
+
+        assertFalse(sut.inFilter("filter_one"));
+    }
+
+    @Test
+    public void inFilter_withUnrelatedIncludeFilterAndRelatedExcludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter("filter_one");
+        sut.addExcludeFilter("filter_two");
+
+        assertFalse(sut.inFilter("filter_two"));
+    }
+
+    @Test
+    public void inFilter_withRelatedIncludeFilterAndUnrelatedExcludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter("filter_one");
+        sut.addExcludeFilter("filter_two");
+
+        assertTrue(sut.inFilter("filter_one"));
+    }
+
+    @Test
+    public void inFilter_withUnrelatedIncludeFilterAndUnrelatedExcludeFilter() {
+        AppLaunchTest sut = new AppLaunchTest();
+
+        sut.addIncludeFilter("filter_one");
+        sut.addExcludeFilter("filter_two");
+
+        assertFalse(sut.inFilter("filter_three"));
+    }
+
+    private InstrumentationTest createFailingInstrumentationTest() {
+        InstrumentationTest instrumentation =
+                new InstrumentationTest() {
+                    @Override
+                    public void run(
+                            final TestInformation testInfo, final ITestInvocationListener listener)
+                            throws DeviceNotAvailableException {
+                        listener.testFailed(new TestDescription("", ""), "test failed");
+                    }
+                };
+        return instrumentation;
+    }
+
+    private InstrumentationTest createPassingInstrumentationTest() {
+        InstrumentationTest instrumentation =
+                new InstrumentationTest() {
+                    @Override
+                    public void run(
+                            final TestInformation testInfo, final ITestInvocationListener listener)
+                            throws DeviceNotAvailableException {}
+                };
+        return instrumentation;
+    }
+
+    private InstrumentationTest createPassingInstrumentationTestAfterFailing(int failedCount) {
+        InstrumentationTest instrumentation =
+                new InstrumentationTest() {
+                    private int mRetryCount = 0;
+
+                    @Override
+                    public void run(
+                            final TestInformation testInfo, final ITestInvocationListener listener)
+                            throws DeviceNotAvailableException {
+                        if (mRetryCount < failedCount) {
+                            listener.testFailed(new TestDescription("", ""), "test failed");
+                        }
+                        mRetryCount++;
+                    }
+                };
+        return instrumentation;
+    }
+
+    private AppLaunchTest createLaunchTestWithInstrumentation(InstrumentationTest instrumentation) {
+        AppLaunchTest appLaunchTest =
+                new AppLaunchTest(TEST_PACKAGE_NAME) {
+                    @Override
+                    protected InstrumentationTest createInstrumentationTest(
+                            String packageBeingTested) {
+                        return instrumentation;
+                    }
+
+                    @Override
+                    protected CommandResult resetPackage() throws DeviceNotAvailableException {
+                        return new CommandResult(CommandStatus.SUCCESS);
+                    }
+                };
+        appLaunchTest.setDevice(mock(ITestDevice.class));
+        return appLaunchTest;
+    }
+
+    private AppLaunchTest createLaunchTestWithRetry(
+            InstrumentationTest instrumentation, int retryCount) {
+        AppLaunchTest appLaunchTest =
+                new AppLaunchTest(TEST_PACKAGE_NAME, retryCount) {
+                    @Override
+                    protected InstrumentationTest createInstrumentationTest(
+                            String packageBeingTested) {
+                        return instrumentation;
+                    }
+
+                    @Override
+                    protected CommandResult resetPackage() throws DeviceNotAvailableException {
+                        return new CommandResult(CommandStatus.SUCCESS);
+                    }
+                };
+        appLaunchTest.setDevice(mock(ITestDevice.class));
+        return appLaunchTest;
+    }
+
+    private AppLaunchTest createLaunchTestWithMockDevice(ITestDevice device) {
+        AppLaunchTest appLaunchTest = new AppLaunchTest(TEST_PACKAGE_NAME);
+        appLaunchTest.setDevice(device);
+        return appLaunchTest;
+    }
+
+    private static void verifyFailedAndEndedCall(ITestInvocationListener listener) {
+        InOrder inOrder = inOrder(listener);
+        inOrder.verify(listener, times(1)).testRunStarted(anyString(), anyInt());
+        inOrder.verify(listener, times(1)).testStarted(anyObject(), anyLong());
+        inOrder.verify(listener, times(1)).testFailed(any(), anyString());
+        inOrder.verify(listener, times(1))
+                .testEnded(anyObject(), anyLong(), (Map<String, String>) any());
+        inOrder.verify(listener, times(1)).testRunEnded(anyLong(), (HashMap<String, Metric>) any());
+    }
+
+    private static void verifyPassedAndEndedCall(ITestInvocationListener listener) {
+        InOrder inOrder = inOrder(listener);
+        inOrder.verify(listener, times(1)).testRunStarted(anyString(), anyInt());
+        inOrder.verify(listener, times(1)).testStarted(anyObject(), anyLong());
+        inOrder.verify(listener, never()).testFailed(any(), anyString());
+        inOrder.verify(listener, times(1))
+                .testEnded(anyObject(), anyLong(), (Map<String, String>) any());
+        inOrder.verify(listener, times(1)).testRunEnded(anyLong(), (HashMap<String, Metric>) any());
+    }
+}
diff --git a/instrumentation/launch/Android.bp b/instrumentation/launch/Android.bp
new file mode 100644
index 0000000..22255f6
--- /dev/null
+++ b/instrumentation/launch/Android.bp
@@ -0,0 +1,25 @@
+// Copyright (C) 2012 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.
+
+android_test {
+    name: "csuite-launch-instrumentation",
+    static_libs: ["androidx.test.rules"],
+    // Include all test java files.
+    srcs: ["src/**/*.java"],
+    platform_apis: true,
+    manifest: "src/main/AndroidManifest.xml",
+    test_suites: [
+        "csuite",
+    ],
+}
diff --git a/instrumentation/launch/src/main/AndroidManifest.xml b/instrumentation/launch/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7d346b5
--- /dev/null
+++ b/instrumentation/launch/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2012 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.compatibilitytest">
+    <uses-sdk android:minSdkVersion="21"
+              android:targetSdkVersion="21" />
+    <application />
+    <uses-permission android:name="android.permission.READ_LOGS" />
+    <uses-permission android:name="android.permission.REAL_GET_TASKS" />
+    <instrumentation
+        android:name=".AppCompatibilityRunner"
+        android:targetPackage="com.android.compatibilitytest"
+        android:label="App Compatibility Test Runner" />
+</manifest>
diff --git a/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibility.java b/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibility.java
new file mode 100644
index 0000000..5918a62
--- /dev/null
+++ b/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibility.java
@@ -0,0 +1,380 @@
+/*
+ * Copyright (C) 2012 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.compatibilitytest;
+
+import android.app.ActivityManager;
+import android.app.ActivityManager.ProcessErrorStateInfo;
+import android.app.ActivityManager.RunningTaskInfo;
+import android.app.IActivityController;
+import android.app.IActivityManager;
+import android.app.Instrumentation;
+import android.app.UiAutomation;
+import android.app.UiModeManager;
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.content.res.Configuration;
+import android.os.Bundle;
+import android.os.DropBoxManager;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.util.Log;
+
+import androidx.test.InstrumentationRegistry;
+import androidx.test.runner.AndroidJUnit4;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/** Application Compatibility Test that launches an application and detects crashes. */
+@RunWith(AndroidJUnit4.class)
+public final class AppCompatibility {
+
+    private static final String TAG = AppCompatibility.class.getSimpleName();
+    private static final String PACKAGE_TO_LAUNCH = "package_to_launch";
+    private static final String APP_LAUNCH_TIMEOUT_MSECS = "app_launch_timeout_ms";
+    private static final String WORKSPACE_LAUNCH_TIMEOUT_MSECS = "workspace_launch_timeout_ms";
+    private static final Set<String> DROPBOX_TAGS = new HashSet<>();
+    private static final int MAX_CRASH_SNIPPET_LINES = 20;
+    private static final int MAX_NUM_CRASH_SNIPPET = 3;
+
+    // time waiting for app to launch
+    private int mAppLaunchTimeout = 7000;
+    // time waiting for launcher home screen to show up
+    private int mWorkspaceLaunchTimeout = 2000;
+
+    private Context mContext;
+    private ActivityManager mActivityManager;
+    private PackageManager mPackageManager;
+    private Bundle mArgs;
+    private Instrumentation mInstrumentation;
+    private String mLauncherPackageName;
+    private IActivityController mCrashSupressor = new CrashSuppressor();
+    private Map<String, List<String>> mAppErrors = new HashMap<>();
+
+    static {
+        DROPBOX_TAGS.add("SYSTEM_TOMBSTONE");
+        DROPBOX_TAGS.add("system_app_anr");
+        DROPBOX_TAGS.add("system_app_native_crash");
+        DROPBOX_TAGS.add("system_app_crash");
+        DROPBOX_TAGS.add("data_app_anr");
+        DROPBOX_TAGS.add("data_app_native_crash");
+        DROPBOX_TAGS.add("data_app_crash");
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        mInstrumentation = InstrumentationRegistry.getInstrumentation();
+
+        // Get permissions for privileged device operations.
+        mInstrumentation.getUiAutomation().adoptShellPermissionIdentity();
+
+        mContext = InstrumentationRegistry.getTargetContext();
+        mActivityManager = (ActivityManager) mContext.getSystemService(Context.ACTIVITY_SERVICE);
+        mPackageManager = mContext.getPackageManager();
+        mArgs = InstrumentationRegistry.getArguments();
+
+        // resolve launcher package name
+        Intent intent = new Intent(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_HOME);
+        ResolveInfo resolveInfo =
+                mPackageManager.resolveActivity(intent, PackageManager.MATCH_DEFAULT_ONLY);
+        mLauncherPackageName = resolveInfo.activityInfo.packageName;
+        Assert.assertNotNull("failed to resolve package name for launcher", mLauncherPackageName);
+        Log.v(TAG, "Using launcher package name: " + mLauncherPackageName);
+
+        // Parse optional inputs.
+        String appLaunchTimeoutMsecs = mArgs.getString(APP_LAUNCH_TIMEOUT_MSECS);
+        if (appLaunchTimeoutMsecs != null) {
+            mAppLaunchTimeout = Integer.parseInt(appLaunchTimeoutMsecs);
+        }
+        String workspaceLaunchTimeoutMsecs = mArgs.getString(WORKSPACE_LAUNCH_TIMEOUT_MSECS);
+        if (workspaceLaunchTimeoutMsecs != null) {
+            mWorkspaceLaunchTimeout = Integer.parseInt(workspaceLaunchTimeoutMsecs);
+        }
+        mInstrumentation.getUiAutomation().setRotation(UiAutomation.ROTATION_FREEZE_0);
+
+        // set activity controller to suppress crash dialogs and collects them by process name
+        mAppErrors.clear();
+        IActivityManager.Stub.asInterface(ServiceManager.checkService(Context.ACTIVITY_SERVICE))
+                .setActivityController(mCrashSupressor, false);
+    }
+
+    @After
+    public void tearDown() throws Exception {
+        // unset activity controller
+        IActivityManager.Stub.asInterface(ServiceManager.checkService(Context.ACTIVITY_SERVICE))
+                .setActivityController(null, false);
+        mInstrumentation.getUiAutomation().setRotation(UiAutomation.ROTATION_UNFREEZE);
+    }
+
+    /**
+     * Actual test case that launches the package and throws an exception on the first error.
+     *
+     * @throws Exception
+     */
+    @Test
+    public void testAppStability() throws Exception {
+        String packageName = mArgs.getString(PACKAGE_TO_LAUNCH);
+        if (packageName != null) {
+            Log.d(TAG, "Launching app " + packageName);
+            Intent intent = getLaunchIntentForPackage(packageName);
+            if (intent == null) {
+                Log.w(TAG, String.format("Skipping %s; no launch intent", packageName));
+                return;
+            }
+            long startTime = System.currentTimeMillis();
+            launchActivity(packageName, intent);
+            try {
+                checkDropbox(startTime, packageName);
+                if (mAppErrors.containsKey(packageName)) {
+                    StringBuilder message =
+                            new StringBuilder("Error(s) detected for package: ")
+                                    .append(packageName);
+                    List<String> errors = mAppErrors.get(packageName);
+                    for (int i = 0; i < MAX_NUM_CRASH_SNIPPET && i < errors.size(); i++) {
+                        String err = errors.get(i);
+                        message.append("\n\n");
+                        // limit the size of each crash snippet
+                        message.append(truncate(err, MAX_CRASH_SNIPPET_LINES));
+                    }
+                    if (errors.size() > MAX_NUM_CRASH_SNIPPET) {
+                        message.append(
+                                String.format(
+                                        "\n... %d more errors omitted ...",
+                                        errors.size() - MAX_NUM_CRASH_SNIPPET));
+                    }
+                    Assert.fail(message.toString());
+                }
+                // last check: see if app process is still running
+                Assert.assertTrue(
+                        "app package \""
+                                + packageName
+                                + "\" no longer found in running "
+                                + "tasks, but no explicit crashes were detected; check logcat for "
+                                + "details",
+                        processStillUp(packageName));
+            } finally {
+                returnHome();
+            }
+        } else {
+            Log.d(
+                    TAG,
+                    "Missing argument, use "
+                            + PACKAGE_TO_LAUNCH
+                            + " to specify the package to launch");
+        }
+    }
+
+    /**
+     * Truncate the text to at most the specified number of lines, and append a marker at the end
+     * when truncated
+     *
+     * @param text
+     * @param maxLines
+     * @return
+     */
+    private static String truncate(String text, int maxLines) {
+        String[] lines = text.split("\\r?\\n");
+        StringBuilder ret = new StringBuilder();
+        for (int i = 0; i < maxLines && i < lines.length; i++) {
+            ret.append(lines[i]);
+            ret.append('\n');
+        }
+        if (lines.length > maxLines) {
+            ret.append("... ");
+            ret.append(lines.length - maxLines);
+            ret.append(" more lines truncated ...\n");
+        }
+        return ret.toString();
+    }
+
+    /**
+     * Check dropbox for entries of interest regarding the specified process
+     *
+     * @param startTime if not 0, only check entries with timestamp later than the start time
+     * @param processName the process name to check for
+     */
+    private void checkDropbox(long startTime, String processName) {
+        DropBoxManager dropbox =
+                (DropBoxManager) mContext.getSystemService(Context.DROPBOX_SERVICE);
+        DropBoxManager.Entry entry = null;
+        while (null != (entry = dropbox.getNextEntry(null, startTime))) {
+            try {
+                // only check entries with tag that's of interest
+                String tag = entry.getTag();
+                if (DROPBOX_TAGS.contains(tag)) {
+                    String content = entry.getText(4096);
+                    if (content != null) {
+                        if (content.contains(processName)) {
+                            addProcessError(processName, "dropbox:" + tag, content);
+                        }
+                    }
+                }
+                startTime = entry.getTimeMillis();
+            } finally {
+                entry.close();
+            }
+        }
+    }
+
+    private void returnHome() {
+        Intent homeIntent = new Intent(Intent.ACTION_MAIN);
+        homeIntent.addCategory(Intent.CATEGORY_HOME);
+        homeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+        // Send the "home" intent and wait 2 seconds for us to get there
+        mContext.startActivity(homeIntent);
+        try {
+            Thread.sleep(mWorkspaceLaunchTimeout);
+        } catch (InterruptedException e) {
+            // ignore
+        }
+    }
+
+    private Intent getLaunchIntentForPackage(String packageName) {
+        UiModeManager umm = (UiModeManager) mContext.getSystemService(Context.UI_MODE_SERVICE);
+        boolean isLeanback = umm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION;
+        Intent intent = null;
+        if (isLeanback) {
+            intent = mPackageManager.getLeanbackLaunchIntentForPackage(packageName);
+        } else {
+            intent = mPackageManager.getLaunchIntentForPackage(packageName);
+        }
+        return intent;
+    }
+
+    /**
+     * Launches and activity and queries for errors.
+     *
+     * @param packageName {@link String} the package name of the application to launch.
+     * @return {@link Collection} of {@link ProcessErrorStateInfo} detected during the app launch.
+     */
+    private void launchActivity(String packageName, Intent intent) {
+        Log.d(
+                TAG,
+                String.format(
+                        "launching package \"%s\" with intent: %s",
+                        packageName, intent.toString()));
+
+        // Launch Activity
+        mContext.startActivity(intent);
+
+        try {
+            // artificial delay: in case app crashes after doing some work during launch
+            Thread.sleep(mAppLaunchTimeout);
+        } catch (InterruptedException e) {
+            // ignore
+        }
+    }
+
+    private void addProcessError(String processName, String errorType, String errorInfo) {
+        // parse out the package name if necessary, for apps with multiple processes
+        String pkgName = processName.split(":", 2)[0];
+        List<String> errors;
+        if (mAppErrors.containsKey(pkgName)) {
+            errors = mAppErrors.get(pkgName);
+        } else {
+            errors = new ArrayList<>();
+        }
+        errors.add(String.format("### Type: %s, Details:\n%s", errorType, errorInfo));
+        mAppErrors.put(pkgName, errors);
+    }
+
+    /**
+     * Determine if a given package is still running.
+     *
+     * @param packageName {@link String} package to look for
+     * @return True if package is running, false otherwise.
+     */
+    private boolean processStillUp(String packageName) {
+        @SuppressWarnings("deprecation")
+        List<RunningTaskInfo> infos = mActivityManager.getRunningTasks(100);
+        for (RunningTaskInfo info : infos) {
+            if (info.baseActivity.getPackageName().equals(packageName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * An {@link IActivityController} that instructs framework to kill processes hitting crashes
+     * directly without showing crash dialogs
+     */
+    private class CrashSuppressor extends IActivityController.Stub {
+
+        @Override
+        public boolean activityStarting(Intent intent, String pkg) throws RemoteException {
+            Log.d(TAG, "activity starting: " + intent.getComponent().toShortString());
+            return true;
+        }
+
+        @Override
+        public boolean activityResuming(String pkg) throws RemoteException {
+            Log.d(TAG, "activity resuming: " + pkg);
+            return true;
+        }
+
+        @Override
+        public boolean appCrashed(
+                String processName,
+                int pid,
+                String shortMsg,
+                String longMsg,
+                long timeMillis,
+                String stackTrace)
+                throws RemoteException {
+            Log.d(TAG, "app crash: " + processName);
+            addProcessError(processName, "crash", stackTrace);
+            // don't show dialog
+            return false;
+        }
+
+        @Override
+        public int appEarlyNotResponding(String processName, int pid, String annotation)
+                throws RemoteException {
+            // ignore
+            return 0;
+        }
+
+        @Override
+        public int appNotResponding(String processName, int pid, String processStats)
+                throws RemoteException {
+            Log.d(TAG, "app ANR: " + processName);
+            addProcessError(processName, "ANR", processStats);
+            // don't show dialog
+            return -1;
+        }
+
+        @Override
+        public int systemNotResponding(String msg) throws RemoteException {
+            // ignore
+            return -1;
+        }
+    }
+}
diff --git a/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibilityRunner.java b/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibilityRunner.java
new file mode 100644
index 0000000..943cea9
--- /dev/null
+++ b/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibilityRunner.java
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 2012 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.compatibilitytest;
+
+import androidx.test.runner.AndroidJUnitRunner;
+
+// empty subclass to maintain backwards compatibility on host-side harness
+public class AppCompatibilityRunner extends AndroidJUnitRunner {}
diff --git a/tools/build/config.mk b/tools/build/config.mk
new file mode 100644
index 0000000..f773d9b
--- /dev/null
+++ b/tools/build/config.mk
@@ -0,0 +1,16 @@
+#
+# Copyright (C) 2019 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.
+
+COMPATIBILITY_TESTCASES_OUT_csuite := $(HOST_OUT)/csuite/android-csuite/testcases
diff --git a/tools/csuite-tradefed/Android.bp b/tools/csuite-tradefed/Android.bp
new file mode 100644
index 0000000..82d959e
--- /dev/null
+++ b/tools/csuite-tradefed/Android.bp
@@ -0,0 +1,37 @@
+// Copyright (C) 2018 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.
+
+tradefed_binary_host {
+    name: "csuite-tradefed",
+    wrapper: "src/scripts/csuite-tradefed",
+    short_name: "CSUITE",
+    full_name: "App Compatibility Test Suite",
+    version: "1.0",
+    static_libs: [
+        "cts-tradefed-harness",
+        "csuite-harness",
+    ],
+}
+
+java_test_host {
+    name: "csuite-tradefed-tests",
+    srcs: [
+        "src/test/java/**/*.java",
+    ],
+    static_libs: [
+        "tradefed",
+        "csuite-tradefed",
+    ],
+    test_suites: ["general-tests"],
+}
diff --git a/tools/csuite-tradefed/AndroidTest.xml b/tools/csuite-tradefed/AndroidTest.xml
new file mode 100644
index 0000000..850750e
--- /dev/null
+++ b/tools/csuite-tradefed/AndroidTest.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2019 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.
+-->
+<configuration>
+  <test class="com.android.tradefed.testtype.HostTest" >
+    <option name="class" value="com.android.compatibility.tradefed.CSuiteTradefedTest" />
+  </test>
+</configuration>
diff --git a/tools/csuite-tradefed/README b/tools/csuite-tradefed/README
new file mode 100644
index 0000000..32cf936
--- /dev/null
+++ b/tools/csuite-tradefed/README
@@ -0,0 +1,45 @@
+C-Suite Trade Federation
+---------------------------
+
+App Compatibility Test Suite Trade Federation, csuite-tradefed for short, is built on
+top of the Android Trade Federation test harness to test app compatibility.
+
+Configuring csuite-tradefed
+---------------------------
+
+Ensure 'adb' is in your current PATH. adb can be found in the
+Android SDK available from http://developer.android.com
+
+Example:
+  PATH=$PATH:/home/myuser/android-sdk-linux_x86/platform-tools
+
+And ensure that the device is visible via 'adb devices'
+
+Using csuite-tradefed
+---------------------------
+
+To run a test plan on a single device:
+
+1. Make sure you have at least one device connected
+2. Launch the csuite-tradefed console by running the 'csuite-tradefed'. If you
+are working from the Android source tree and have run 'm csuite',
+the script can be found at
+  out/host/linux-x86/csuite/android-csuite/tools/csuite-tradefed
+
+C-Suite Tradefed Development
+---------------------------
+
+See http://source.android.com for instructions on obtaining the Android
+platform source code and setting up a build environment.
+
+The source for the tradefed framework can be found on the 'tradefed' branch.
+
+Perform these steps to build and run csuite from the development environment:
+
+cd <path to android source root>
+make csuite
+csuite-tradefed
+run <plan-name>
+
+More documentation and details on using and extending trade federation will
+be forthcoming in the near future.
diff --git a/tools/csuite-tradefed/TEST_MAPPING b/tools/csuite-tradefed/TEST_MAPPING
new file mode 100644
index 0000000..89c2072
--- /dev/null
+++ b/tools/csuite-tradefed/TEST_MAPPING
@@ -0,0 +1,8 @@
+{
+  "postsubmit": [
+    {
+      "name": "csuite-tradefed-tests",
+      "host": true
+    }
+  ]
+}
diff --git a/tools/csuite-tradefed/src/scripts/csuite-tradefed b/tools/csuite-tradefed/src/scripts/csuite-tradefed
new file mode 100644
index 0000000..4277884
--- /dev/null
+++ b/tools/csuite-tradefed/src/scripts/csuite-tradefed
@@ -0,0 +1,122 @@
+#!/bin/bash
+
+# Copyright (C) 2019 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.
+
+# The launcher script for running C-Suite.
+# This can be run from an Android build environment or a standalone C-Suite zip.
+
+checkFile() {
+    if [ ! -f "$1" ]; then
+        echo "Unable to locate $1"
+        exit
+    fi;
+}
+
+checkPath() {
+    if ! type -P $1 &> /dev/null; then
+        echo "Unable to find $1 in path."
+        exit
+    fi;
+}
+
+checkPath adb
+checkPath java
+
+# Check the Java version.
+JAVA_VERSION=$(java -version 2>&1 | head -n 1 | grep 'version [ "]\(1\.8\|9\|11\).*[ "]')
+if [ "${JAVA_VERSION}" == "" ]; then
+    echo "Wrong java version. 1.8, 9, or 11 is required."
+    exit
+fi
+
+# Check for the debug flag and set up remote debugging.
+if [ -n "${TF_DEBUG}" ]; then
+  if [ -z "${TF_DEBUG_PORT}" ]; then
+    TF_DEBUG_PORT=10088
+  fi
+  RDBG_FLAG=-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=${TF_DEBUG_PORT}
+fi
+
+# Get OS.
+HOST=`uname`
+if [ "$HOST" == "Linux" ]; then
+    OS="linux-x86"
+elif [ "$HOST" == "Darwin" ]; then
+    OS="darwin-x86"
+else
+    echo "Unrecognized OS"
+    exit
+fi
+
+# Check if in Android build environment.
+if [ ! -z "${ANDROID_BUILD_TOP}" ]; then
+    if [ ! -z "${ANDROID_HOST_OUT}" ]; then
+      CSUITE_ROOT=${ANDROID_HOST_OUT}/csuite
+    else
+      CSUITE_ROOT=${ANDROID_BUILD_TOP}/${OUT_DIR:-out}/host/${OS}/csuite
+    fi
+    if [ ! -d ${CSUITE_ROOT} ]; then
+        echo "Could not find $CSUITE_ROOT in Android build environment. Try 'make csuite'"
+        exit
+    fi;
+fi;
+
+if [ -z ${CSUITE_ROOT} ]; then
+    # Assume we're in an extracted csuite install.
+    CSUITE_ROOT="$(dirname $0)/../.."
+fi;
+
+JAR_DIR=${CSUITE_ROOT}/android-csuite/tools
+
+TRADEFED_JAR="tradefed"
+
+JARS="tradefed
+  hosttestlib
+  compatibility-host-util
+  csuite-tradefed
+  csuite-tradefed-tests"
+
+for JAR in $JARS; do
+    checkFile ${JAR_DIR}/${JAR}.jar
+    JAR_PATH=${JAR_PATH}:${JAR_DIR}/${JAR}.jar
+done
+
+OPTIONAL_JARS="
+  google-tradefed
+  google-tradefed-tests
+  google-tf-prod-tests"
+
+for JAR in $OPTIONAL_JARS; do
+    if [ -f "$JAR.jar" ]; then
+        JAR_PATH=${JAR_PATH}:${JAR_DIR}/${JAR}.jar
+    fi;
+done
+
+# Load any shared libraries for host-side executables.
+LIB_DIR=${CSUITE_ROOT}/android-csuite/lib
+if [ "$HOST" == "Linux" ]; then
+    LD_LIBRARY_PATH=${LIB_DIR}:${LIB_DIR}64:${LD_LIBRARY_PATH}
+    export LD_LIBRARY_PATH
+elif [ "$HOST" == "Darwin" ]; then
+    DYLD_LIBRARY_PATH=${LIB_DIR}:${LIB_DIR}64:${DYLD_LIBRARY_PATH}
+    export DYLD_LIBRARY_PATH
+fi
+
+# Include any host-side test jars.
+for j in ${CSUITE_ROOT}/android-csuite/testcases/*.jar; do
+    JAR_PATH=${JAR_PATH}:$j
+done
+
+java $RDBG_FLAG -cp ${JAR_PATH} -DMTS_ROOT=${CSUITE_ROOT} com.android.compatibility.common.tradefed.command.CompatibilityConsole "$@"
diff --git a/tools/csuite-tradefed/src/test/java/com/android/compatibility/tradefed/CSuiteTradefedTest.java b/tools/csuite-tradefed/src/test/java/com/android/compatibility/tradefed/CSuiteTradefedTest.java
new file mode 100644
index 0000000..709798b
--- /dev/null
+++ b/tools/csuite-tradefed/src/test/java/com/android/compatibility/tradefed/CSuiteTradefedTest.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2019 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.compatibility.tradefed;
+
+import static org.junit.Assert.assertEquals;
+
+import com.android.compatibility.common.tradefed.build.CompatibilityBuildHelper;
+import com.android.compatibility.common.tradefed.build.CompatibilityBuildProvider;
+
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+
+@RunWith(JUnit4.class)
+public class CSuiteTradefedTest {
+
+    @Rule public final TemporaryFolder tempFolder = new TemporaryFolder();
+
+    private static final String ROOT_DIR_PROPERTY_NAME = "CSUITE_ROOT";
+    private static final String SUITE_FULL_NAME = "App Compatibility Test Suite";
+    private static final String SUITE_NAME = "C-Suite";
+
+    private CompatibilityBuildProvider mProvider;
+
+    @Before
+    public void setUp() throws Exception {
+        System.setProperty(ROOT_DIR_PROPERTY_NAME, tempFolder.getRoot().getAbsolutePath());
+        File base = tempFolder.newFolder("android-csuite");
+        File tests = tempFolder.newFolder("testcases");
+
+        mProvider =
+                new CompatibilityBuildProvider() {
+                    @Override
+                    protected String getSuiteInfoName() {
+                        return SUITE_NAME;
+                    }
+
+                    @Override
+                    protected String getSuiteInfoFullname() {
+                        return SUITE_FULL_NAME;
+                    }
+                };
+    }
+
+    @Test
+    public void testSuiteInfoLoad() throws Exception {
+        CompatibilityBuildHelper helper = new CompatibilityBuildHelper(mProvider.getBuild());
+        assertEquals("Incorrect suite full name", SUITE_FULL_NAME, helper.getSuiteFullName());
+        assertEquals("Incorrect suite name", SUITE_NAME, helper.getSuiteName());
+    }
+
+    @After
+    public void cleanUp() throws Exception {
+        System.clearProperty(ROOT_DIR_PROPERTY_NAME);
+    }
+}
diff --git a/tools/script/generate_module.py b/tools/script/generate_module.py
new file mode 100644
index 0000000..30ae7b4
--- /dev/null
+++ b/tools/script/generate_module.py
@@ -0,0 +1,258 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2020 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.
+#
+# This script generates C-Suite configuration files for a list of apps.
+
+import argparse
+import glob
+import os
+import sys
+from xml.dom import minidom
+from xml.etree import cElementTree as ET
+from xml.sax import saxutils
+
+from typing import IO, List, Text
+
+_ANDROID_BP_FILE_NAME = 'Android.bp'
+_ANDROID_XML_FILE_NAME = 'AndroidTest.xml'
+
+_TF_TEST_APP_INSTALL_SETUP =\
+  'com.android.tradefed.targetprep.TestAppInstallSetup'
+_CSUITE_APP_SETUP_PREPARER =\
+  'com.android.compatibility.targetprep.AppSetupPreparer'
+_CSUITE_LAUNCH_TEST_CLASS =\
+  'com.android.compatibility.testtype.AppLaunchTest'
+
+_CONFIG_TYPE_TARGET_PREPARER = 'target_preparer'
+_CONFIG_TYPE_TEST = 'test'
+
+
+def generate_all_modules_from_config(package_list_file_path, root_dir):
+    """Generate multiple test and build modules.
+
+    Args:
+    package_list_file_path: path of a file containing package names.
+    root_dir: root directory that modules will be generated in.
+    """
+    remove_existing_package_files(root_dir)
+
+    with open(package_list_file_path) as fp:
+        for line in parse_package_list(fp):
+            _generate_module_files(line.strip(), root_dir)
+
+
+def remove_existing_package_files(root_dir):
+    for filename in glob.iglob(root_dir + '**/AndroidTest.xml'):
+        if _is_auto_generated(filename):
+            os.remove(filename)
+
+    for filename in glob.iglob(root_dir + '**/Android.bp'):
+        if _is_auto_generated(filename):
+            os.remove(filename)
+
+    _remove_empty_dirs(root_dir)
+
+
+def _is_auto_generated(filename):
+    with open(filename, 'r') as f:
+        return 'auto-generated' in f.read()
+
+
+def _remove_empty_dirs(path):
+    for filename in os.listdir(path):
+        file_path = os.path.join(path, filename)
+        if os.path.isdir(file_path) and not os.listdir(file_path):
+            os.rmdir(file_path)
+
+
+def parse_package_list(package_list_file: IO[bytes]) -> List[bytes]:
+    return {
+        line.strip() for line in package_list_file.readlines() if line.strip()}
+
+
+def _generate_module_files(package_name, root_dir):
+    """Generate test and build modules for a single package.
+
+    Args:
+    package_name: package name of test and build modules.
+    root_dir: root directory that modules will be generated in.
+    """
+    package_dir = _create_package_dir(root_dir, package_name)
+
+    build_module_path = os.path.join(package_dir, _ANDROID_BP_FILE_NAME)
+    test_module_path = os.path.join(package_dir, _ANDROID_XML_FILE_NAME)
+
+    with open(build_module_path, 'w') as f:
+        write_build_module(package_name, f)
+
+    with open(test_module_path, 'w') as f:
+        write_test_module(package_name, f)
+
+
+def _create_package_dir(root_dir, package_name):
+    package_dir_path = os.path.join(root_dir, package_name)
+    os.mkdir(package_dir_path)
+
+    return package_dir_path
+
+
+def write_build_module(package_name: Text, out_file: IO[bytes]) -> Text:
+    build_module = _BUILD_MODULE_HEADER \
+        + _BUILD_MODULE_TEMPLATE.format(package_name=package_name)
+    out_file.write(build_module)
+
+
+def write_test_module(package_name: Text, out_file: IO[bytes]) -> Text:
+    configuration = ET.Element('configuration', {
+        'description': 'Tests the compatibility of apps'
+    })
+    ET.SubElement(
+        configuration, 'option', {
+            'name': 'config-descriptor:metadata',
+            'key': 'plan',
+            'value': 'csuite-launch'
+        }
+    )
+    ET.SubElement(
+        configuration, 'option', {
+            'name': 'package-name',
+            'value': package_name
+        }
+    )
+    test_file_name_option = {
+        'name': 'test-file-name',
+        'value': 'csuite-launch-instrumentation.apk'
+    }
+    _add_element_with_option(
+        configuration,
+        _CONFIG_TYPE_TARGET_PREPARER,
+        _TF_TEST_APP_INSTALL_SETUP,
+        options=[test_file_name_option]
+    )
+    _add_element_with_option(
+        configuration,
+        _CONFIG_TYPE_TARGET_PREPARER,
+        _CSUITE_APP_SETUP_PREPARER
+    )
+    _add_element_with_option(
+        configuration,
+        _CONFIG_TYPE_TEST,
+        _CSUITE_LAUNCH_TEST_CLASS
+    )
+
+    test_module = _TEST_MODULE_HEADER + _prettify(configuration)
+    out_file.write(test_module)
+
+
+def _add_element_with_option(elem, sub_elem, class_name, options=None):
+    if options is None:
+        options = []
+
+    new_elem = ET.SubElement(
+        elem, sub_elem, {
+            'class': class_name,
+        }
+    )
+    for option in options:
+        ET.SubElement(
+            new_elem, 'option', option
+        )
+
+
+def _prettify(elem: ET.Element) -> Text:
+    declaration = minidom.Document().toxml()
+    parsed = minidom.parseString(ET.tostring(elem, 'utf-8'))
+
+    return saxutils.unescape(
+        parsed.toprettyxml(indent='    ')[len(declaration) + 1:])
+
+_BUILD_MODULE_HEADER = """// Copyright (C) 2020 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.
+
+// This file was auto-generated by test/app_compat/csuite/tools/script/generate_module.py.
+// Do not edit manually.
+
+"""
+
+_BUILD_MODULE_TEMPLATE = """csuite_config {{
+    name: "csuite_{package_name}",
+}}
+"""
+
+_TEST_MODULE_HEADER = """<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2020 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.
+-->
+<!-- This file was auto-generated by test/app_compat/csuite/tools/script/generate_module.py.
+     Do not edit manually.
+-->
+
+"""
+
+
+def _file_path(path):
+    if os.path.isfile(path):
+        return path
+    raise argparse.ArgumentTypeError('%s is not a valid path' % path)
+
+
+def _dir_path(path):
+    if os.path.isdir(path):
+        return path
+    raise argparse.ArgumentTypeError('%s is not a valid path' % path)
+
+
+def parse_args(args):
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--package_list',
+                        type=_file_path,
+                        required=True,
+                        help='path of the file containing package names')
+    parser.add_argument('--root_dir',
+                        type=_dir_path,
+                        required=True,
+                        help='path of the root directory that' +
+                        'modules will be generated in')
+    return parser.parse_args(args)
+
+
+def main():
+    parser = parse_args(sys.argv[1:])
+    generate_all_modules_from_config(parser.package_list, parser.root_dir)
+
+if __name__ == '__main__':
+    main()
diff --git a/tools/script/generate_module_unittest.py b/tools/script/generate_module_unittest.py
new file mode 100644
index 0000000..4d24162
--- /dev/null
+++ b/tools/script/generate_module_unittest.py
@@ -0,0 +1,203 @@
+#!/usr/bin/env python
+#
+# Copyright (C) 2020 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.
+
+import io
+import os
+import unittest
+
+from lxml import etree
+from pyfakefs import fake_filesystem_unittest
+
+import generate_module
+
+
+class WriteTestModuleTest(unittest.TestCase):
+
+    def test_xml_is_valid(self):
+        package_name = 'package_name'
+        out = io.StringIO()
+
+        generate_module.write_test_module(package_name, out)
+
+        test_module_generated = out.getvalue()
+        self.assertTrue(self._contains_license(test_module_generated))
+        self.assertTrue(self._is_validate_xml(test_module_generated))
+
+    def _contains_license(self, generated_str: bytes) -> bool:
+        return 'Copyright' in generated_str and \
+                'Android Open Source Project' in generated_str
+
+    def _is_validate_xml(self, xml_str: bytes) -> bool:
+        xmlschema_doc = etree.parse(
+            io.BytesIO('''<?xml version="1.0" encoding="UTF-8" ?>
+        <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
+        <xs:element name="configuration">
+        <xs:complexType>
+            <xs:sequence>
+            <xs:element name="option" minOccurs="0" maxOccurs="unbounded"/>
+            <xs:element name="target_preparer" minOccurs="0" maxOccurs="unbounded"/>
+            <xs:element name="test" minOccurs="0" maxOccurs="unbounded"/>
+            </xs:sequence>
+            <xs:attribute name="description"/>
+        </xs:complexType>
+        </xs:element>
+        </xs:schema>
+        '''.encode('utf8')))
+        xmlschema = etree.XMLSchema(xmlschema_doc)
+
+        xml_doc = etree.parse(io.BytesIO(xml_str.encode('utf8')))
+        result = xmlschema.validate(xml_doc)
+
+        return result
+
+
+class WriteBuildModuleTest(unittest.TestCase):
+
+    def test_build_file_is_valid(self):
+        package_name = 'package_name'
+        out = io.StringIO()
+
+        generate_module.write_build_module(package_name, out)
+
+        build_module_generated = out.getvalue()
+        self.assertTrue(self._contains_license(build_module_generated))
+        self.assertTrue(self._are_parentheses_balanced(build_module_generated))
+        self.assertIn('csuite_config', build_module_generated)
+        self.assertIn(package_name, build_module_generated)
+
+    def _contains_license(self, generated_str: bytes) -> bool:
+        return 'Copyright' in generated_str and \
+                'Android Open Source Project' in generated_str
+
+    def _are_parentheses_balanced(self, generated_str: bytes) -> bool:
+        parenthese_count = 0
+
+        for elem in generated_str:
+            if elem == '{':
+                parenthese_count += 1
+            elif elem == '}':
+                parenthese_count -= 1
+
+            if parenthese_count < 0:
+                return False
+
+        return parenthese_count == 0
+
+
+class ParsePackageListTest(unittest.TestCase):
+
+    def test_accepts_empty_lines(self):
+        input = io.StringIO('\n\n\npackage_name\n\n')
+
+        package_list = generate_module.parse_package_list(input)
+
+        self.assertEqual(len(package_list), 1)
+        self.assertIn('package_name', package_list)
+        self.assertTrue(all(package_list))
+
+    def test_strips_trailing_whitespace(self):
+        input = io.StringIO('  package_name  ')
+
+        package_list = generate_module.parse_package_list(input)
+
+        self.assertEqual(len(package_list), 1)
+        self.assertIn('package_name', package_list)
+        self.assertTrue(all(package_list))
+
+    def test_duplicate_package_name(self):
+        input = io.StringIO('\n\npackage_name\n\npackage_name\n')
+
+        package_list = generate_module.parse_package_list(input)
+
+        self.assertEqual(len(package_list), 1)
+        self.assertIn('package_name', package_list)
+        self.assertTrue(all(package_list))
+
+
+class ParseArgsTest(fake_filesystem_unittest.TestCase):
+
+    def setUp(self):
+        super(ParseArgsTest, self).setUp()
+        self.setUpPyfakefs()
+
+    def test_configuration_file_not_exist(self):
+        package_list_file_path = '/test/package_list.txt'
+        root_dir = '/test/modules'
+        os.makedirs(root_dir)
+
+        with self.assertRaises(SystemExit):
+            generate_module.parse_args(
+                ['--package_list', package_list_file_path,
+                 '--root_dir', root_dir])
+
+    def test_module_dir_not_exist(self):
+        package_list_file_path = '/test/package_list.txt'
+        package_name1 = 'package_name_1'
+        package_name2 = 'package_name_2'
+        self.fs.create_file(package_list_file_path,
+                            contents=(package_name1+'\n'+package_name2))
+        root_dir = '/test/modules'
+
+        with self.assertRaises(SystemExit):
+            generate_module.parse_args(
+                ['--package_list', package_list_file_path,
+                 '--root_dir', root_dir])
+
+
+class GenerateAllModulesFromConfigTest(fake_filesystem_unittest.TestCase):
+
+    def setUp(self):
+        super(GenerateAllModulesFromConfigTest, self).setUp()
+        self.setUpPyfakefs()
+
+    def test_creates_package_files(self):
+        package_list_file_path = '/test/package_list.txt'
+        package_name1 = 'package_name_1'
+        package_name2 = 'package_name_2'
+        self.fs.create_file(package_list_file_path,
+                            contents=(package_name1+'\n'+package_name2))
+        root_dir = '/test/modules'
+        self.fs.create_dir(root_dir)
+
+        generate_module.generate_all_modules_from_config(
+            package_list_file_path, root_dir)
+
+        self.assertTrue(os.path.exists(
+            os.path.join(root_dir, package_name1, 'Android.bp')))
+        self.assertTrue(os.path.exists(
+            os.path.join(root_dir, package_name1, 'AndroidTest.xml')))
+        self.assertTrue(os.path.exists(
+            os.path.join(root_dir, package_name2, 'Android.bp')))
+        self.assertTrue(os.path.exists(
+            os.path.join(root_dir, package_name2, 'AndroidTest.xml')))
+
+    def test_removes_all_existing_package_files(self):
+        root_dir = '/test/'
+        package_dir = '/test/existing_package/'
+        existing_package_file1 = 'test/existing_package/AndroidTest.xml'
+        existing_package_file2 = 'test/existing_package/Android.bp'
+        self.fs.create_file(existing_package_file1, contents='auto-generated')
+        self.fs.create_file(existing_package_file2, contents='auto-generated')
+
+        generate_module.remove_existing_package_files(root_dir)
+
+        self.assertFalse(os.path.exists(existing_package_file1))
+        self.assertFalse(os.path.exists(existing_package_file2))
+        self.assertFalse(os.path.exists(package_dir))
+
+
+if __name__ == '__main__':
+    unittest.main()