Merge "Add test name as the file prefix for different test cases. Test: forret run"
diff --git a/harness/src/main/java/com/android/csuite/core/ApkInstaller.java b/harness/src/main/java/com/android/csuite/core/ApkInstaller.java
index 70a6105..54df8f4 100644
--- a/harness/src/main/java/com/android/csuite/core/ApkInstaller.java
+++ b/harness/src/main/java/com/android/csuite/core/ApkInstaller.java
@@ -198,17 +198,15 @@
                 .filter(path -> path.toString().toLowerCase().endsWith(".obb"))
                 .forEach(
                         path -> {
+                            String dest =
+                                    "/sdcard/Android/obb/" + packageName + "/" + path.getFileName();
                             cmds.add(
                                     new String[] {
-                                        "adb",
-                                        "-s",
-                                        deviceSerial,
-                                        "push",
-                                        path.toString(),
-                                        "/sdcard/Android/obb/"
-                                                + packageName
-                                                + "/"
-                                                + path.getFileName()
+                                        "adb", "-s", deviceSerial, "shell", "rm", "-f", dest
+                                    });
+                            cmds.add(
+                                    new String[] {
+                                        "adb", "-s", deviceSerial, "push", path.toString(), dest
                                     });
                         });
 
@@ -221,6 +219,7 @@
                         deviceSerial,
                         "shell",
                         "mkdir",
+                        "-p",
                         "/sdcard/Android/obb/" + packageName
                     });
         }
diff --git a/harness/src/main/java/com/android/csuite/core/AppCrawlTester.java b/harness/src/main/java/com/android/csuite/core/AppCrawlTester.java
index 320be19..258b374 100644
--- a/harness/src/main/java/com/android/csuite/core/AppCrawlTester.java
+++ b/harness/src/main/java/com/android/csuite/core/AppCrawlTester.java
@@ -17,6 +17,7 @@
 package com.android.csuite.core;
 
 import com.android.csuite.core.DeviceUtils.DeviceTimestamp;
+import com.android.csuite.core.TestUtils.TestUtilsException;
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.invoker.TestInformation;
 import com.android.tradefed.log.LogUtil.CLog;
@@ -36,11 +37,12 @@
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 import java.util.concurrent.atomic.AtomicReference;
 import java.util.stream.Collectors;
@@ -64,6 +66,7 @@
     private Path mRoboscriptFile;
     private Path mCrawlGuidanceProtoFile;
     private Path mLoginConfigDir;
+    private FileSystem mFileSystem;
 
     /**
      * Creates an {@link AppCrawlTester} instance.
@@ -78,14 +81,20 @@
         return new AppCrawlTester(
                 packageName,
                 TestUtils.getInstance(testInformation, testLogData),
-                () -> new RunUtil());
+                () -> new RunUtil(),
+                FileSystems.getDefault());
     }
 
     @VisibleForTesting
-    AppCrawlTester(String packageName, TestUtils testUtils, RunUtilProvider runUtilProvider) {
+    AppCrawlTester(
+            String packageName,
+            TestUtils testUtils,
+            RunUtilProvider runUtilProvider,
+            FileSystem fileSystem) {
         mRunUtilProvider = runUtilProvider;
         mPackageName = packageName;
         mTestUtils = testUtils;
+        mFileSystem = fileSystem;
     }
 
     /** An exception class representing crawler test failures. */
@@ -188,17 +197,34 @@
             throw new CrawlerException("Failed to create temp directory for output.", e);
         }
 
-        String[] command = createCrawlerRunCommand(mTestUtils.getTestInformation());
-
-        CLog.d("Launching package: %s.", mPackageName);
-
         IRunUtil runUtil = mRunUtilProvider.get();
-
+        AtomicReference<String[]> command = new AtomicReference<>();
         AtomicReference<CommandResult> commandResult = new AtomicReference<>();
-        runUtil.setEnvVariable(
-                "GOOGLE_APPLICATION_CREDENTIALS",
-                AppCrawlTesterHostPreparer.getCredentialPath(mTestUtils.getTestInformation())
-                        .toString());
+
+        CLog.d("Start to crawl package: %s.", mPackageName);
+
+        Path bin =
+                mFileSystem.getPath(
+                        AppCrawlTesterHostPreparer.getCrawlerBinPath(
+                                mTestUtils.getTestInformation()));
+        boolean isUtpClient = false;
+        if (Files.exists(bin.resolve("utp-cli-android_deploy.jar"))) {
+            command.set(createUtpCrawlerRunCommand(mTestUtils.getTestInformation()));
+            runUtil.setEnvVariable(
+                    "ANDROID_SDK",
+                    AppCrawlTesterHostPreparer.getSdkPath(mTestUtils.getTestInformation())
+                            .toString());
+            isUtpClient = true;
+        } else if (Files.exists(bin.resolve("crawl_launcher_deploy.jar"))) {
+            command.set(createCrawlerRunCommand(mTestUtils.getTestInformation()));
+            runUtil.setEnvVariable(
+                    "GOOGLE_APPLICATION_CREDENTIALS",
+                    AppCrawlTesterHostPreparer.getCredentialPath(mTestUtils.getTestInformation())
+                            .toString());
+        } else {
+            throw new CrawlerException(
+                    "Crawler executable binaries not found in " + bin.toString());
+        }
 
         if (mCollectGmsVersion) {
             mTestUtils.collectGmsVersion(mPackageName);
@@ -212,11 +238,11 @@
         if (mRecordScreen) {
             mTestUtils.collectScreenRecord(
                     () -> {
-                        commandResult.set(runUtil.runTimedCmd(commandTimeout, command));
+                        commandResult.set(runUtil.runTimedCmd(commandTimeout, command.get()));
                     },
                     mPackageName);
         } else {
-            commandResult.set(runUtil.runTimedCmd(commandTimeout, command));
+            commandResult.set(runUtil.runTimedCmd(commandTimeout, command.get()));
         }
 
         // Must be done after the crawler run because the app is installed by the crawler.
@@ -225,9 +251,10 @@
         }
 
         collectOutputZip();
-        collectCrawlStepScreenshots();
+        collectCrawlStepScreenshots(isUtpClient);
 
-        if (!commandResult.get().getStatus().equals(CommandStatus.SUCCESS)) {
+        if (!commandResult.get().getStatus().equals(CommandStatus.SUCCESS)
+                || commandResult.get().getStdout().contains("Unknown options:")) {
             throw new CrawlerException("Crawler command failed: " + commandResult.get());
         }
 
@@ -235,13 +262,16 @@
     }
 
     /** Copys the step screenshots into test outputs for easier access. */
-    private void collectCrawlStepScreenshots() {
+    private void collectCrawlStepScreenshots(boolean isUtpClient) {
         if (mOutput == null) {
             CLog.e("Output directory is not created yet. Skipping collecting step screenshots.");
             return;
         }
 
-        Path subDir = mOutput.resolve("app_firebase_test_lab");
+        Path subDir =
+                isUtpClient
+                        ? mOutput.resolve("output").resolve("artifacts")
+                        : mOutput.resolve("app_firebase_test_lab");
         if (!Files.exists(subDir)) {
             CLog.e(
                     "The crawler output directory is not complete, skipping collecting step"
@@ -285,81 +315,107 @@
         }
     }
 
-    /**
-     * Generates a list of APK paths where the base.apk of split apk files are always on the first
-     * index if exists.
-     *
-     * <p>If the apk path is a single apk, then the apk is returned. If the apk path is a directory
-     * containing only one non-split apk file, the apk file is returned. If the apk path is a
-     * directory containing split apk files for one package, then the list of apks are returned and
-     * the base.apk sits on the first index. If the apk path does not contain any apk files, or
-     * multiple apk files without base.apk, then an IOException is thrown.
-     *
-     * @return A list of APK paths.
-     * @throws CrawlerException If failed to read the apk path or unexpected number of apk files are
-     *     found under the path.
-     */
-    private static List<Path> getApks(Path root) throws CrawlerException {
-        // The apk path points to a non-split apk file.
-        if (Files.isRegularFile(root)) {
-            if (!root.toString().endsWith(".apk")) {
-                throw new CrawlerException(
-                        "The file on the given apk path is not an apk file: " + root);
-            }
-            return List.of(root);
-        }
-
-        List<Path> apks;
-        CLog.d("APK path = " + root);
-        try (Stream<Path> fileTree = Files.walk(root)) {
-            apks =
-                    fileTree.filter(Files::isRegularFile)
-                            .filter(path -> path.getFileName().toString().endsWith(".apk"))
-                            .collect(Collectors.toList());
-        } catch (IOException e) {
-            throw new CrawlerException("Failed to list apk files.", e);
-        }
-
-        if (apks.isEmpty()) {
-            throw new CrawlerException("The apk directory does not contain any apk files");
-        }
-
-        // The apk path contains a single non-split apk or the base.apk of a split-apk.
-        if (apks.size() == 1) {
-            return apks;
-        }
-
-        if (apks.stream().map(path -> path.getParent().toString()).distinct().count() != 1) {
-            throw new CrawlerException(
-                    "Apk files are not all in the same folder: "
-                            + Arrays.deepToString(apks.toArray(new Path[apks.size()])));
-        }
-
-        if (apks.stream().filter(path -> path.getFileName().toString().equals("base.apk")).count()
-                == 0) {
-            throw new CrawlerException(
-                    "Multiple non-split apk files detected: "
-                            + Arrays.deepToString(apks.toArray(new Path[apks.size()])));
-        }
-
-        Collections.sort(
-                apks,
-                (first, second) -> first.getFileName().toString().equals("base.apk") ? -1 : 0);
-
-        return apks;
-    }
-
     @VisibleForTesting
-    String[] createCrawlerRunCommand(TestInformation testInfo) throws CrawlerException {
+    String[] createUtpCrawlerRunCommand(TestInformation testInfo) throws CrawlerException {
 
+        Path bin =
+                mFileSystem.getPath(
+                        AppCrawlTesterHostPreparer.getCrawlerBinPath(
+                                mTestUtils.getTestInformation()));
         ArrayList<String> cmd = new ArrayList<>();
         cmd.addAll(
                 Arrays.asList(
                         "java",
                         "-jar",
-                        AppCrawlTesterHostPreparer.getCrawlerBinPath(testInfo)
-                                .resolve("crawl_launcher_deploy.jar")
+                        bin.resolve("utp-cli-android_deploy.jar").toString(),
+                        "android",
+                        "robo",
+                        "--device-id",
+                        testInfo.getDevice().getSerialNumber(),
+                        "--app-id",
+                        mPackageName,
+                        "--controller-endpoint",
+                        "PROD",
+                        "--utp-binaries-dir",
+                        bin.toString(),
+                        "--key-file",
+                        AppCrawlTesterHostPreparer.getCredentialPath(
+                                        mTestUtils.getTestInformation())
                                 .toString(),
+                        "--base-crawler-apk",
+                        bin.resolve("crawler_app.apk").toString(),
+                        "--stub-crawler-apk",
+                        bin.resolve("crawler_stubapp_androidx.apk").toString(),
+                        "--tmp-dir",
+                        mOutput.toString()));
+
+        if (mTimeoutSec > 0) {
+            cmd.add("--crawler-flag");
+            cmd.add("crawlDurationSec=" + Integer.toString(mTimeoutSec));
+        }
+
+        if (mUiAutomatorMode) {
+            cmd.addAll(Arrays.asList("--ui-automator-mode", "--app-installed-on-device"));
+        } else {
+            Preconditions.checkNotNull(
+                    mApkRoot, "Apk file path is required when not running in UIAutomator mode");
+
+            List<Path> apks;
+            try {
+                apks =
+                        TestUtils.listApks(mApkRoot).stream()
+                                .filter(
+                                        path ->
+                                                path.getFileName()
+                                                        .toString()
+                                                        .toLowerCase()
+                                                        .endsWith(".apk"))
+                                .collect(Collectors.toList());
+            } catch (TestUtilsException e) {
+                throw new CrawlerException(e);
+            }
+
+            cmd.add("--apks-to-crawl");
+            cmd.add(apks.stream().map(Path::toString).collect(Collectors.joining(",")));
+        }
+
+        if (mRoboscriptFile != null) {
+            Assert.assertTrue(
+                    "Please provide a valid roboscript file.",
+                    Files.isRegularFile(mRoboscriptFile));
+            cmd.add("--crawler-asset");
+            cmd.add("robo.script=" + mRoboscriptFile.toString());
+        }
+
+        if (mCrawlGuidanceProtoFile != null) {
+            Assert.assertTrue(
+                    "Please provide a valid CrawlGuidance file.",
+                    Files.isRegularFile(mCrawlGuidanceProtoFile));
+            cmd.add("--crawl-guidance-proto-path");
+            cmd.add(mCrawlGuidanceProtoFile.toString());
+        }
+
+        if (mLoginConfigDir != null) {
+            RoboLoginConfigProvider configProvider = new RoboLoginConfigProvider(mLoginConfigDir);
+            cmd.addAll(configProvider.findConfigFor(mPackageName, true).getLoginArgs());
+        }
+
+        return cmd.toArray(new String[cmd.size()]);
+    }
+
+    @VisibleForTesting
+    String[] createCrawlerRunCommand(TestInformation testInfo) throws CrawlerException {
+
+        Path bin =
+                mFileSystem.getPath(
+                        AppCrawlTesterHostPreparer.getCrawlerBinPath(
+                                mTestUtils.getTestInformation()));
+        ArrayList<String> cmd = new ArrayList<>();
+        cmd.addAll(
+                Arrays.asList(
+                        "java",
+                        "-jar",
+                        bin.resolve("crawl_launcher_deploy.jar").toString(),
                         "--android-sdk-path",
                         AppCrawlTesterHostPreparer.getSdkPath(testInfo).toString(),
                         "--device-serial-code",
@@ -368,9 +424,7 @@
                         mOutput.toString(),
                         "--key-store-file",
                         // Using the publicly known default file name of the debug keystore.
-                        AppCrawlTesterHostPreparer.getCrawlerBinPath(testInfo)
-                                .resolve("debug.keystore")
-                                .toString(),
+                        bin.resolve("debug.keystore").toString(),
                         "--key-store-password",
                         // Using the publicly known default password of the debug keystore.
                         "android"));
@@ -385,7 +439,20 @@
             Preconditions.checkNotNull(
                     mApkRoot, "Apk file path is required when not running in UIAutomator mode");
 
-            List<Path> apks = getApks(mApkRoot);
+            List<Path> apks;
+            try {
+                apks =
+                        TestUtils.listApks(mApkRoot).stream()
+                                .filter(
+                                        path ->
+                                                path.getFileName()
+                                                        .toString()
+                                                        .toLowerCase()
+                                                        .endsWith(".apk"))
+                                .collect(Collectors.toList());
+            } catch (TestUtilsException e) {
+                throw new CrawlerException(e);
+            }
 
             cmd.add("--apk-file");
             cmd.add(apks.get(0).toString());
@@ -417,8 +484,7 @@
 
         if (mLoginConfigDir != null) {
             RoboLoginConfigProvider configProvider = new RoboLoginConfigProvider(mLoginConfigDir);
-            RoboLoginConfig loginConfig = configProvider.findConfigFor(mPackageName);
-            cmd.addAll(loginConfig.getLoginArgs());
+            cmd.addAll(configProvider.findConfigFor(mPackageName, false).getLoginArgs());
         }
 
         return cmd.toArray(new String[cmd.size()]);
diff --git a/harness/src/main/java/com/android/csuite/core/AppCrawlTesterHostPreparer.java b/harness/src/main/java/com/android/csuite/core/AppCrawlTesterHostPreparer.java
index 4be78f7..abb3de3 100644
--- a/harness/src/main/java/com/android/csuite/core/AppCrawlTesterHostPreparer.java
+++ b/harness/src/main/java/com/android/csuite/core/AppCrawlTesterHostPreparer.java
@@ -31,6 +31,8 @@
 
 import java.io.File;
 import java.io.IOException;
+import java.nio.file.FileSystem;
+import java.nio.file.FileSystems;
 import java.nio.file.Files;
 import java.nio.file.Path;
 
@@ -44,6 +46,7 @@
     @VisibleForTesting static final String SDK_TAR_OPTION = "sdk-tar";
     @VisibleForTesting static final String CRAWLER_BIN_OPTION = "crawler-bin";
     @VisibleForTesting static final String CREDENTIAL_JSON_OPTION = "credential-json";
+    private final FileSystem mFileSystem;
 
     @Option(
             name = SDK_TAR_OPTION,
@@ -66,12 +69,13 @@
     private RunUtilProvider mRunUtilProvider;
 
     public AppCrawlTesterHostPreparer() {
-        this(() -> new RunUtil());
+        this(() -> new RunUtil(), FileSystems.getDefault());
     }
 
     @VisibleForTesting
-    AppCrawlTesterHostPreparer(RunUtilProvider runUtilProvider) {
+    AppCrawlTesterHostPreparer(RunUtilProvider runUtilProvider, FileSystem fileSystem) {
         mRunUtilProvider = runUtilProvider;
+        mFileSystem = fileSystem;
     }
 
     /**
@@ -80,7 +84,7 @@
      * @param testInfo The test info where the path is stored in.
      * @return The path to Android SDK; Null if not set.
      */
-    public static Path getSdkPath(TestInformation testInfo) {
+    public static String getSdkPath(TestInformation testInfo) {
         return getPathFromBuildInfo(testInfo, SDK_PATH_KEY);
     }
 
@@ -90,7 +94,7 @@
      * @param testInfo The test info where the path is stored in.
      * @return The path to the crawler binaries folder; Null if not set.
      */
-    public static Path getCrawlerBinPath(TestInformation testInfo) {
+    public static String getCrawlerBinPath(TestInformation testInfo) {
         return getPathFromBuildInfo(testInfo, CRAWLER_BIN_PATH_KEY);
     }
 
@@ -100,7 +104,7 @@
      * @param testInfo The test info where the path is stored in.
      * @return The path to the crawler credential json file.
      */
-    public static Path getCredentialPath(TestInformation testInfo) {
+    public static String getCredentialPath(TestInformation testInfo) {
         return getPathFromBuildInfo(testInfo, CREDENTIAL_PATH_KEY);
     }
 
@@ -114,9 +118,8 @@
         return testInfo.getBuildInfo().getBuildAttributes().get(IS_READY_KEY) != null;
     }
 
-    private static Path getPathFromBuildInfo(TestInformation testInfo, String key) {
-        String path = testInfo.getBuildInfo().getBuildAttributes().get(key);
-        return path == null ? null : Path.of(path);
+    private static String getPathFromBuildInfo(TestInformation testInfo, String key) {
+        return testInfo.getBuildInfo().getBuildAttributes().get(key);
     }
 
     @VisibleForTesting
@@ -154,9 +157,13 @@
 
         setSdkPath(testInfo, sdkPath);
 
+        Path jar = mCrawlerBin.toPath().resolve("crawl_launcher_deploy.jar");
+        if (!Files.exists(jar)) {
+            jar = mCrawlerBin.toPath().resolve("utp-cli-android_deploy.jar");
+        }
+
         // Make the crawler binary executable.
-        String chmodCmd =
-                "chmod 555 " + mCrawlerBin.toPath().resolve("crawl_launcher_deploy.jar").toString();
+        String chmodCmd = "chmod 555 " + jar.toString();
         CommandResult chmodRes = runUtil.runTimedCmd(COMMAND_TIMEOUT_MILLIS, chmodCmd.split(" "));
         if (!chmodRes.getStatus().equals(CommandStatus.SUCCESS)) {
             throw new TargetSetupError(
@@ -173,7 +180,7 @@
     @Override
     public void tearDown(TestInformation testInfo, Throwable e) {
         try {
-            cleanUp(getSdkPath(testInfo));
+            cleanUp(mFileSystem.getPath(getSdkPath(testInfo)));
         } catch (IOException ioException) {
             CLog.e(ioException);
         }
diff --git a/harness/src/main/java/com/android/csuite/core/DeviceUtils.java b/harness/src/main/java/com/android/csuite/core/DeviceUtils.java
index c075408..3528fe8 100644
--- a/harness/src/main/java/com/android/csuite/core/DeviceUtils.java
+++ b/harness/src/main/java/com/android/csuite/core/DeviceUtils.java
@@ -219,6 +219,11 @@
                     recordingProcess.destroyForcibly();
                 }
             }
+
+            CommandResult result = mDevice.executeShellV2Command("ls -sh " + videoPath);
+            if (result != null && result.getStatus() == CommandStatus.SUCCESS) {
+                CLog.d("Completed screenrecord %s, video size: %s", videoPath, result.getStdout());
+            }
             // Try to pull, handle, and delete the video file from the device anyway.
             handler.handleScreenRecordFile(mDevice.pullFile(videoPath));
             mDevice.deleteFile(videoPath);
diff --git a/harness/src/main/java/com/android/csuite/core/RoboLoginConfig.java b/harness/src/main/java/com/android/csuite/core/RoboLoginConfig.java
deleted file mode 100644
index 0a37b3a..0000000
--- a/harness/src/main/java/com/android/csuite/core/RoboLoginConfig.java
+++ /dev/null
@@ -1,36 +0,0 @@
-/*
- * Copyright (C) 2023 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.csuite.core;
-
-import com.google.common.collect.ImmutableList;
-
-/*
- * A class returned by RoboLoginConfigProvider that contains the login arguments
- * to be passed to the crawler.
- */
-public final class RoboLoginConfig {
-    private final ImmutableList<String> mLoginArgs;
-
-    public RoboLoginConfig(ImmutableList<String> loginArgs) {
-        this.mLoginArgs = loginArgs;
-    }
-
-    /* Returns the login arguments for this config which can be passed to the crawler. */
-    public ImmutableList<String> getLoginArgs() {
-        return mLoginArgs;
-    }
-}
diff --git a/harness/src/main/java/com/android/csuite/core/RoboLoginConfigProvider.java b/harness/src/main/java/com/android/csuite/core/RoboLoginConfigProvider.java
index 944e50f..2cfb4d0 100644
--- a/harness/src/main/java/com/android/csuite/core/RoboLoginConfigProvider.java
+++ b/harness/src/main/java/com/android/csuite/core/RoboLoginConfigProvider.java
@@ -45,20 +45,48 @@
      * directory should contain only one config file per package name. If both Roboscript and
      * CrawlGuidance files are present, only the Roboscript file will be used."
      */
-    public RoboLoginConfig findConfigFor(String packageName) {
+    public RoboLoginConfig findConfigFor(String packageName, boolean isUtpClient) {
         Path crawlGuidanceFile = mLoginFilesDir.resolve(packageName + CRAWL_GUIDANCE_FILE_SUFFIX);
         Path roboScriptFile = mLoginFilesDir.resolve(packageName + ROBOSCRIPT_FILE_SUFFIX);
 
-        if (Files.exists(roboScriptFile)) {
+        if (Files.exists(roboScriptFile) && !isUtpClient) {
             return new RoboLoginConfig(
                     ImmutableList.of(ROBOSCRIPT_CMD_FLAG, roboScriptFile.toString()));
         }
 
-        if (Files.exists(crawlGuidanceFile)) {
+        if (Files.exists(crawlGuidanceFile) && !isUtpClient) {
             return new RoboLoginConfig(
                     ImmutableList.of(CRAWL_GUIDANCE_CMD_FLAG, crawlGuidanceFile.toString()));
         }
 
+        if (Files.exists(roboScriptFile) && isUtpClient) {
+            return new RoboLoginConfig(
+                    ImmutableList.of(
+                            "--crawler-asset", "robo.script=" + roboScriptFile.toString()));
+        }
+
+        if (Files.exists(crawlGuidanceFile) && isUtpClient) {
+            return new RoboLoginConfig(
+                    ImmutableList.of("--crawl-guidance-proto-path", crawlGuidanceFile.toString()));
+        }
+
         return new RoboLoginConfig(ImmutableList.of());
     }
+
+    /*
+     * A class returned by RoboLoginConfigProvider that contains the login arguments
+     * to be passed to the crawler.
+     */
+    public static final class RoboLoginConfig {
+        private final ImmutableList<String> mLoginArgs;
+
+        public RoboLoginConfig(ImmutableList<String> loginArgs) {
+            this.mLoginArgs = loginArgs;
+        }
+
+        /* Returns the login arguments for this config which can be passed to the crawler. */
+        public ImmutableList<String> getLoginArgs() {
+            return mLoginArgs;
+        }
+    }
 }
diff --git a/harness/src/test/java/com/android/csuite/core/ApkInstallerTest.java b/harness/src/test/java/com/android/csuite/core/ApkInstallerTest.java
index 7f99e36..c26b9d7 100644
--- a/harness/src/test/java/com/android/csuite/core/ApkInstallerTest.java
+++ b/harness/src/test/java/com/android/csuite/core/ApkInstallerTest.java
@@ -16,6 +16,8 @@
 package com.android.csuite.core;
 
 import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.anyLong;
 
 import com.android.csuite.core.ApkInstaller.ApkInstallerException;
 import com.android.tradefed.util.CommandResult;
@@ -27,6 +29,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.mockito.ArgumentCaptor;
 import org.mockito.ArgumentMatchers;
 import org.mockito.Mockito;
 
@@ -34,6 +37,7 @@
 import java.nio.file.FileSystem;
 import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.List;
 
 @RunWith(JUnit4.class)
 public final class ApkInstallerTest {
@@ -96,6 +100,29 @@
         assertThrows(ApkInstallerException.class, () -> sut.install(root));
     }
 
+    @Test
+    public void install_obbExists_installObb() throws Exception {
+        Path root = mFileSystem.getPath("apk");
+        Files.createDirectories(root);
+        Path apkPath = root.resolve("base.apk");
+        Files.createFile(apkPath);
+        Path obbPath = root.resolve("main.obb");
+        Files.createFile(obbPath);
+        IRunUtil runUtil = Mockito.mock(IRunUtil.class);
+        Mockito.when(runUtil.runTimedCmd(Mockito.anyLong(), ArgumentMatchers.<String>any()))
+                .thenReturn(createSuccessfulCommandResultWithStdout(""));
+        ApkInstaller sut = new ApkInstaller("serial", runUtil, apk -> "package.name");
+
+        sut.install(root);
+
+        ArgumentCaptor<String> cmdCaptor = ArgumentCaptor.forClass(String.class);
+        Mockito.verify(runUtil, Mockito.atLeastOnce()).runTimedCmd(anyLong(), cmdCaptor.capture());
+        List<String> capturedArgs = cmdCaptor.getAllValues();
+        assertTrue(capturedArgs.stream().anyMatch(arg -> arg.contains("push")));
+        assertTrue(capturedArgs.stream().anyMatch(arg -> arg.contains("rm")));
+        assertTrue(capturedArgs.stream().anyMatch(arg -> arg.contains("mkdir")));
+    }
+
     private static CommandResult createSuccessfulCommandResultWithStdout(String stdout) {
         CommandResult commandResult = new CommandResult(CommandStatus.SUCCESS);
         commandResult.setExitCode(0);
diff --git a/harness/src/test/java/com/android/csuite/core/AppCrawlTesterHostPreparerTest.java b/harness/src/test/java/com/android/csuite/core/AppCrawlTesterHostPreparerTest.java
index 87762ea..7a060ea 100644
--- a/harness/src/test/java/com/android/csuite/core/AppCrawlTesterHostPreparerTest.java
+++ b/harness/src/test/java/com/android/csuite/core/AppCrawlTesterHostPreparerTest.java
@@ -56,14 +56,14 @@
         Path path = Path.of("some");
         AppCrawlTesterHostPreparer.setSdkPath(mTestInfo, path);
 
-        Path result = AppCrawlTesterHostPreparer.getSdkPath(mTestInfo);
+        String result = AppCrawlTesterHostPreparer.getSdkPath(mTestInfo);
 
-        assertThat(result.toString()).isEqualTo(path.toString());
+        assertThat(result).isEqualTo(path.toString());
     }
 
     @Test
     public void getSdkPath_wasNotSet_returnsNull() {
-        Path result = AppCrawlTesterHostPreparer.getSdkPath(mTestInfo);
+        String result = AppCrawlTesterHostPreparer.getSdkPath(mTestInfo);
 
         assertNull(result);
     }
@@ -73,14 +73,14 @@
         Path path = Path.of("some");
         AppCrawlTesterHostPreparer.setCrawlerBinPath(mTestInfo, path);
 
-        Path result = AppCrawlTesterHostPreparer.getCrawlerBinPath(mTestInfo);
+        String result = AppCrawlTesterHostPreparer.getCrawlerBinPath(mTestInfo);
 
-        assertThat(result.toString()).isEqualTo(path.toString());
+        assertThat(result).isEqualTo(path.toString());
     }
 
     @Test
     public void getCrawlerBinPath_wasNotSet_returnsNull() {
-        Path result = AppCrawlTesterHostPreparer.getCrawlerBinPath(mTestInfo);
+        String result = AppCrawlTesterHostPreparer.getCrawlerBinPath(mTestInfo);
 
         assertNull(result);
     }
@@ -90,14 +90,14 @@
         Path path = Path.of("some");
         AppCrawlTesterHostPreparer.setCredentialPath(mTestInfo, path);
 
-        Path result = AppCrawlTesterHostPreparer.getCredentialPath(mTestInfo);
+        String result = AppCrawlTesterHostPreparer.getCredentialPath(mTestInfo);
 
-        assertThat(result.toString()).isEqualTo(path.toString());
+        assertThat(result).isEqualTo(path.toString());
     }
 
     @Test
     public void getCredentialPath_wasNotSet_returnsNull() {
-        Path result = AppCrawlTesterHostPreparer.getCredentialPath(mTestInfo);
+        String result = AppCrawlTesterHostPreparer.getCredentialPath(mTestInfo);
 
         assertNull(result);
     }
@@ -143,7 +143,8 @@
     }
 
     private AppCrawlTesterHostPreparer createTestSubject() throws Exception {
-        AppCrawlTesterHostPreparer suj = new AppCrawlTesterHostPreparer(() -> mRunUtil);
+        AppCrawlTesterHostPreparer suj =
+                new AppCrawlTesterHostPreparer(() -> mRunUtil, mFileSystem);
         OptionSetter optionSetter = new OptionSetter(suj);
         optionSetter.setOptionValue(
                 AppCrawlTesterHostPreparer.SDK_TAR_OPTION,
diff --git a/harness/src/test/java/com/android/csuite/core/AppCrawlTesterTest.java b/harness/src/test/java/com/android/csuite/core/AppCrawlTesterTest.java
index 6907307..67ff1cf 100644
--- a/harness/src/test/java/com/android/csuite/core/AppCrawlTesterTest.java
+++ b/harness/src/test/java/com/android/csuite/core/AppCrawlTesterTest.java
@@ -55,6 +55,7 @@
 
 @RunWith(JUnit4.class)
 public final class AppCrawlTesterTest {
+    private static final String PACKAGE_NAME = "package.name";
     private final TestArtifactReceiver mTestArtifactReceiver =
             Mockito.mock(TestArtifactReceiver.class);
     private final FileSystem mFileSystem =
@@ -228,14 +229,13 @@
     }
 
     @Test
-    public void start_credentialIsProvidedToCrawler() throws Exception {
+    public void start_sdkPathIsProvidedToCrawler() throws Exception {
         AppCrawlTester suj = createPreparedTestSubject();
         suj.setApkPath(createApkPathWithSplitApks());
 
         suj.start();
 
-        Mockito.verify(mRunUtil)
-                .setEnvVariable(Mockito.eq("GOOGLE_APPLICATION_CREDENTIALS"), Mockito.anyString());
+        Mockito.verify(mRunUtil).setEnvVariable(Mockito.eq("ANDROID_SDK"), Mockito.anyString());
     }
 
     @Test
@@ -361,7 +361,7 @@
     }
 
     @Test
-    public void createCrawlerRunCommand_containsRequiredCrawlerParams() throws Exception {
+    public void createUtpCrawlerRunCommand_containsRequiredCrawlerParams() throws Exception {
         Path apkRoot = mFileSystem.getPath("apk");
         Files.createDirectories(apkRoot);
         Files.createFile(apkRoot.resolve("some.apk"));
@@ -369,16 +369,20 @@
         suj.setApkPath(apkRoot);
         suj.start();
 
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(result).asList().contains("--key-store-file");
-        assertThat(result).asList().contains("--key-store-password");
-        assertThat(result).asList().contains("--device-serial-code");
-        assertThat(result).asList().contains("--apk-file");
+        assertThat(result).asList().contains("android");
+        assertThat(result).asList().contains("robo");
+        assertThat(result).asList().contains("--device-id");
+        assertThat(result).asList().contains("--app-id");
+        assertThat(result).asList().contains("--utp-binaries-dir");
+        assertThat(result).asList().contains("--key-file");
+        assertThat(result).asList().contains("--base-crawler-apk");
+        assertThat(result).asList().contains("--stub-crawler-apk");
     }
 
     @Test
-    public void createCrawlerRunCommand_containsRoboscriptFileWhenProvided() throws Exception {
+    public void createUtpCrawlerRunCommand_containsRoboscriptFileWhenProvided() throws Exception {
         AppCrawlTester suj = createPreparedTestSubject();
         Path roboDir = mFileSystem.getPath("/robo");
         Files.createDirectory(roboDir);
@@ -387,27 +391,15 @@
         suj.setRoboscriptFile(roboFile);
         suj.start();
 
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(result).asList().contains("--robo-script-file");
+        assertThat(result).asList().contains("--crawler-asset");
+        assertThat(result).asList().contains("robo.script=" + roboFile.toString());
     }
 
     @Test
-    public void createCrawlerRunCommand_containsEndpointWhenProvided() throws Exception {
-        AppCrawlTester suj = createPreparedTestSubject();
-        suj.setUiAutomatorMode(true);
-        String endpoint = "abc@efg";
-        suj.setCrawlControllerEndpoint(endpoint);
-        suj.start();
-
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
-
-        assertThat(result).asList().contains("--endpoint");
-        assertThat(result).asList().contains(endpoint);
-    }
-
-    @Test
-    public void createCrawlerRunCommand_containsCrawlGuidanceFileWhenProvided() throws Exception {
+    public void createUtpCrawlerRunCommand_containsCrawlGuidanceFileWhenProvided()
+            throws Exception {
         AppCrawlTester suj = createPreparedTestSubject();
         Path crawlGuideDir = mFileSystem.getPath("/cg");
         Files.createDirectory(crawlGuideDir);
@@ -416,73 +408,71 @@
         suj.setUiAutomatorMode(true);
         suj.setCrawlGuidanceProtoFile(crawlGuideFile);
         suj.start();
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(result).asList().contains("--text-guide-file");
+        assertThat(result).asList().contains("--crawl-guidance-proto-path");
     }
 
     @Test
-    public void createCrawlerRunCommand_loginDirContainsOnlyCrawlGuidanceFile_addsFilePath()
+    public void createUtpCrawlerRunCommand_loginDirContainsOnlyCrawlGuidanceFile_addsFilePath()
             throws Exception {
-        String packageName = "app.package";
-        AppCrawlTester suj = createPreparedTestSubject(packageName);
+        AppCrawlTester suj = createPreparedTestSubject();
         Path loginFilesDir = mFileSystem.getPath("/login");
         Files.createDirectory(loginFilesDir);
         Path crawlGuideFile =
-                Files.createFile(loginFilesDir.resolve(packageName + CRAWL_GUIDANCE_FILE_SUFFIX));
+                Files.createFile(loginFilesDir.resolve(PACKAGE_NAME + CRAWL_GUIDANCE_FILE_SUFFIX));
 
         suj.setUiAutomatorMode(true);
         suj.setLoginConfigDir(loginFilesDir);
         suj.start();
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(result).asList().contains("--text-guide-file");
+        assertThat(result).asList().contains("--crawl-guidance-proto-path");
         assertThat(result).asList().contains(crawlGuideFile.toString());
     }
 
     @Test
-    public void createCrawlerRunCommand_loginDirContainsOnlyRoboscriptFile_addsFilePath()
+    public void createUtpCrawlerRunCommand_loginDirContainsOnlyRoboscriptFile_addsFilePath()
             throws Exception {
-        String packageName = "app.package";
-        AppCrawlTester suj = createPreparedTestSubject(packageName);
+        AppCrawlTester suj = createPreparedTestSubject();
         Path loginFilesDir = mFileSystem.getPath("/login");
         Files.createDirectory(loginFilesDir);
         Path roboscriptFile =
-                Files.createFile(loginFilesDir.resolve(packageName + ROBOSCRIPT_FILE_SUFFIX));
+                Files.createFile(loginFilesDir.resolve(PACKAGE_NAME + ROBOSCRIPT_FILE_SUFFIX));
 
         suj.setUiAutomatorMode(true);
         suj.setLoginConfigDir(loginFilesDir);
         suj.start();
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(result).asList().contains("--robo-script-file");
-        assertThat(result).asList().contains(roboscriptFile.toString());
+        assertThat(result).asList().contains("--crawler-asset");
+        assertThat(result).asList().contains("robo.script=" + roboscriptFile.toString());
     }
 
     @Test
-    public void createCrawlerRunCommand_loginDirContainsMultipleLoginFiles_addsRoboscriptFilePath()
-            throws Exception {
-        String packageName = "app.package";
-        AppCrawlTester suj = createPreparedTestSubject(packageName);
+    public void
+            createUtpCrawlerRunCommand_loginDirContainsMultipleLoginFiles_addsRoboscriptFilePath()
+                    throws Exception {
+        AppCrawlTester suj = createPreparedTestSubject();
         Path loginFilesDir = mFileSystem.getPath("/login");
         Files.createDirectory(loginFilesDir);
         Path roboscriptFile =
-                Files.createFile(loginFilesDir.resolve(packageName + ROBOSCRIPT_FILE_SUFFIX));
+                Files.createFile(loginFilesDir.resolve(PACKAGE_NAME + ROBOSCRIPT_FILE_SUFFIX));
         Path crawlGuideFile =
-                Files.createFile(loginFilesDir.resolve(packageName + CRAWL_GUIDANCE_FILE_SUFFIX));
+                Files.createFile(loginFilesDir.resolve(PACKAGE_NAME + CRAWL_GUIDANCE_FILE_SUFFIX));
 
         suj.setUiAutomatorMode(true);
         suj.setLoginConfigDir(loginFilesDir);
         suj.start();
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(result).asList().contains("--robo-script-file");
-        assertThat(result).asList().contains(roboscriptFile.toString());
+        assertThat(result).asList().contains("--crawler-asset");
+        assertThat(result).asList().contains("robo.script=" + roboscriptFile.toString());
         assertThat(result).asList().doesNotContain(crawlGuideFile.toString());
     }
 
     @Test
-    public void createCrawlerRunCommand_loginDirEmpty_doesNotAddFlag() throws Exception {
+    public void createUtpCrawlerRunCommand_loginDirEmpty_doesNotAddFlag() throws Exception {
         AppCrawlTester suj = createPreparedTestSubject();
         Path loginFilesDir = mFileSystem.getPath("/login");
         Files.createDirectory(loginFilesDir);
@@ -490,14 +480,14 @@
         suj.setUiAutomatorMode(true);
         suj.setLoginConfigDir(loginFilesDir);
         suj.start();
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(result).asList().doesNotContain("--robo-script-file");
-        assertThat(result).asList().doesNotContain("--text-guide-file");
+        assertThat(result).asList().doesNotContain("--crawler-asset");
+        assertThat(result).asList().doesNotContain("--crawl-guidance-proto-path");
     }
 
     @Test
-    public void createCrawlerRunCommand_crawlerIsExecutedThroughJavaJar() throws Exception {
+    public void createUtpCrawlerRunCommand_crawlerIsExecutedThroughJavaJar() throws Exception {
         Path apkRoot = mFileSystem.getPath("apk");
         Files.createDirectories(apkRoot);
         Files.createFile(apkRoot.resolve("some.apk"));
@@ -505,14 +495,14 @@
         suj.setApkPath(apkRoot);
         suj.start();
 
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
         assertThat(result).asList().contains("java");
         assertThat(result).asList().contains("-jar");
     }
 
     @Test
-    public void createCrawlerRunCommand_splitApksProvided_useApkFileAndSplitApkFilesParams()
+    public void createUtpCrawlerRunCommand_splitApksProvided_useApkFileAndSplitApkFilesParams()
             throws Exception {
         Path apkRoot = mFileSystem.getPath("apk");
         Files.createDirectories(apkRoot);
@@ -523,19 +513,16 @@
         suj.setApkPath(apkRoot);
         suj.start();
 
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(Arrays.asList(result).stream().filter(s -> s.equals("--apk-file")).count())
+        assertThat(Arrays.asList(result).stream().filter(s -> s.equals("--apks-to-crawl")).count())
                 .isEqualTo(1);
-        assertThat(
-                        Arrays.asList(result).stream()
-                                .filter(s -> s.equals("--split-apk-files"))
-                                .count())
-                .isEqualTo(2);
+        assertThat(Arrays.asList(result).stream().filter(s -> s.contains("config1.apk")).count())
+                .isEqualTo(1);
     }
 
     @Test
-    public void createCrawlerRunCommand_uiAutomatorModeEnabled_doesNotContainApks()
+    public void createUtpCrawlerRunCommand_uiAutomatorModeEnabled_doesNotContainApks()
             throws Exception {
         Path apkRoot = mFileSystem.getPath("apk");
         Files.createDirectories(apkRoot);
@@ -547,19 +534,14 @@
         suj.setUiAutomatorMode(true);
         suj.start();
 
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
-        assertThat(Arrays.asList(result).stream().filter(s -> s.equals("--apk-file")).count())
-                .isEqualTo(0);
-        assertThat(
-                        Arrays.asList(result).stream()
-                                .filter(s -> s.equals("--split-apk-files"))
-                                .count())
+        assertThat(Arrays.asList(result).stream().filter(s -> s.equals("--apks-to-crawl")).count())
                 .isEqualTo(0);
     }
 
     @Test
-    public void createCrawlerRunCommand_uiAutomatorModeEnabled_containsUiAutomatorParam()
+    public void createUtpCrawlerRunCommand_uiAutomatorModeEnabled_containsUiAutomatorParam()
             throws Exception {
         Path apkRoot = mFileSystem.getPath("apk");
         Files.createDirectories(apkRoot);
@@ -571,7 +553,7 @@
         suj.setUiAutomatorMode(true);
         suj.start();
 
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
         assertThat(
                         Arrays.asList(result).stream()
@@ -580,13 +562,13 @@
                 .isEqualTo(1);
         assertThat(
                         Arrays.asList(result).stream()
-                                .filter(s -> s.equals("--app-package-name"))
+                                .filter(s -> s.equals("--app-installed-on-device"))
                                 .count())
                 .isEqualTo(1);
     }
 
     @Test
-    public void createCrawlerRunCommand_doesNotContainNullOrEmptyStrings() throws Exception {
+    public void createUtpCrawlerRunCommand_doesNotContainNullOrEmptyStrings() throws Exception {
         Path apkRoot = mFileSystem.getPath("apk");
         Files.createDirectories(apkRoot);
         Files.createFile(apkRoot.resolve("base.apk"));
@@ -596,10 +578,9 @@
         suj.setApkPath(apkRoot);
         suj.start();
 
-        String[] result = suj.createCrawlerRunCommand(mTestInfo);
+        String[] result = suj.createUtpCrawlerRunCommand(mTestInfo);
 
         assertThat(Arrays.asList(result).stream().filter(s -> s == null).count()).isEqualTo(0);
-
         assertThat(Arrays.asList(result).stream().map(String::trim).filter(String::isEmpty).count())
                 .isEqualTo(0);
     }
@@ -609,17 +590,20 @@
         IRunUtil runUtil = Mockito.mock(IRunUtil.class);
         Mockito.when(runUtil.runTimedCmd(Mockito.anyLong(), ArgumentMatchers.<String>any()))
                 .thenReturn(createSuccessfulCommandResult());
-        AppCrawlTesterHostPreparer preparer = new AppCrawlTesterHostPreparer(() -> runUtil);
+        AppCrawlTesterHostPreparer preparer =
+                new AppCrawlTesterHostPreparer(() -> runUtil, mFileSystem);
         OptionSetter optionSetter = new OptionSetter(preparer);
+
+        Path bin = Files.createDirectories(mFileSystem.getPath("/bin"));
+        Files.createFile(bin.resolve("utp-cli-android_deploy.jar"));
+
         optionSetter.setOptionValue(
                 AppCrawlTesterHostPreparer.SDK_TAR_OPTION,
-                Files.createDirectories(mFileSystem.getPath("sdk")).toString());
-        optionSetter.setOptionValue(
-                AppCrawlTesterHostPreparer.CRAWLER_BIN_OPTION,
-                Files.createDirectories(mFileSystem.getPath("bin")).toString());
+                Files.createDirectories(mFileSystem.getPath("/sdk")).toString());
+        optionSetter.setOptionValue(AppCrawlTesterHostPreparer.CRAWLER_BIN_OPTION, bin.toString());
         optionSetter.setOptionValue(
                 AppCrawlTesterHostPreparer.CREDENTIAL_JSON_OPTION,
-                Files.createDirectories(mFileSystem.getPath("cred.json")).toString());
+                Files.createDirectories(mFileSystem.getPath("/cred.json")).toString());
         preparer.setUp(mTestInfo);
     }
 
@@ -627,23 +611,14 @@
         Mockito.when(mRunUtil.runTimedCmd(Mockito.anyLong(), ArgumentMatchers.<String>any()))
                 .thenReturn(createSuccessfulCommandResult());
         Mockito.when(mDevice.getSerialNumber()).thenReturn("serial");
-        return new AppCrawlTester("package.name", mTestUtils, () -> mRunUtil);
+        return new AppCrawlTester(PACKAGE_NAME, mTestUtils, () -> mRunUtil, mFileSystem);
     }
-
     private AppCrawlTester createPreparedTestSubject()
             throws IOException, ConfigurationException, TargetSetupError {
         simulatePreparerWasExecutedSuccessfully();
         Mockito.when(mRunUtil.runTimedCmd(Mockito.anyLong(), ArgumentMatchers.<String>any()))
                 .thenReturn(createSuccessfulCommandResult());
-        return new AppCrawlTester("package.name", mTestUtils, () -> mRunUtil);
-    }
-
-    private AppCrawlTester createPreparedTestSubject(String packageName)
-            throws IOException, ConfigurationException, TargetSetupError {
-        simulatePreparerWasExecutedSuccessfully();
-        Mockito.when(mRunUtil.runTimedCmd(Mockito.anyLong(), ArgumentMatchers.<String>any()))
-                .thenReturn(createSuccessfulCommandResult());
-        return new AppCrawlTester(packageName, mTestUtils, () -> mRunUtil);
+        return new AppCrawlTester(PACKAGE_NAME, mTestUtils, () -> mRunUtil, mFileSystem);
     }
 
     private TestUtils createTestUtils() throws DeviceNotAvailableException {
diff --git a/test_scripts/src/main/java/com/android/webview/lib/GcloudCli.java b/test_scripts/src/main/java/com/android/webview/lib/GcloudCli.java
deleted file mode 100644
index 1595339..0000000
--- a/test_scripts/src/main/java/com/android/webview/lib/GcloudCli.java
+++ /dev/null
@@ -1,116 +0,0 @@
-/*
- * Copyright (C) 2022 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.webview.tests;
-
-import com.android.tradefed.util.CommandResult;
-import com.android.tradefed.util.CommandStatus;
-import com.android.tradefed.util.RunUtil;
-
-import org.junit.Assert;
-
-import java.io.File;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.Paths;
-
-/**
- * The WebView installer tool uses gsutil to download WebView apk's from GCS. The GcloudCli class
- * can be used to authenticate the host for using gsutil. This class does this by running 'gcloud
- * init' on the host machine. When the host machine runs the command, gcloud uses the application
- * default credentials to authenticate gsutil.
- */
-public class GcloudCli {
-    private static final long COMMAND_TIMEOUT_MILLIS = 5 * 60 * 1000;
-    private File mGcloudCliDir;
-
-    private RunUtil mRunUtil;
-
-    private GcloudCli(File gcloudCliDir, RunUtil runUtil) {
-        mRunUtil = runUtil;
-        mGcloudCliDir = gcloudCliDir;
-    }
-
-    public static GcloudCli buildFromZipArchive(File gcloudCliZipArchive) throws IOException {
-        Path gcloudCliDir = Files.createTempDirectory(null);
-        try {
-            CommandResult unzipRes =
-                    RunUtil.getDefault()
-                            .runTimedCmd(
-                                    COMMAND_TIMEOUT_MILLIS,
-                                    "unzip",
-                                    gcloudCliZipArchive.getAbsolutePath(),
-                                    "-d",
-                                    gcloudCliDir.toFile().getAbsolutePath());
-            Assert.assertEquals(
-                    "Unable to unzip the gcloud cli zip archive",
-                    unzipRes.getStatus(),
-                    CommandStatus.SUCCESS);
-            RunUtil runUtil = new RunUtil();
-            // The 'gcloud init' command creates configuration files for gsutil and other
-            // applications that use the gcloud sdk in the home directory. We can isolate
-            // the effects of these configuration files to the processes that run the
-            // gcloud and gsutil executables tracked by this class by setting the home
-            // directory for processes that run those executables to a temporary directory
-            // also tracked by this class.
-            runUtil.setEnvVariable("HOME", gcloudCliDir.toFile().getAbsolutePath());
-            File gcloudBin =
-                    gcloudCliDir.resolve(Paths.get("google-cloud-sdk", "bin", "gcloud")).toFile();
-            String gcloudInitScript =
-                    String.format(
-                            "printf \"1\\n1\" | %s init --console-only",
-                            gcloudBin.getAbsolutePath());
-            CommandResult gcloudInitRes =
-                    runUtil.runTimedCmd(
-                            COMMAND_TIMEOUT_MILLIS,
-                            System.out,
-                            System.out,
-                            "sh",
-                            "-c",
-                            gcloudInitScript);
-            Assert.assertEquals(
-                    "gcloud cli initialization failed",
-                    gcloudInitRes.getStatus(),
-                    CommandStatus.SUCCESS);
-            return new GcloudCli(gcloudCliDir.toFile(), runUtil);
-        } catch (Exception e) {
-            RunUtil.getDefault()
-                    .runTimedCmd(
-                            COMMAND_TIMEOUT_MILLIS,
-                            "rm",
-                            "-rf",
-                            gcloudCliDir.toFile().getAbsolutePath());
-            throw e;
-        }
-    }
-
-    public File getGsutilExecutable() {
-        return mGcloudCliDir
-                .toPath()
-                .resolve(Paths.get("google-cloud-sdk", "bin", "gsutil"))
-                .toFile();
-    }
-
-    public RunUtil getRunUtil() {
-        return mRunUtil;
-    }
-
-    public void tearDown() {
-        RunUtil.getDefault()
-                .runTimedCmd(COMMAND_TIMEOUT_MILLIS, "rm", "-rf", mGcloudCliDir.getAbsolutePath());
-    }
-}
diff --git a/test_scripts/src/main/java/com/android/webview/lib/WebviewInstallerToolPreparer.java b/test_scripts/src/main/java/com/android/webview/lib/WebviewInstallerToolPreparer.java
new file mode 100644
index 0000000..52e5c94
--- /dev/null
+++ b/test_scripts/src/main/java/com/android/webview/lib/WebviewInstallerToolPreparer.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright (C) 2023 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.webview.tests;
+
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.Option.Importance;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.targetprep.ITargetPreparer;
+import com.android.tradefed.targetprep.TargetSetupError;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.RunUtil;
+
+import org.junit.Assert;
+
+import java.io.File;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+public class WebviewInstallerToolPreparer implements ITargetPreparer {
+    private static final long COMMAND_TIMEOUT_MILLIS = 5 * 60 * 1000;
+    private static final String WEBVIEW_INSTALLER_TOOL_PATH = "WEBVIEW_INSTALLER_TOOL_PATH";
+    private static final String GCLOUD_CLI_PATH = "GCLOUD_CLI_PATH";
+
+    private File mGcloudCliDir;
+    private RunUtilProvider mRunUtilProvider;
+
+    @Option(
+            name = "gcloud-cli-zip-archive",
+            description = "Path to the google cli zip archive.",
+            importance = Importance.ALWAYS)
+    private File mGcloudCliZipArchive;
+
+    @Option(
+            name = "webview-installer-tool",
+            description = "Path to the webview installer executable.",
+            importance = Importance.ALWAYS)
+    private File mWebviewInstallerTool;
+
+    public WebviewInstallerToolPreparer(RunUtilProvider runUtilProvider) {
+        mRunUtilProvider = runUtilProvider;
+    }
+
+    public WebviewInstallerToolPreparer() {
+        this(() -> new RunUtil());
+    }
+
+    public static CommandResult runWebviewInstallerToolCommand(
+            TestInformation testInformation,
+            @Nullable String webviewVersion,
+            @Nullable String releaseChannel,
+            List<String> extraArgs) {
+        RunUtil runUtil = new RunUtil();
+        runUtil.setEnvVariable("HOME", getGcloudCliPath(testInformation));
+
+        List<String> commandLineArgs =
+                new ArrayList<>(
+                        Arrays.asList(
+                                getWebviewInstallerToolPath(testInformation),
+                                "--non-next",
+                                "--serial",
+                                testInformation.getDevice().getSerialNumber(),
+                                "-vvv",
+                                "--gsutil",
+                                Paths.get(
+                                                getGcloudCliPath(testInformation),
+                                                "google-cloud-sdk",
+                                                "bin",
+                                                "gsutil")
+                                        .toFile()
+                                        .getAbsolutePath()));
+        commandLineArgs.addAll(extraArgs);
+
+        if (webviewVersion != null) {
+            commandLineArgs.addAll(Arrays.asList("--chrome-version", webviewVersion));
+        }
+
+        if (releaseChannel != null) {
+            commandLineArgs.addAll(Arrays.asList("--channel", releaseChannel));
+        }
+
+        return runUtil.runTimedCmd(
+                COMMAND_TIMEOUT_MILLIS,
+                System.out,
+                System.out,
+                commandLineArgs.toArray(new String[0]));
+    }
+
+    public static void setGcloudCliPath(TestInformation testInformation, File gcloudCliDir) {
+        testInformation
+                .getBuildInfo()
+                .addBuildAttribute(GCLOUD_CLI_PATH, gcloudCliDir.getAbsolutePath());
+    }
+
+    public static void setWebviewInstallerToolPath(
+            TestInformation testInformation, File webviewInstallerTool) {
+        testInformation
+                .getBuildInfo()
+                .addBuildAttribute(
+                        WEBVIEW_INSTALLER_TOOL_PATH, webviewInstallerTool.getAbsolutePath());
+    }
+
+    public static String getWebviewInstallerToolPath(TestInformation testInformation) {
+        return testInformation.getBuildInfo().getBuildAttributes().get(WEBVIEW_INSTALLER_TOOL_PATH);
+    }
+
+    public static String getGcloudCliPath(TestInformation testInformation) {
+        return testInformation.getBuildInfo().getBuildAttributes().get(GCLOUD_CLI_PATH);
+    }
+
+    @Override
+    public void setUp(TestInformation testInfo) throws TargetSetupError {
+        Assert.assertNotEquals(
+                "Argument --webview-installer-tool must be used.", mWebviewInstallerTool, null);
+        Assert.assertNotEquals(
+                "Argument --gcloud-cli-zip must be used.", mGcloudCliZipArchive, null);
+        try {
+            RunUtil runUtil = mRunUtilProvider.get();
+            mGcloudCliDir = Files.createTempDirectory(null).toFile();
+            CommandResult unzipRes =
+                    runUtil.runTimedCmd(
+                            COMMAND_TIMEOUT_MILLIS,
+                            "unzip",
+                            mGcloudCliZipArchive.getAbsolutePath(),
+                            "-d",
+                            mGcloudCliDir.getAbsolutePath());
+
+            Assert.assertEquals(
+                    "Unable to unzip the gcloud cli zip archive",
+                    unzipRes.getStatus(),
+                    CommandStatus.SUCCESS);
+
+            // The 'gcloud init' command creates configuration files for gsutil and other
+            // applications that use the gcloud sdk in the home directory. We can isolate
+            // the effects of these configuration files to the processes that run the
+            // gcloud and gsutil executables tracked by this class by setting the home
+            // directory for processes that run those executables to a temporary directory
+            // also tracked by this class.
+            runUtil.setEnvVariable("HOME", mGcloudCliDir.getAbsolutePath());
+            File gcloudBin =
+                    mGcloudCliDir
+                            .toPath()
+                            .resolve(Paths.get("google-cloud-sdk", "bin", "gcloud"))
+                            .toFile();
+            String gcloudInitScript =
+                    String.format(
+                            "printf \"1\\n1\" | %s init --console-only",
+                            gcloudBin.getAbsolutePath());
+            CommandResult gcloudInitRes =
+                    runUtil.runTimedCmd(
+                            COMMAND_TIMEOUT_MILLIS,
+                            System.out,
+                            System.out,
+                            "sh",
+                            "-c",
+                            gcloudInitScript);
+            Assert.assertEquals(
+                    "gcloud cli initialization failed",
+                    gcloudInitRes.getStatus(),
+                    CommandStatus.SUCCESS);
+
+            CommandResult chmodRes =
+                    runUtil.runTimedCmd(
+                            COMMAND_TIMEOUT_MILLIS,
+                            System.out,
+                            System.out,
+                            "chmod",
+                            "755",
+                            "-v",
+                            mWebviewInstallerTool.getAbsolutePath());
+
+            Assert.assertEquals(
+                    "The 'chmod 755 -v <WebView installer tool>' command failed",
+                    chmodRes.getStatus(),
+                    CommandStatus.SUCCESS);
+
+        } catch (Exception ex) {
+            throw new TargetSetupError("Caught an exception during setup:\n" + ex);
+        }
+        setGcloudCliPath(testInfo, mGcloudCliDir);
+        setWebviewInstallerToolPath(testInfo, mWebviewInstallerTool);
+    }
+
+    @Override
+    public void tearDown(TestInformation testInfo, Throwable e) {
+        // Clean up some files.
+        mRunUtilProvider
+                .get()
+                .runTimedCmd(COMMAND_TIMEOUT_MILLIS, "rm", "-rf", mGcloudCliDir.getAbsolutePath());
+    }
+
+    interface RunUtilProvider {
+        RunUtil get();
+    }
+}
diff --git a/test_scripts/src/main/java/com/android/webview/lib/WebviewUtils.java b/test_scripts/src/main/java/com/android/webview/lib/WebviewUtils.java
new file mode 100644
index 0000000..2d47066
--- /dev/null
+++ b/test_scripts/src/main/java/com/android/webview/lib/WebviewUtils.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright (C) 2023 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.webview.tests;
+
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+
+import org.junit.Assert;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+public class WebviewUtils {
+    private TestInformation mTestInformation;
+
+    public WebviewUtils(TestInformation testInformation) {
+        mTestInformation = testInformation;
+    }
+
+    public WebviewPackage installWebview(String webviewVersion, String releaseChannel)
+            throws IOException, InterruptedException, DeviceNotAvailableException {
+        List<String> extraArgs = new ArrayList<>();
+        if (webviewVersion == null
+                && Arrays.asList("beta", "stable").contains(releaseChannel.toLowerCase())) {
+            // Get current version of WebView in the stable or beta release channels.
+            CLog.i(
+                    "Getting the latest nightly official release version of the %s branch",
+                    releaseChannel);
+            String releaseChannelVersion = getNightlyBranchBuildVersion(releaseChannel);
+            Assert.assertNotNull(
+                    String.format(
+                            "Could not retrieve the latest "
+                                    + "nightly release version of the %s channel",
+                            releaseChannel),
+                    releaseChannelVersion);
+            // Install the latest official build compiled for the beta or stable branches.
+            extraArgs.addAll(
+                    Arrays.asList("--milestone", releaseChannelVersion.split("\\.", 2)[0]));
+        }
+        CommandResult commandResult =
+                WebviewInstallerToolPreparer.runWebviewInstallerToolCommand(
+                        mTestInformation, webviewVersion, releaseChannel, extraArgs);
+
+        Assert.assertEquals(
+                "The WebView installer tool failed to install WebView:\n"
+                        + commandResult.toString(),
+                commandResult.getStatus(),
+                CommandStatus.SUCCESS);
+
+        printWebviewVersion();
+        return getCurrentWebviewPackage();
+    }
+
+    private static String getNightlyBranchBuildVersion(String releaseChannel)
+            throws IOException, MalformedURLException {
+        final URL omahaProxyUrl = new URL("https://omahaproxy.appspot.com/all?os=webview");
+        try (BufferedReader bufferedReader =
+                new BufferedReader(
+                        new InputStreamReader(omahaProxyUrl.openConnection().getInputStream()))) {
+            String csvLine = null;
+            while ((csvLine = bufferedReader.readLine()) != null) {
+                String[] csvLineValues = csvLine.split(",");
+                if (csvLineValues[1].toLowerCase().equals(releaseChannel.toLowerCase())) {
+                    return csvLineValues[2];
+                }
+            }
+        }
+        return null;
+    }
+
+    public void uninstallWebview(
+            WebviewPackage webviewPackage, WebviewPackage preInstalledWebviewPackage)
+            throws DeviceNotAvailableException {
+        Assert.assertNotEquals(
+                "Test is attempting to uninstall the preinstalled WebView provider",
+                webviewPackage,
+                preInstalledWebviewPackage);
+        updateWebviewImplementation(preInstalledWebviewPackage.getPackageName());
+        mTestInformation
+                .getDevice()
+                .executeAdbCommand("uninstall", webviewPackage.getPackageName());
+        printWebviewVersion();
+    }
+
+    private void updateWebviewImplementation(String webviewPackageName)
+            throws DeviceNotAvailableException {
+        CommandResult res =
+                mTestInformation
+                        .getDevice()
+                        .executeShellV2Command(
+                                String.format(
+                                        "cmd webviewupdate set-webview-implementation %s",
+                                        webviewPackageName));
+        Assert.assertEquals(
+                "Failed to set webview update: " + res, res.getStatus(), CommandStatus.SUCCESS);
+    }
+
+    public WebviewPackage getCurrentWebviewPackage() throws DeviceNotAvailableException {
+        String dumpsys = mTestInformation.getDevice().executeShellCommand("dumpsys webviewupdate");
+        return WebviewPackage.buildFromDumpsys(dumpsys);
+    }
+
+    public void printWebviewVersion() throws DeviceNotAvailableException {
+        WebviewPackage currentWebview = getCurrentWebviewPackage();
+        printWebviewVersion(currentWebview);
+    }
+
+    public void printWebviewVersion(WebviewPackage currentWebview)
+            throws DeviceNotAvailableException {
+        CLog.i("Current webview implementation: %s", currentWebview.getPackageName());
+        CLog.i("Current webview version: %s", currentWebview.getVersion());
+    }
+}
diff --git a/test_scripts/src/main/java/com/android/webview/tests/WebviewAppLaunchTest.java b/test_scripts/src/main/java/com/android/webview/tests/WebviewAppLaunchTest.java
index aba4a93..a8f72d1 100644
--- a/test_scripts/src/main/java/com/android/webview/tests/WebviewAppLaunchTest.java
+++ b/test_scripts/src/main/java/com/android/webview/tests/WebviewAppLaunchTest.java
@@ -23,14 +23,11 @@
 import com.android.csuite.core.DeviceUtils.DeviceUtilsException;
 import com.android.csuite.core.TestUtils;
 import com.android.tradefed.config.Option;
-import com.android.tradefed.config.Option.Importance;
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestLogData;
 import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
-import com.android.tradefed.util.CommandResult;
-import com.android.tradefed.util.CommandStatus;
 import com.android.tradefed.util.RunUtil;
 
 import org.junit.After;
@@ -40,17 +37,11 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
-import java.io.BufferedReader;
 import java.io.File;
 import java.io.IOException;
-import java.io.InputStreamReader;
-import java.net.MalformedURLException;
-import java.net.URL;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.List;
 
-import javax.annotation.Nullable;
 
 /** A test that verifies that a single app can be successfully launched. */
 @RunWith(DeviceJUnit4ClassRunner.class)
@@ -58,21 +49,13 @@
     @Rule public TestLogData mLogData = new TestLogData();
 
     private static final long COMMAND_TIMEOUT_MILLIS = 5 * 60 * 1000;
-
-    private ApkInstaller mApkInstaller;
-    private final List<WebviewPackage> mOrderedWebviews = new ArrayList<>();
+    private WebviewUtils mWebviewUtils;
     private WebviewPackage mPreInstalledWebview;
-    private WebviewPackage mCurrentWebview;
-    private GcloudCli mGcloudCli;
+    private ApkInstaller mApkInstaller;
 
     @Option(name = "record-screen", description = "Whether to record screen during test.")
     private boolean mRecordScreen;
 
-    @Option(
-            name = "webview-installer-tool",
-            description = "Path to the webview installer executable.")
-    private File mWebviewInstallerTool;
-
     @Option(name = "webview-version-to-test", description = "Version of Webview to test.")
     private String mWebviewVersionToTest;
 
@@ -101,44 +84,17 @@
             description = "Time to wait for an app to launch in msecs.")
     private int mAppLaunchTimeoutMs = 20000;
 
-    @Option(
-            name = "gcloud-cli-zip-archive",
-            description = "Path to the google cli zip archive.",
-            importance = Importance.ALWAYS)
-    private File mGcloudCliZipArchive;
-
     @Before
     public void setUp() throws DeviceNotAvailableException, ApkInstallerException, IOException {
-        mCurrentWebview = mPreInstalledWebview = getCurrentWebviewPackage();
-
         Assert.assertNotNull("Package name cannot be null", mPackageName);
         Assert.assertTrue(
                 "Either the --release-channel or --webview-version-to-test arguments "
                         + "must be used",
                 mWebviewVersionToTest != null || mReleaseChannel != null);
-        Assert.assertNotEquals(
-                "Argument --webview-installer-tool must be used when "
-                        + "using the --webview-version-to-test argument.",
-                mWebviewInstallerTool,
-                null);
-        Assert.assertNotEquals(
-                "Argument --gcloud-cli-zip must be used when "
-                        + "using the --webview-version-to-test argument.",
-                mGcloudCliZipArchive,
-                null);
-
-        mGcloudCli = GcloudCli.buildFromZipArchive(mGcloudCliZipArchive);
-        RunUtil.getDefault()
-                .runTimedCmd(
-                        COMMAND_TIMEOUT_MILLIS,
-                        System.out,
-                        System.out,
-                        "chmod",
-                        "755",
-                        "-v",
-                        mWebviewInstallerTool.getAbsolutePath());
 
         mApkInstaller = ApkInstaller.getInstance(getDevice());
+        mWebviewUtils = new WebviewUtils(getTestInformation());
+        mPreInstalledWebview = mWebviewUtils.getCurrentWebviewPackage();
 
         for (File apkPath : mApkPaths) {
             CLog.d("Installing " + apkPath);
@@ -146,8 +102,7 @@
         }
 
         DeviceUtils.getInstance(getDevice()).freezeRotation();
-
-        printWebviewVersion(mPreInstalledWebview);
+        mWebviewUtils.printWebviewVersion();
     }
 
     @Test
@@ -155,19 +110,15 @@
             throws DeviceNotAvailableException, InterruptedException, ApkInstallerException,
                     IOException {
         AssertionError lastError = null;
-        WebviewPackage lastWebviewInstalled;
-        if (mWebviewVersionToTest != null) {
-            lastWebviewInstalled = installVersionOfWebview(mWebviewVersionToTest, mReleaseChannel);
-        } else {
-            lastWebviewInstalled = installReleaseChannelVersionOfWebview(mReleaseChannel);
-        }
+        WebviewPackage lastWebviewInstalled =
+                mWebviewUtils.installWebview(mWebviewVersionToTest, mReleaseChannel);
 
         try {
             assertAppLaunchNoCrash();
         } catch (AssertionError e) {
             lastError = e;
         } finally {
-            uninstallWebview();
+            mWebviewUtils.uninstallWebview(lastWebviewInstalled, mPreInstalledWebview);
         }
 
         // If the app doesn't crash, complete the test.
@@ -203,131 +154,7 @@
         deviceUtils.unfreezeRotation();
 
         mApkInstaller.uninstallAllInstalledPackages();
-        printWebviewVersion();
-
-        mGcloudCli.tearDown();
-    }
-
-    private void printWebviewVersion(WebviewPackage currentWebview)
-            throws DeviceNotAvailableException {
-        CLog.i("Current webview implementation: %s", currentWebview.getPackageName());
-        CLog.i("Current webview version: %s", currentWebview.getVersion());
-    }
-
-    private void printWebviewVersion() throws DeviceNotAvailableException {
-        WebviewPackage currentWebview = getCurrentWebviewPackage();
-        printWebviewVersion(currentWebview);
-    }
-
-    private WebviewPackage installReleaseChannelVersionOfWebview(String releaseChannel)
-            throws IOException, InterruptedException, DeviceNotAvailableException {
-        List<String> commandLineArgs = new ArrayList<>(Arrays.asList("--channel", releaseChannel));
-        if (Arrays.asList("beta", "stable").contains(releaseChannel.toLowerCase())) {
-            // Get current version of WebView in the stable or beta release channels.
-            CLog.i(
-                    "Getting the latest nightly official release version of the %s branch",
-                    releaseChannel);
-            String webviewVersion = getNightlyBranchBuildVersion(releaseChannel);
-            Assert.assertNotNull(
-                    String.format(
-                            "Could not retrieve the latest "
-                                    + "nightly release version of the %s channel",
-                            releaseChannel),
-                    webviewVersion);
-            // Install the latest official build compiled for the beta or stable branches.
-            commandLineArgs.addAll(Arrays.asList("--milestone", webviewVersion.split("\\.", 2)[0]));
-        }
-        return installWebviewWithInstallerTool(commandLineArgs);
-    }
-
-    private String getNightlyBranchBuildVersion(String releaseChannel)
-            throws IOException, MalformedURLException {
-        final URL omahaProxyUrl = new URL("https://omahaproxy.appspot.com/all?os=webview");
-        try (BufferedReader bufferedReader =
-                new BufferedReader(
-                        new InputStreamReader(omahaProxyUrl.openConnection().getInputStream()))) {
-            String csvLine = null;
-            while ((csvLine = bufferedReader.readLine()) != null) {
-                String[] csvLineValues = csvLine.split(",");
-                if (csvLineValues[1].toLowerCase().equals(releaseChannel.toLowerCase())) {
-                    return csvLineValues[2];
-                }
-            }
-        }
-        return null;
-    }
-
-    private WebviewPackage installVersionOfWebview(
-            String webviewVersion, @Nullable String releaseChannel)
-            throws IOException, InterruptedException, DeviceNotAvailableException {
-        List<String> commandLineArgs = Arrays.asList("--chrome-version", webviewVersion);
-        if (releaseChannel != null) {
-            commandLineArgs.addAll(Arrays.asList("--channel", releaseChannel));
-        }
-        return installWebviewWithInstallerTool(commandLineArgs);
-    }
-
-    private WebviewPackage installWebviewWithInstallerTool(List<String> extraArgs)
-            throws IOException, InterruptedException, DeviceNotAvailableException {
-        // TODO(rmhasan): Remove the --non-next command line argument after
-        // crbug.com/1002673 is resolved.
-        List<String> fullCommandLineArgs =
-                new ArrayList<>(
-                        Arrays.asList(
-                                mWebviewInstallerTool.getAbsolutePath(),
-                                "--non-next",
-                                "--serial",
-                                getDevice().getSerialNumber(),
-                                "-vvv",
-                                "--gsutil",
-                                mGcloudCli.getGsutilExecutable().getAbsolutePath()));
-        fullCommandLineArgs.addAll(extraArgs);
-
-        CommandResult installWebViewRes =
-                mGcloudCli
-                        .getRunUtil()
-                        .runTimedCmd(
-                                COMMAND_TIMEOUT_MILLIS,
-                                System.out,
-                                System.out,
-                                fullCommandLineArgs.toArray(new String[0]));
-        Assert.assertEquals(
-                "The WebView installer tool failed to install WebView:\n"
-                        + installWebViewRes.toString(),
-                installWebViewRes.getStatus(),
-                CommandStatus.SUCCESS);
-
-        mCurrentWebview = getCurrentWebviewPackage();
-        printWebviewVersion(mCurrentWebview);
-        return mCurrentWebview;
-    }
-
-    private void uninstallWebview() throws DeviceNotAvailableException {
-        Assert.assertNotEquals(
-                "Test is attempting to uninstall the preinstalled WebView provider",
-                mCurrentWebview,
-                mPreInstalledWebview);
-        updateWebviewImplementation(mPreInstalledWebview.getPackageName());
-        getDevice().executeAdbCommand("uninstall", mCurrentWebview.getPackageName());
-        mCurrentWebview = mPreInstalledWebview;
-        printWebviewVersion(mCurrentWebview);
-    }
-
-    private void updateWebviewImplementation(String webviewPackageName)
-            throws DeviceNotAvailableException {
-        CommandResult res =
-                getDevice()
-                        .executeShellV2Command(
-                                String.format(
-                                        "cmd webviewupdate set-webview-implementation %s",
-                                        webviewPackageName));
-        Assert.assertEquals(
-                "Failed to set webview update: " + res, res.getStatus(), CommandStatus.SUCCESS);
-    }
-
-    private WebviewPackage getCurrentWebviewPackage() throws DeviceNotAvailableException {
-        String dumpsys = getDevice().executeShellCommand("dumpsys webviewupdate");
-        return WebviewPackage.buildFromDumpsys(dumpsys);
+        mWebviewUtils.printWebviewVersion();
     }
 
     private void assertAppLaunchNoCrash() throws DeviceNotAvailableException {
diff --git a/test_targets/pixel-app-launch-lock-recentapp/template.xml b/test_targets/pixel-app-launch-lock-recentapp/template.xml
index 718da9c..6f546a7 100644
--- a/test_targets/pixel-app-launch-lock-recentapp/template.xml
+++ b/test_targets/pixel-app-launch-lock-recentapp/template.xml
@@ -23,7 +23,6 @@
     <metrics_collector class="com.android.tradefed.device.metric.FilePullerLogCollector">
     	<!-- repeatable: The key of the DIRECTORY to pull -->
       	<option name = "directory-keys" value = "/sdcard/logData" />
-      	<option name="collect-on-run-ended-only" value="true" />
     </metrics_collector>
     <test class="com.android.tradefed.testtype.AndroidJUnitTest" >
         <option name="runner" value="androidx.test.runner.AndroidJUnitRunner" />
diff --git a/test_targets/webview-app-launch/Android.bp b/test_targets/webview-app-launch/Android.bp
index 3658018..8d54421 100644
--- a/test_targets/webview-app-launch/Android.bp
+++ b/test_targets/webview-app-launch/Android.bp
@@ -18,5 +18,6 @@
 
 csuite_test {
     name: "webview-app-launch",
+    test_plan_include: "plan.xml",
     test_config_template: "default.xml",
 }
diff --git a/test_targets/webview-app-launch/plan.xml b/test_targets/webview-app-launch/plan.xml
new file mode 100644
index 0000000..5d3c48f
--- /dev/null
+++ b/test_targets/webview-app-launch/plan.xml
@@ -0,0 +1,3 @@
+<configuration description="WebView C-Suite Crawler Test Plan">
+  <target_preparer class="com.android.webview.tests.WebviewInstallerToolPreparer"/>
+</configuration>