Merge "Add memory size to VM config."
diff --git a/authfs/tests/Android.bp b/authfs/tests/Android.bp
index bacb890..8061c56 100644
--- a/authfs/tests/Android.bp
+++ b/authfs/tests/Android.bp
@@ -10,6 +10,12 @@
         "compatibility-tradefed",
         "compatibility-host-util",
     ],
+    static_libs: [
+        "VirtualizationTestHelper",
+    ],
     test_suites: ["general-tests"],
-    data: [":authfs_test_files"],
+    data: [
+        ":authfs_test_files",
+        ":MicrodroidTestApp.signed",
+    ],
 }
diff --git a/authfs/tests/AndroidTest.xml b/authfs/tests/AndroidTest.xml
index 485e392..8f940f6 100644
--- a/authfs/tests/AndroidTest.xml
+++ b/authfs/tests/AndroidTest.xml
@@ -15,10 +15,13 @@
 -->
 
 <configuration description="Config for authfs tests">
-    <!-- Since Android does not support user namespace, we need root to access /dev/fuse and also
-         to set up the mount. -->
+    <!-- Need root to start virtualizationservice -->
     <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer"/>
 
+    <!-- virtualizationservice doesn't have access to shell_data_file. Instead of giving it
+          a test-only permission, run it without selinux -->
+    <target_preparer class="com.android.tradefed.targetprep.DisableSELinuxTargetPreparer"/>
+
     <!-- Basic checks that the device has all the prerequisites. -->
     <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer">
         <option name="throw-if-cmd-fail" value="true" />
diff --git a/authfs/tests/java/src/com/android/fs/AuthFsHostTest.java b/authfs/tests/java/src/com/android/fs/AuthFsHostTest.java
index 43d1210..426b333 100644
--- a/authfs/tests/java/src/com/android/fs/AuthFsHostTest.java
+++ b/authfs/tests/java/src/com/android/fs/AuthFsHostTest.java
@@ -18,19 +18,16 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
-import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertTrue;
 
 import android.platform.test.annotations.RootPermissionTest;
+import android.virt.test.VirtualizationTestCaseBase;
 
 import com.android.compatibility.common.util.PollingCheck;
 import com.android.tradefed.device.DeviceNotAvailableException;
-import com.android.tradefed.device.ITestDevice;
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
-import com.android.tradefed.testtype.junit4.BaseHostJUnit4Test;
 import com.android.tradefed.util.CommandResult;
-import com.android.tradefed.util.CommandStatus;
 
 import org.junit.After;
 import org.junit.Before;
@@ -40,18 +37,22 @@
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
+// TODO move to Virtualization/tests/hostside/
 @RootPermissionTest
 @RunWith(DeviceJUnit4ClassRunner.class)
-public final class AuthFsHostTest extends BaseHostJUnit4Test {
+public final class AuthFsHostTest extends VirtualizationTestCaseBase {
 
-    /** Test directory where data are located */
+    /** Test directory on Android where data are located */
     private static final String TEST_DIR = "/data/local/tmp/authfs";
 
-    /** Mount point of authfs during the test */
-    private static final String MOUNT_DIR = "/data/local/tmp/authfs/mnt";
+    /** Mount point of authfs on Microdroid during the test */
+    private static final String MOUNT_DIR = "/data/local/tmp";
 
+    /** Path to fd_server on Android */
     private static final String FD_SERVER_BIN = "/apex/com.android.virt/bin/fd_server";
-    private static final String AUTHFS_BIN = "/apex/com.android.virt/bin/authfs";
+
+    /** Path to authfs on Microdroid */
+    private static final String AUTHFS_BIN = "/system/bin/authfs";
 
     /** Plenty of time for authfs to get ready */
     private static final int AUTHFS_INIT_TIMEOUT_MS = 1500;
@@ -59,68 +60,65 @@
     /** FUSE's magic from statfs(2) */
     private static final String FUSE_SUPER_MAGIC_HEX = "65735546";
 
-    private ITestDevice mDevice;
     private ExecutorService mThreadPool;
+    private String mCid;
 
     @Before
-    public void setUp() {
-        mDevice = getDevice();
+    public void setUp() throws DeviceNotAvailableException {
+        testIfDeviceIsCapable();
+
+        cleanUpTestFiles();
+
+        prepareVirtualizationTestSetup();
+
         mThreadPool = Executors.newCachedThreadPool();
+
+        // For each test case, boot and adb connect to a new Microdroid
+        final String apkName = "MicrodroidTestApp.apk";
+        final String packageName = "com.android.microdroid.test";
+        final String configPath = "assets/vm_config.json"; // path inside the APK
+        mCid = startMicrodroid(apkName, packageName, configPath, /* debug */ false);
+        adbConnectToMicrodroid(mCid);
+
+        // Root because authfs (started from shell in this test) currently require root to open
+        // /dev/fuse and mount the FUSE.
+        rootMicrodroid();
     }
 
     @After
     public void tearDown() throws DeviceNotAvailableException {
-        mDevice.executeShellV2Command("killall authfs fd_server");
-        mDevice.executeShellV2Command("umount " + MOUNT_DIR);
-        mDevice.executeShellV2Command("rm -f " + TEST_DIR + "/output");
+        if (mCid != null) {
+            shutdownMicrodroid(mCid);
+            mCid = null;
+        }
+
+        tryRunOnAndroid("killall fd_server");
+        cleanUpTestFiles();
+        cleanUpVirtualizationTestSetup();
     }
 
-    @Test
-    public void testReadWithFsverityVerification_LocalFile()
-            throws DeviceNotAvailableException, InterruptedException {
-        // Setup
-        runAuthFsInBackground(
-                "--local-ro-file-unverified 3:input.4m"
-                + " --local-ro-file 4:input.4m:input.4m.merkle_dump:input.4m.fsv_sig:cert.der"
-                + " --local-ro-file 5:input.4k1:input.4k1.merkle_dump:input.4k1.fsv_sig:cert.der"
-                + " --local-ro-file 6:input.4k:input.4k.merkle_dump:input.4k.fsv_sig:cert.der"
-        );
-
-        // Action
-        String actualHashUnverified4m = computeFileHashInGuest(MOUNT_DIR + "/3");
-        String actualHash4m = computeFileHashInGuest(MOUNT_DIR + "/4");
-        String actualHash4k1 = computeFileHashInGuest(MOUNT_DIR + "/5");
-        String actualHash4k = computeFileHashInGuest(MOUNT_DIR + "/6");
-
-        // Verify
-        String expectedHash4m = computeFileHash(TEST_DIR + "/input.4m");
-        String expectedHash4k1 = computeFileHash(TEST_DIR + "/input.4k1");
-        String expectedHash4k = computeFileHash(TEST_DIR + "/input.4k");
-
-        assertEquals("Inconsistent hash from /authfs/3: ", expectedHash4m, actualHashUnverified4m);
-        assertEquals("Inconsistent hash from /authfs/4: ", expectedHash4m, actualHash4m);
-        assertEquals("Inconsistent hash from /authfs/5: ", expectedHash4k1, actualHash4k1);
-        assertEquals("Inconsistent hash from /authfs/6: ", expectedHash4k, actualHash4k);
+    private void cleanUpTestFiles() throws DeviceNotAvailableException {
+        tryRunOnAndroid("rm -f " + TEST_DIR + "/output");
     }
 
     @Test
     public void testReadWithFsverityVerification_RemoteFile()
             throws DeviceNotAvailableException, InterruptedException {
         // Setup
-        runFdServerInBackground(
+        runFdServerOnAndroid(
                 "3<input.4m 4<input.4m.merkle_dump 5<input.4m.fsv_sig 6<input.4m",
-                "--ro-fds 3:4:5 --ro-fds 6"
-        );
-        runAuthFsInBackground(
+                "--ro-fds 3:4:5 --ro-fds 6 --rpc-binder");
+
+        runAuthFsOnMicrodroid(
                 "--remote-ro-file-unverified 10:6:4194304 --remote-ro-file 11:3:4194304:cert.der"
-        );
+                        + " --cid 2");
 
         // Action
-        String actualHashUnverified4m = computeFileHashInGuest(MOUNT_DIR + "/10");
-        String actualHash4m = computeFileHashInGuest(MOUNT_DIR + "/11");
+        String actualHashUnverified4m = computeFileHashOnMicrodroid(MOUNT_DIR + "/10");
+        String actualHash4m = computeFileHashOnMicrodroid(MOUNT_DIR + "/11");
 
         // Verify
-        String expectedHash4m = computeFileHash(TEST_DIR + "/input.4m");
+        String expectedHash4m = computeFileHashOnAndroid(TEST_DIR + "/input.4m");
 
         assertEquals("Inconsistent hash from /authfs/10: ", expectedHash4m, actualHashUnverified4m);
         assertEquals("Inconsistent hash from /authfs/11: ", expectedHash4m, actualHash4m);
@@ -132,22 +130,20 @@
     public void testReadWithFsverityVerification_RemoteSmallerFile()
             throws DeviceNotAvailableException, InterruptedException {
         // Setup
-        runFdServerInBackground(
+        runFdServerOnAndroid(
                 "3<input.4k 4<input.4k.merkle_dump 5<input.4k.fsv_sig"
-                + " 6<input.4k1 7<input.4k1.merkle_dump 8<input.4k1.fsv_sig",
-                "--ro-fds 3:4:5 --ro-fds 6:7:8"
-        );
-        runAuthFsInBackground(
-                "--remote-ro-file 10:3:4096:cert.der --remote-ro-file 11:6:4097:cert.der"
-        );
+                        + " 6<input.4k1 7<input.4k1.merkle_dump 8<input.4k1.fsv_sig",
+                "--ro-fds 3:4:5 --ro-fds 6:7:8 --rpc-binder");
+        runAuthFsOnMicrodroid(
+                "--remote-ro-file 10:3:4096:cert.der --remote-ro-file 11:6:4097:cert.der --cid 2");
 
         // Action
-        String actualHash4k = computeFileHashInGuest(MOUNT_DIR + "/10");
-        String actualHash4k1 = computeFileHashInGuest(MOUNT_DIR + "/11");
+        String actualHash4k = computeFileHashOnMicrodroid(MOUNT_DIR + "/10");
+        String actualHash4k1 = computeFileHashOnMicrodroid(MOUNT_DIR + "/11");
 
         // Verify
-        String expectedHash4k = computeFileHash(TEST_DIR + "/input.4k");
-        String expectedHash4k1 = computeFileHash(TEST_DIR + "/input.4k1");
+        String expectedHash4k = computeFileHashOnAndroid(TEST_DIR + "/input.4k");
+        String expectedHash4k1 = computeFileHashOnAndroid(TEST_DIR + "/input.4k1");
 
         assertEquals("Inconsistent hash from /authfs/10: ", expectedHash4k, actualHash4k);
         assertEquals("Inconsistent hash from /authfs/11: ", expectedHash4k1, actualHash4k1);
@@ -157,31 +153,30 @@
     public void testReadWithFsverityVerification_TamperedMerkleTree()
             throws DeviceNotAvailableException, InterruptedException {
         // Setup
-        runFdServerInBackground(
+        runFdServerOnAndroid(
                 "3<input.4m 4<input.4m.merkle_dump.bad 5<input.4m.fsv_sig",
-                "--ro-fds 3:4:5"
-        );
-        runAuthFsInBackground("--remote-ro-file 10:3:4096:cert.der");
+                "--ro-fds 3:4:5 --rpc-binder");
+        runAuthFsOnMicrodroid("--remote-ro-file 10:3:4096:cert.der --cid 2");
 
         // Verify
-        assertFalse(copyFileInGuest(MOUNT_DIR + "/10", "/dev/null"));
+        assertFalse(copyFileOnMicrodroid(MOUNT_DIR + "/10", "/dev/null"));
     }
 
     @Test
     public void testWriteThroughCorrectly()
             throws DeviceNotAvailableException, InterruptedException {
         // Setup
-        runFdServerInBackground("3<>output", "--rw-fds 3");
-        runAuthFsInBackground("--remote-new-rw-file 20:3");
+        runFdServerOnAndroid("3<>output", "--rw-fds 3 --rpc-binder");
+        runAuthFsOnMicrodroid("--remote-new-rw-file 20:3 --cid 2");
 
         // Action
-        String srcPath = "/system/bin/linker";
+        String srcPath = "/system/bin/linker64";
         String destPath = MOUNT_DIR + "/20";
         String backendPath = TEST_DIR + "/output";
-        assertTrue(copyFileInGuest(srcPath, destPath));
+        assertTrue(copyFileOnMicrodroid(srcPath, destPath));
 
         // Verify
-        String expectedHash = computeFileHashInGuest(srcPath);
+        String expectedHash = computeFileHashOnMicrodroid(srcPath);
         expectBackingFileConsistency(destPath, backendPath, expectedHash);
     }
 
@@ -189,60 +184,59 @@
     public void testWriteFailedIfDetectsTampering()
             throws DeviceNotAvailableException, InterruptedException {
         // Setup
-        runFdServerInBackground("3<>output", "--rw-fds 3");
-        runAuthFsInBackground("--remote-new-rw-file 20:3");
+        runFdServerOnAndroid("3<>output", "--rw-fds 3 --rpc-binder");
+        runAuthFsOnMicrodroid("--remote-new-rw-file 20:3 --cid 2");
 
-        String srcPath = "/system/bin/linker";
+        String srcPath = "/system/bin/linker64";
         String destPath = MOUNT_DIR + "/20";
         String backendPath = TEST_DIR + "/output";
-        assertTrue(copyFileInGuest(srcPath, destPath));
+        assertTrue(copyFileOnMicrodroid(srcPath, destPath));
 
         // Action
         // Tampering with the first 2 4K block of the backing file.
-        expectRemoteCommandToSucceed("dd if=/dev/zero of=" + backendPath + " bs=1 count=8192");
+        runOnAndroid("dd if=/dev/zero of=" + backendPath + " bs=1 count=8192");
 
         // Verify
         // Write to a block partially requires a read back to calculate the new hash. It should fail
         // when the content is inconsistent to the known hash. Use direct I/O to avoid simply
         // writing to the filesystem cache.
-        expectRemoteCommandToFail("dd if=/dev/zero of=" + destPath + " bs=1 count=1024 direct");
+        assertEquals(
+                tryRunOnMicrodroid("dd if=/dev/zero of=" + destPath + " bs=1 count=1024 direct"),
+                null);
 
         // A full 4K write does not require to read back, so write can succeed even if the backing
         // block has already been tampered.
-        expectRemoteCommandToSucceed(
-                "dd if=/dev/zero of=" + destPath + " bs=1 count=4096 skip=4096");
+        runOnMicrodroid("dd if=/dev/zero of=" + destPath + " bs=1 count=4096 skip=4096");
 
         // Otherwise, a partial write with correct backing file should still succeed.
-        expectRemoteCommandToSucceed(
-                "dd if=/dev/zero of=" + destPath + " bs=1 count=1024 skip=8192");
+        runOnMicrodroid("dd if=/dev/zero of=" + destPath + " bs=1 count=1024 skip=8192");
     }
 
     @Test
     public void testFileResize() throws DeviceNotAvailableException, InterruptedException {
         // Setup
-        runFdServerInBackground("3<>output", "--rw-fds 3");
-        runAuthFsInBackground("--remote-new-rw-file 20:3");
+        runFdServerOnAndroid("3<>output", "--rw-fds 3 --rpc-binder");
+        runAuthFsOnMicrodroid("--remote-new-rw-file 20:3 --cid 2");
         String outputPath = MOUNT_DIR + "/20";
         String backendPath = TEST_DIR + "/output";
 
         // Action & Verify
-        expectRemoteCommandToSucceed(
-                "yes $'\\x01' | tr -d '\\n' | dd bs=1 count=10000 of=" + outputPath);
-        assertEquals(getFileSizeInBytes(outputPath), 10000);
+        runOnMicrodroid("yes $'\\x01' | tr -d '\\n' | dd bs=1 count=10000 of=" + outputPath);
+        assertEquals(getFileSizeInBytesOnMicrodroid(outputPath), 10000);
         expectBackingFileConsistency(
                 outputPath,
                 backendPath,
                 "684ad25fdc2bbb80cbc910dd1bde6d5499ccf860ca6ee44704b77ec445271353");
 
-        resizeFile(outputPath, 15000);
-        assertEquals(getFileSizeInBytes(outputPath), 15000);
+        resizeFileOnMicrodroid(outputPath, 15000);
+        assertEquals(getFileSizeInBytesOnMicrodroid(outputPath), 15000);
         expectBackingFileConsistency(
                 outputPath,
                 backendPath,
                 "567c89f62586e0d33369157afdfe99a2fa36cdffb01e91dcdc0b7355262d610d");
 
-        resizeFile(outputPath, 5000);
-        assertEquals(getFileSizeInBytes(outputPath), 5000);
+        resizeFileOnMicrodroid(outputPath, 5000);
+        assertEquals(getFileSizeInBytesOnMicrodroid(outputPath), 5000);
         expectBackingFileConsistency(
                 outputPath,
                 backendPath,
@@ -252,30 +246,16 @@
     private void expectBackingFileConsistency(
             String authFsPath, String backendPath, String expectedHash)
             throws DeviceNotAvailableException {
-        String hashOnAuthFs = computeFileHashInGuest(authFsPath);
+        String hashOnAuthFs = computeFileHashOnMicrodroid(authFsPath);
         assertEquals("File hash is different to expectation", expectedHash, hashOnAuthFs);
 
-        String hashOfBackingFile = computeFileHash(backendPath);
+        String hashOfBackingFile = computeFileHashOnAndroid(backendPath);
         assertEquals(
                 "Inconsistent file hash on the backend storage", hashOnAuthFs, hashOfBackingFile);
     }
 
-    // TODO(b/178874539): This does not really run in the guest VM.  Send the shell command to the
-    // guest VM when authfs works across VM boundary.
-    private String computeFileHashInGuest(String path) throws DeviceNotAvailableException {
-        return computeFileHash(path);
-    }
-
-    private boolean copyFileInGuest(String src, String dest) throws DeviceNotAvailableException {
-        // TODO(b/182576497): cp returns error because close(2) returns ENOSYS in the current authfs
-        // implementation. We should probably fix that since programs can expect close(2) return 0.
-        String cmd = "cat " + src + " > " + dest;
-        CommandResult result = mDevice.executeShellV2Command(cmd);
-        return result.getStatus() == CommandStatus.SUCCESS;
-    }
-
-    private String computeFileHash(String path) throws DeviceNotAvailableException {
-        String result = expectRemoteCommandToSucceed("sha256sum " + path);
+    private String computeFileHashOnMicrodroid(String path) {
+        String result = runOnMicrodroid("sha256sum " + path);
         String[] tokens = result.split("\\s");
         if (tokens.length > 0) {
             return tokens[0];
@@ -285,18 +265,46 @@
         }
     }
 
-    private void resizeFile(String path, long size) throws DeviceNotAvailableException {
-        expectRemoteCommandToSucceed("truncate -c -s " + size + " " + path);
+    private boolean copyFileOnMicrodroid(String src, String dest)
+            throws DeviceNotAvailableException {
+        // TODO(b/182576497): cp returns error because close(2) returns ENOSYS in the current authfs
+        // implementation. We should probably fix that since programs can expect close(2) return 0.
+        String cmd = "cat " + src + " > " + dest;
+        return tryRunOnMicrodroid(cmd) != null;
     }
 
-    private long getFileSizeInBytes(String path) throws DeviceNotAvailableException {
-        return Long.parseLong(expectRemoteCommandToSucceed("stat -c '%s' " + path));
-    }
-
-    private void throwDowncastedException(Exception e) throws DeviceNotAvailableException {
-        if (e instanceof DeviceNotAvailableException) {
-            throw (DeviceNotAvailableException) e;
+    private String computeFileHashOnAndroid(String path) throws DeviceNotAvailableException {
+        String result = runOnAndroid("sha256sum " + path);
+        String[] tokens = result.split("\\s");
+        if (tokens.length > 0) {
+            return tokens[0];
         } else {
+            CLog.e("Unrecognized output by sha256sum: " + result);
+            return "";
+        }
+    }
+
+    private void resizeFileOnMicrodroid(String path, long size) {
+        runOnMicrodroid("truncate -c -s " + size + " " + path);
+    }
+
+    private long getFileSizeInBytesOnMicrodroid(String path) {
+        return Long.parseLong(runOnMicrodroid("stat -c '%s' " + path));
+    }
+
+    private void runAuthFsOnMicrodroid(String flags) {
+        String cmd = AUTHFS_BIN + " " + MOUNT_DIR + " " + flags;
+
+        mThreadPool.submit(
+                () -> {
+                    CLog.i("Starting authfs");
+                    CommandResult result = runOnMicrodroidForResult(cmd);
+                    CLog.w("authfs has stopped: " + result);
+                });
+        try {
+            PollingCheck.waitFor(
+                    AUTHFS_INIT_TIMEOUT_MS, () -> isMicrodroidDirectoryOnFuse(MOUNT_DIR));
+        } catch (Exception e) {
             // Convert the broad Exception into an unchecked exception to avoid polluting all other
             // methods. waitFor throws Exception because the callback, Callable#call(), has a
             // signature to throw an Exception.
@@ -304,56 +312,25 @@
         }
     }
 
-    private void runAuthFsInBackground(String flags) throws DeviceNotAvailableException {
-        String cmd = "cd " + TEST_DIR + " && " + AUTHFS_BIN + " " + MOUNT_DIR + " " + flags;
-
-        mThreadPool.submit(() -> {
-            try {
-                CLog.i("Starting authfs");
-                expectRemoteCommandToSucceed(cmd);
-            } catch (DeviceNotAvailableException e) {
-                CLog.e("Error running authfs", e);
-                throw new RuntimeException(e);
-            }
-        });
-        try {
-            PollingCheck.waitFor(AUTHFS_INIT_TIMEOUT_MS, () -> isRemoteDirectoryOnFuse(MOUNT_DIR));
-        } catch (Exception e) {
-            throwDowncastedException(e);
-        }
-    }
-
-    private void runFdServerInBackground(String execParamsForOpeningFds, String flags)
+    private void runFdServerOnAndroid(String execParamsForOpeningFds, String flags)
             throws DeviceNotAvailableException {
         String cmd = "cd " + TEST_DIR + " && exec " + execParamsForOpeningFds + " " + FD_SERVER_BIN
                 + " " + flags;
-        mThreadPool.submit(() -> {
-            try {
-                CLog.i("Starting fd_server");
-                expectRemoteCommandToSucceed(cmd);
-            } catch (DeviceNotAvailableException e) {
-                CLog.e("Error running fd_server", e);
-                throw new RuntimeException(e);
-            }
-        });
+        mThreadPool.submit(
+                () -> {
+                    try {
+                        CLog.i("Starting fd_server");
+                        CommandResult result = getDevice().executeShellV2Command(cmd);
+                        CLog.w("fd_server has stopped: " + result);
+                    } catch (DeviceNotAvailableException e) {
+                        CLog.e("Error running fd_server", e);
+                        throw new RuntimeException(e);
+                    }
+                });
     }
 
-    private boolean isRemoteDirectoryOnFuse(String path) throws DeviceNotAvailableException {
-        String fs_type = expectRemoteCommandToSucceed("stat -f -c '%t' " + path);
+    private boolean isMicrodroidDirectoryOnFuse(String path) {
+        String fs_type = tryRunOnMicrodroid("stat -f -c '%t' " + path);
         return FUSE_SUPER_MAGIC_HEX.equals(fs_type);
     }
-
-    private String expectRemoteCommandToSucceed(String cmd) throws DeviceNotAvailableException {
-        CommandResult result = mDevice.executeShellV2Command(cmd);
-        assertEquals("`" + cmd + "` failed: " + result.getStderr(), CommandStatus.SUCCESS,
-                result.getStatus());
-        CLog.d("Stdout: " + result.getStdout());
-        return result.getStdout().trim();
-    }
-
-    private void expectRemoteCommandToFail(String cmd) throws DeviceNotAvailableException {
-        CommandResult result = mDevice.executeShellV2Command(cmd);
-        assertNotEquals("Unexpected success from `" + cmd + "`: " + result.getStdout(),
-                result.getStatus(), CommandStatus.SUCCESS);
-    }
 }
diff --git a/demo/Android.bp b/demo/Android.bp
new file mode 100644
index 0000000..77049de
--- /dev/null
+++ b/demo/Android.bp
@@ -0,0 +1,21 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+    name: "MicrodroidDemoApp",
+    srcs: ["java/**/*.java"],
+    resource_dirs: ["res"],
+    static_libs: [
+        "androidx-constraintlayout_constraintlayout",
+        "androidx.appcompat_appcompat",
+        "com.google.android.material_material",
+    ],
+    libs: [
+        "android.system.virtualmachine",
+    ],
+    jni_libs: ["MicrodroidTestNativeLib"],
+    platform_apis: true,
+    use_embedded_native_libs: true,
+    v4_signature: true,
+}
diff --git a/demo/AndroidManifest.xml b/demo/AndroidManifest.xml
new file mode 100644
index 0000000..ae4f734
--- /dev/null
+++ b/demo/AndroidManifest.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.microdroid.demo">
+
+    <application
+        android:label="MicrodroidDemo"
+        android:theme="@style/Theme.MicrodroidDemo">
+        <uses-library android:name="android.system.virtualmachine" android:required="true" />
+        <activity android:name=".MainActivity" android:exported="true">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+    </application>
+
+</manifest>
diff --git a/demo/assets/vm_config.json b/demo/assets/vm_config.json
new file mode 100644
index 0000000..b814394
--- /dev/null
+++ b/demo/assets/vm_config.json
@@ -0,0 +1,13 @@
+{
+  "os": {
+    "name": "microdroid"
+  },
+  "task": {
+    "type": "microdroid_launcher",
+    "command": "MicrodroidTestNativeLib.so",
+    "args": [
+      "hello",
+      "microdroid"
+    ]
+  }
+}
diff --git a/demo/java/com/android/microdroid/demo/MainActivity.java b/demo/java/com/android/microdroid/demo/MainActivity.java
new file mode 100644
index 0000000..6373b55
--- /dev/null
+++ b/demo/java/com/android/microdroid/demo/MainActivity.java
@@ -0,0 +1,172 @@
+/*
+ * Copyright (C) 2021 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.microdroid.demo;
+
+import android.app.Application;
+import android.os.Bundle;
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineException;
+import android.system.virtualmachine.VirtualMachineManager;
+import android.view.View;
+import android.widget.Button;
+import android.widget.CheckBox;
+import android.widget.ScrollView;
+import android.widget.TextView;
+
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.ViewModelProvider;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * This app is to demonstrate the use of APIs in the android.system.virtualmachine library.
+ * Currently, this app starts a virtual machine running Microdroid and shows the console output from
+ * the virtual machine to the UI.
+ */
+public class MainActivity extends AppCompatActivity {
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+        TextView consoleView = (TextView) findViewById(R.id.consoleOutput);
+        Button runStopButton = (Button) findViewById(R.id.runStopButton);
+        ScrollView scrollView = (ScrollView) findViewById(R.id.scrollview);
+
+        // When the console model is updated, append the new line to the text view.
+        VirtualMachineModel model = new ViewModelProvider(this).get(VirtualMachineModel.class);
+        model.getConsoleOutput()
+                .observeForever(
+                        new Observer<String>() {
+                            @Override
+                            public void onChanged(String line) {
+                                consoleView.append(line + "\n");
+                                scrollView.fullScroll(View.FOCUS_DOWN);
+                            }
+                        });
+
+        // When the VM status is updated, change the label of the button
+        model.getStatus()
+                .observeForever(
+                        new Observer<VirtualMachine.Status>() {
+                            @Override
+                            public void onChanged(VirtualMachine.Status status) {
+                                if (status == VirtualMachine.Status.RUNNING) {
+                                    runStopButton.setText("Stop");
+                                } else {
+                                    runStopButton.setText("Run");
+                                    consoleView.setText("");
+                                }
+                            }
+                        });
+
+        // When the button is clicked, run or stop the VM
+        runStopButton.setOnClickListener(
+                new View.OnClickListener() {
+                    public void onClick(View v) {
+                        if (model.getStatus().getValue() == VirtualMachine.Status.RUNNING) {
+                            model.stop();
+                        } else {
+                            CheckBox debugModeCheckBox = (CheckBox) findViewById(R.id.debugMode);
+                            final boolean debug = debugModeCheckBox.isChecked();
+                            model.run(debug);
+                        }
+                    }
+                });
+    }
+
+    /** Models a virtual machine and console output from it. */
+    public static class VirtualMachineModel extends AndroidViewModel {
+        private VirtualMachine mVirtualMachine;
+        private final MutableLiveData<String> mConsoleOutput = new MutableLiveData<>();
+        private final MutableLiveData<VirtualMachine.Status> mStatus = new MutableLiveData<>();
+
+        public VirtualMachineModel(Application app) {
+            super(app);
+            mStatus.setValue(VirtualMachine.Status.DELETED);
+        }
+
+        /** Runs a VM */
+        public void run(boolean debug) {
+            // Create a VM and run it.
+            // TODO(jiyong): remove the call to idsigPath
+            try {
+                VirtualMachineConfig.Builder builder =
+                        new VirtualMachineConfig.Builder(getApplication(), "assets/vm_config.json")
+                                .idsigPath("/data/local/tmp/virt/MicrodroidDemoApp.apk.idsig")
+                                .debugMode(debug);
+                VirtualMachineConfig config = builder.build();
+                VirtualMachineManager vmm = VirtualMachineManager.getInstance(getApplication());
+                mVirtualMachine = vmm.create("demo_vm", config);
+                mVirtualMachine.run();
+                mStatus.postValue(mVirtualMachine.getStatus());
+            } catch (VirtualMachineException e) {
+                throw new RuntimeException(e);
+            }
+
+            // Read console output from the VM in the background
+            ExecutorService executorService = Executors.newFixedThreadPool(1);
+            executorService.execute(
+                    new Runnable() {
+                        @Override
+                        public void run() {
+                            try {
+                                BufferedReader reader =
+                                        new BufferedReader(
+                                                new InputStreamReader(
+                                                        mVirtualMachine.getConsoleOutputStream()));
+                                while (true) {
+                                    String line = reader.readLine();
+                                    mConsoleOutput.postValue(line);
+                                }
+                            } catch (IOException | VirtualMachineException e) {
+                                // Consume
+                            }
+                        }
+                    });
+        }
+
+        /** Stops the running VM */
+        public void stop() {
+            try {
+                mVirtualMachine.stop();
+            } catch (VirtualMachineException e) {
+                // Consume
+            }
+            mVirtualMachine = null;
+            mStatus.postValue(VirtualMachine.Status.STOPPED);
+        }
+
+        /** Returns the console output from the VM */
+        public LiveData<String> getConsoleOutput() {
+            return mConsoleOutput;
+        }
+
+        /** Returns the status of the VM */
+        public LiveData<VirtualMachine.Status> getStatus() {
+            return mStatus;
+        }
+    }
+}
diff --git a/demo/res/layout/activity_main.xml b/demo/res/layout/activity_main.xml
new file mode 100644
index 0000000..cd30f35
--- /dev/null
+++ b/demo/res/layout/activity_main.xml
@@ -0,0 +1,51 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:background="#FFC107"
+    android:scrollbars="horizontal|vertical"
+    android:textAlignment="textStart"
+    tools:context=".MainActivity">
+
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="vertical">
+
+        <LinearLayout
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <Button
+                android:id="@+id/runStopButton"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:text="Run" />
+
+            <CheckBox
+                android:id="@+id/debugMode"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:layout_weight="1"
+                android:text="Debug mode" />
+        </LinearLayout>
+
+        <ScrollView
+            android:id="@+id/scrollview"
+            android:layout_width="match_parent"
+            android:layout_height="match_parent">
+
+            <TextView
+                android:id="@+id/consoleOutput"
+                android:layout_width="match_parent"
+                android:layout_height="wrap_content"
+                android:background="#FFEB3B"
+                android:fontFamily="monospace"
+                android:textColor="#000000" />
+        </ScrollView>
+    </LinearLayout>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/demo/res/values/colors.xml b/demo/res/values/colors.xml
new file mode 100644
index 0000000..f8c6127
--- /dev/null
+++ b/demo/res/values/colors.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="purple_200">#FFBB86FC</color>
+    <color name="purple_500">#FF6200EE</color>
+    <color name="purple_700">#FF3700B3</color>
+    <color name="teal_200">#FF03DAC5</color>
+    <color name="teal_700">#FF018786</color>
+    <color name="black">#FF000000</color>
+    <color name="white">#FFFFFFFF</color>
+</resources>
\ No newline at end of file
diff --git a/demo/res/values/themes.xml b/demo/res/values/themes.xml
new file mode 100644
index 0000000..16b8ab3
--- /dev/null
+++ b/demo/res/values/themes.xml
@@ -0,0 +1,16 @@
+<resources xmlns:tools="http://schemas.android.com/tools">
+    <!-- Base application theme. -->
+    <style name="Theme.MicrodroidDemo" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
+        <!-- Primary brand color. -->
+        <item name="colorPrimary">@color/purple_500</item>
+        <item name="colorPrimaryVariant">@color/purple_700</item>
+        <item name="colorOnPrimary">@color/white</item>
+        <!-- Secondary brand color. -->
+        <item name="colorSecondary">@color/teal_200</item>
+        <item name="colorSecondaryVariant">@color/teal_700</item>
+        <item name="colorOnSecondary">@color/black</item>
+        <!-- Status bar color. -->
+        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
+        <!-- Customize your theme here. -->
+    </style>
+</resources>
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachine.java b/javalib/src/android/system/virtualmachine/VirtualMachine.java
index 504bc03..8089d85 100644
--- a/javalib/src/android/system/virtualmachine/VirtualMachine.java
+++ b/javalib/src/android/system/virtualmachine/VirtualMachine.java
@@ -16,5 +16,267 @@
 
 package android.system.virtualmachine;
 
-/** @hide */
-public class VirtualMachine {}
+import android.content.Context;
+import android.os.ParcelFileDescriptor;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.system.virtualizationservice.IVirtualMachine;
+import android.system.virtualizationservice.IVirtualizationService;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.file.Files;
+import java.util.Optional;
+
+/**
+ * A handle to the virtual machine. The virtual machine is local to the app which created the
+ * virtual machine.
+ *
+ * @hide
+ */
+public class VirtualMachine {
+    /** Name of the directory under the files directory where all VMs created for the app exist. */
+    private static final String VM_DIR = "vm";
+
+    /** Name of the persisted config file for a VM. */
+    private static final String CONFIG_FILE = "config.xml";
+
+    /** Name of the instance image file for a VM. (Not implemented) */
+    private static final String INSTANCE_IMAGE_FILE = "instance.img";
+
+    /** Name of the virtualization service. */
+    private static final String SERVICE_NAME = "android.system.virtualizationservice";
+
+    /** Status of a virtual machine */
+    public enum Status {
+        /** The virtual machine has just been created, or {@link #stop()} was called on it. */
+        STOPPED,
+        /** The virtual machine is running. */
+        RUNNING,
+        /**
+         * The virtual machine is deleted. This is a irreversable state. Once a virtual machine is
+         * deleted, it can never be undone, which means all its secrets are permanently lost.
+         */
+        DELETED,
+    }
+
+    /** The package which owns this VM. */
+    private final String mPackageName;
+
+    /** Name of this VM within the package. The name should be unique in the package. */
+    private final String mName;
+
+    /**
+     * Path to the config file for this VM. The config file is where the configuration is persisted.
+     */
+    private final File mConfigFilePath;
+
+    /** Path to the instance image file for this VM. (Not implemented) */
+    private final File mInstanceFilePath;
+
+    /** The configuration that is currently associated with this VM. */
+    private VirtualMachineConfig mConfig;
+
+    /** Handle to the "running" VM. */
+    private IVirtualMachine mVirtualMachine;
+
+    private ParcelFileDescriptor mConsoleReader;
+    private ParcelFileDescriptor mConsoleWriter;
+
+    private VirtualMachine(Context context, String name, VirtualMachineConfig config) {
+        mPackageName = context.getPackageName();
+        mName = name;
+        mConfig = config;
+
+        final File vmRoot = new File(context.getFilesDir(), VM_DIR);
+        final File thisVmDir = new File(vmRoot, mName);
+        mConfigFilePath = new File(thisVmDir, CONFIG_FILE);
+        mInstanceFilePath = new File(thisVmDir, INSTANCE_IMAGE_FILE);
+    }
+
+    /**
+     * Creates a virtual machine with the given name and config. Once a virtual machine is created
+     * it is persisted until it is deleted by calling {@link #delete()}. The created virtual machine
+     * is in {@link #STOPPED} state. To run the VM, call {@link #run()}.
+     */
+    /* package */ static VirtualMachine create(
+            Context context, String name, VirtualMachineConfig config)
+            throws VirtualMachineException {
+        // TODO(jiyong): trigger an error if the VM having 'name' already exists.
+        VirtualMachine vm = new VirtualMachine(context, name, config);
+
+        try {
+            final File vmRoot = vm.mConfigFilePath.getParentFile();
+            Files.createDirectories(vmRoot.toPath());
+
+            FileOutputStream output = new FileOutputStream(vm.mConfigFilePath);
+            vm.mConfig.serialize(output);
+            output.close();
+        } catch (IOException e) {
+            throw new VirtualMachineException(e);
+        }
+
+        // TODO(jiyong): create the instance image file
+        return vm;
+    }
+
+    /** Loads a virtual machine that is already created before. */
+    /* package */ static VirtualMachine load(Context context, String name)
+            throws VirtualMachineException {
+        // TODO(jiyong): return null if the VM having the 'name' doesn't exist.
+        VirtualMachine vm = new VirtualMachine(context, name, /* config */ null);
+
+        try {
+            FileInputStream input = new FileInputStream(vm.mConfigFilePath);
+            VirtualMachineConfig config = VirtualMachineConfig.from(input);
+            input.close();
+            vm.mConfig = config;
+        } catch (IOException e) {
+            throw new VirtualMachineException(e);
+        }
+
+        return vm;
+    }
+
+    /**
+     * Returns the name of this virtual machine. The name is unique in the package and can't be
+     * changed.
+     */
+    public String getName() {
+        return mName;
+    }
+
+    /**
+     * Returns the currently selected config of this virtual machine. There can be multiple virtual
+     * machines sharing the same config. Even in that case, the virtual machines are completely
+     * isolated from each other; one cannot share its secret to another virtual machine even if they
+     * share the same config. It is also possible that a virtual machine can switch its config,
+     * which can be done by calling {@link #setConfig(VirtualMachineCOnfig)}.
+     */
+    public VirtualMachineConfig getConfig() {
+        return mConfig;
+    }
+
+    /** Returns the current status of this virtual machine. */
+    public Status getStatus() throws VirtualMachineException {
+        try {
+            if (mVirtualMachine != null && mVirtualMachine.isRunning()) {
+                return Status.RUNNING;
+            }
+        } catch (RemoteException e) {
+            throw new VirtualMachineException(e);
+        }
+        if (!mConfigFilePath.exists()) {
+            return Status.DELETED;
+        }
+        return Status.STOPPED;
+    }
+
+    /**
+     * Runs this virtual machine. The returning of this method however doesn't mean that the VM has
+     * actually started running or the OS has booted there. Such events can be notified by
+     * registering a callback object (not implemented currently).
+     */
+    public void run() throws VirtualMachineException {
+        if (getStatus() != Status.STOPPED) {
+            throw new VirtualMachineException(this + " is not in stopped state");
+        }
+        IVirtualizationService service =
+                IVirtualizationService.Stub.asInterface(ServiceManager.getService(SERVICE_NAME));
+
+        try {
+            if (mConsoleReader == null && mConsoleWriter == null) {
+                ParcelFileDescriptor[] pipe = ParcelFileDescriptor.createPipe();
+                mConsoleReader = pipe[0];
+                mConsoleWriter = pipe[1];
+            }
+            mVirtualMachine =
+                    service.startVm(
+                            android.system.virtualizationservice.VirtualMachineConfig.appConfig(
+                                    getConfig().toParcel()),
+                            mConsoleWriter);
+        } catch (IOException e) {
+            throw new VirtualMachineException(e);
+        } catch (RemoteException e) {
+            throw new VirtualMachineException(e);
+        }
+    }
+
+    /** Returns the stream object representing the console output from the virtual machine. */
+    public InputStream getConsoleOutputStream() throws VirtualMachineException {
+        if (mConsoleReader == null) {
+            throw new VirtualMachineException("Console output not available");
+        }
+        return new FileInputStream(mConsoleReader.getFileDescriptor());
+    }
+
+    /**
+     * Stops this virtual machine. Stopping a virtual machine is like pulling the plug on a real
+     * computer; the machine halts immediately. Software running on the virtual machine is not
+     * notified with the event. A stopped virtual machine can be re-started by calling {@link
+     * #run()}.
+     */
+    public void stop() throws VirtualMachineException {
+        // Dropping the IVirtualMachine handle stops the VM
+        mVirtualMachine = null;
+    }
+
+    /**
+     * Deletes this virtual machine. Deleting a virtual machine means deleting any persisted data
+     * associated with it including the per-VM secret. This is an irreversable action. A virtual
+     * machine once deleted can never be restored. A new virtual machine created with the same name
+     * and the same config is different from an already deleted virtual machine.
+     */
+    public void delete() throws VirtualMachineException {
+        if (getStatus() != Status.STOPPED) {
+            throw new VirtualMachineException("Virtual machine is not stopped");
+        }
+        final File vmRootDir = mConfigFilePath.getParentFile();
+        mConfigFilePath.delete();
+        mInstanceFilePath.delete();
+        vmRootDir.delete();
+    }
+
+    /** Returns the CID of this virtual machine, if it is running. */
+    public Optional<Integer> getCid() throws VirtualMachineException {
+        if (getStatus() != Status.RUNNING) {
+            return Optional.empty();
+        }
+        try {
+            return Optional.of(mVirtualMachine.getCid());
+        } catch (RemoteException e) {
+            throw new VirtualMachineException(e);
+        }
+    }
+
+    /**
+     * Changes the config of this virtual machine to a new one. This can be used to adjust things
+     * like the number of CPU and size of the RAM, depending on the situation (e.g. the size of the
+     * application to run on the virtual machine, etc.) However, changing a config might make the
+     * virtual machine un-bootable if the new config is not compatible with the existing one. For
+     * example, if the signer of the app payload in the new config is different from that of the old
+     * config, the virtual machine won't boot. To prevent such cases, this method returns exception
+     * when an incompatible config is attempted.
+     *
+     * @return the old config
+     */
+    public VirtualMachineConfig setConfig(VirtualMachineConfig newConfig)
+            throws VirtualMachineException {
+        // TODO(jiyong): implement this
+        throw new VirtualMachineException("Not implemented");
+    }
+
+    @Override
+    public String toString() {
+        StringBuilder sb = new StringBuilder();
+        sb.append("VirtualMachine(");
+        sb.append("name:" + getName() + ", ");
+        sb.append("config:" + getConfig().getPayloadConfigPath() + ", ");
+        sb.append("package: " + mPackageName);
+        sb.append(")");
+        return sb.toString();
+    }
+}
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java b/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
new file mode 100644
index 0000000..b5f04a2
--- /dev/null
+++ b/javalib/src/android/system/virtualmachine/VirtualMachineConfig.java
@@ -0,0 +1,158 @@
+/*
+ * Copyright (C) 2021 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 android.system.virtualmachine;
+
+import static android.os.ParcelFileDescriptor.MODE_READ_ONLY;
+
+import android.content.Context;
+import android.os.ParcelFileDescriptor;
+import android.os.PersistableBundle;
+import android.system.virtualizationservice.VirtualMachineAppConfig;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+
+/**
+ * Represents a configuration of a virtual machine. A configuration consists of hardware
+ * configurations like the number of CPUs and the size of RAM, and software configurations like the
+ * OS and application to run on the virtual machine.
+ *
+ * @hide
+ */
+public final class VirtualMachineConfig {
+    // These defines the schema of the config file persisted on disk.
+    private static final int VERSION = 1;
+    private static final String KEY_VERSION = "version";
+    private static final String KEY_APKPATH = "apkPath";
+    private static final String KEY_IDSIGPATH = "idsigPath";
+    private static final String KEY_PAYLOADCONFIGPATH = "payloadConfigPath";
+    private static final String KEY_DEBUGMODE = "debugMode";
+
+    // Paths to the APK and its idsig file of this application.
+    private final String mApkPath;
+    private final String mIdsigPath;
+    private final boolean mDebugMode;
+
+    /**
+     * Path within the APK to the payload config file that defines software aspects of this config.
+     */
+    private final String mPayloadConfigPath;
+
+    // TODO(jiyong): add more items like # of cpu, size of ram, debuggability, etc.
+
+    private VirtualMachineConfig(
+            String apkPath, String idsigPath, String payloadConfigPath, boolean debugMode) {
+        mApkPath = apkPath;
+        mIdsigPath = idsigPath;
+        mPayloadConfigPath = payloadConfigPath;
+        mDebugMode = debugMode;
+    }
+
+    /** Loads a config from a stream, for example a file. */
+    /* package */ static VirtualMachineConfig from(InputStream input)
+            throws IOException, VirtualMachineException {
+        PersistableBundle b = PersistableBundle.readFromStream(input);
+        final int version = b.getInt(KEY_VERSION);
+        if (version > VERSION) {
+            throw new VirtualMachineException("Version too high");
+        }
+        final String apkPath = b.getString(KEY_APKPATH);
+        if (apkPath == null) {
+            throw new VirtualMachineException("No apkPath");
+        }
+        final String idsigPath = b.getString(KEY_IDSIGPATH);
+        if (idsigPath == null) {
+            throw new VirtualMachineException("No idsigPath");
+        }
+        final String payloadConfigPath = b.getString(KEY_PAYLOADCONFIGPATH);
+        if (payloadConfigPath == null) {
+            throw new VirtualMachineException("No payloadConfigPath");
+        }
+        final boolean debugMode = b.getBoolean(KEY_DEBUGMODE);
+        return new VirtualMachineConfig(apkPath, idsigPath, payloadConfigPath, debugMode);
+    }
+
+    /** Persists this config to a stream, for example a file. */
+    /* package */ void serialize(OutputStream output) throws IOException {
+        PersistableBundle b = new PersistableBundle();
+        b.putInt(KEY_VERSION, VERSION);
+        b.putString(KEY_APKPATH, mApkPath);
+        b.putString(KEY_IDSIGPATH, mIdsigPath);
+        b.putString(KEY_PAYLOADCONFIGPATH, mPayloadConfigPath);
+        b.putBoolean(KEY_DEBUGMODE, mDebugMode);
+        b.writeToStream(output);
+    }
+
+    /** Returns the path to the payload config within the owning application. */
+    public String getPayloadConfigPath() {
+        return mPayloadConfigPath;
+    }
+
+    /**
+     * Converts this config object into a parcel. Used when creating a VM via the virtualization
+     * service. Notice that the files are not passed as paths, but as file descriptors because the
+     * service doesn't accept paths as it might not have permission to open app-owned files and that
+     * could be abused to run a VM with software that the calling application doesn't own.
+     */
+    /* package */ VirtualMachineAppConfig toParcel() throws FileNotFoundException {
+        VirtualMachineAppConfig parcel = new VirtualMachineAppConfig();
+        parcel.apk = ParcelFileDescriptor.open(new File(mApkPath), MODE_READ_ONLY);
+        parcel.idsig = ParcelFileDescriptor.open(new File(mIdsigPath), MODE_READ_ONLY);
+        parcel.configPath = mPayloadConfigPath;
+        parcel.debug = mDebugMode;
+        return parcel;
+    }
+
+    /** A builder used to create a {@link VirtualMachineConfig}. */
+    public static class Builder {
+        private Context mContext;
+        private String mPayloadConfigPath;
+        private boolean mDebugMode;
+        private String mIdsigPath; // TODO(jiyong): remove this
+        // TODO(jiyong): add more items like # of cpu, size of ram, debuggability, etc.
+
+        /** Creates a builder for the given context (APK), and the payload config file in APK. */
+        public Builder(Context context, String payloadConfigPath) {
+            mContext = context;
+            mPayloadConfigPath = payloadConfigPath;
+            mDebugMode = false;
+        }
+
+        /** Enables or disables the debug mode */
+        public Builder debugMode(boolean enableOrDisable) {
+            mDebugMode = enableOrDisable;
+            return this;
+        }
+
+        // TODO(jiyong): remove this. Apps shouldn't need to set the path to the idsig file. It
+        // should be automatically found or created on demand.
+        /** Set the path to the idsig file for the current application. */
+        public Builder idsigPath(String idsigPath) {
+            mIdsigPath = idsigPath;
+            return this;
+        }
+
+        /** Builds an immutable {@link VirtualMachineConfig} */
+        public VirtualMachineConfig build() {
+            final String apkPath = mContext.getPackageCodePath();
+            return new VirtualMachineConfig(apkPath, mIdsigPath, mPayloadConfigPath, mDebugMode);
+        }
+    }
+}
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachineException.java b/javalib/src/android/system/virtualmachine/VirtualMachineException.java
new file mode 100644
index 0000000..d6aeab3
--- /dev/null
+++ b/javalib/src/android/system/virtualmachine/VirtualMachineException.java
@@ -0,0 +1,36 @@
+/*
+ * Copyright (C) 2021 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 android.system.virtualmachine;
+
+/** @hide */
+public class VirtualMachineException extends Exception {
+    public VirtualMachineException() {
+        super();
+    }
+
+    public VirtualMachineException(String message) {
+        super(message);
+    }
+
+    public VirtualMachineException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public VirtualMachineException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/javalib/src/android/system/virtualmachine/VirtualMachineManager.java b/javalib/src/android/system/virtualmachine/VirtualMachineManager.java
new file mode 100644
index 0000000..dfa4f0b
--- /dev/null
+++ b/javalib/src/android/system/virtualmachine/VirtualMachineManager.java
@@ -0,0 +1,94 @@
+/*
+ * Copyright (C) 2021 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 android.system.virtualmachine;
+
+import android.content.Context;
+
+import java.lang.ref.WeakReference;
+import java.util.Map;
+import java.util.WeakHashMap;
+
+/**
+ * Manages {@link VirtualMachine} objects created for an application.
+ *
+ * @hide
+ */
+public class VirtualMachineManager {
+    private final Context mContext;
+
+    private VirtualMachineManager(Context context) {
+        mContext = context;
+    }
+
+    static Map<Context, WeakReference<VirtualMachineManager>> sInstances = new WeakHashMap<>();
+
+    /** Returns the per-context instance. */
+    public static VirtualMachineManager getInstance(Context context) {
+        synchronized (sInstances) {
+            VirtualMachineManager vmm =
+                    sInstances.containsKey(context) ? sInstances.get(context).get() : null;
+            if (vmm == null) {
+                vmm = new VirtualMachineManager(context);
+                sInstances.put(context, new WeakReference(vmm));
+            }
+            return vmm;
+        }
+    }
+
+    /** A lock used to synchronize the creation of virtual machines */
+    private static final Object sCreateLock = new Object();
+
+    /**
+     * Creates a new {@link VirtualMachine} with the given name and config. Creating a virtual
+     * machine with the same name as an existing virtual machine is an error. The existing virtual
+     * machine has to be deleted before its name can be reused. Every call to this methods creates a
+     * new (and different) virtual machine even if the name and the config are the same as the
+     * deleted one.
+     */
+    public VirtualMachine create(String name, VirtualMachineConfig config)
+            throws VirtualMachineException {
+        synchronized (sCreateLock) {
+            return VirtualMachine.create(mContext, name, config);
+        }
+    }
+
+    /**
+     * Returns an existing {@link VirtualMachine} with the given name. Returns null if there is no
+     * such virtual machine.
+     */
+    public VirtualMachine get(String name) throws VirtualMachineException {
+        return VirtualMachine.load(mContext, name);
+    }
+
+    /** Returns an existing {@link VirtualMachine} if it exists, or create a new one. */
+    public VirtualMachine getOrCreate(String name, VirtualMachineConfig config)
+            throws VirtualMachineException {
+        VirtualMachine vm;
+        synchronized (sCreateLock) {
+            vm = get(name);
+            if (vm == null) {
+                return create(name, config);
+            }
+        }
+
+        if (vm.getConfig().equals(config)) {
+            return vm;
+        } else {
+            throw new VirtualMachineException("Incompatible config");
+        }
+    }
+}
diff --git a/microdroid/README.md b/microdroid/README.md
index d51c8d0..96a2ef9 100644
--- a/microdroid/README.md
+++ b/microdroid/README.md
@@ -11,7 +11,7 @@
 supported. Note that we currently don't support user builds; only userdebug
 builds are supported.
 
-The only remaining requirment is that `com.android.virt` APEX has to be
+The only remaining requirement is that `com.android.virt` APEX has to be
 pre-installed. To do this, add the following line in your product makefile.
 
 ```make
diff --git a/microdroid/payload/README.md b/microdroid/payload/README.md
index b76eead..35502c1 100644
--- a/microdroid/payload/README.md
+++ b/microdroid/payload/README.md
@@ -7,7 +7,7 @@
 
 Payload disk has 1 + N(number of APEX/APK payloads) partitions.
 
-The first partition is a "metadata" partition which describes other partitions.
+The first partition is a "payload-metadata" partition which describes other partitions.
 And APEXes and an APK are following as separate partitions.
 
 For now, the order of partitions are important.
diff --git a/microdroid/payload/metadata/Android.bp b/microdroid/payload/metadata/Android.bp
index d3ec625..e4c7692 100644
--- a/microdroid/payload/metadata/Android.bp
+++ b/microdroid/payload/metadata/Android.bp
@@ -10,6 +10,7 @@
     prefer_rlib: true,
     edition: "2018",
     rustlibs: [
+        "libanyhow",
         "libmicrodroid_metadata_proto_rust",
         "libprotobuf",
     ],
diff --git a/microdroid/payload/metadata/src/lib.rs b/microdroid/payload/metadata/src/lib.rs
index 9c97411..eb9d90d 100644
--- a/microdroid/payload/metadata/src/lib.rs
+++ b/microdroid/payload/metadata/src/lib.rs
@@ -18,15 +18,15 @@
 //!   4 bytes : size(N) in big endian
 //!   N bytes : protobuf message for Metadata
 
+use anyhow::Result;
 use protobuf::Message;
-use std::io;
 use std::io::Read;
 use std::io::Write;
 
 pub use microdroid_metadata::metadata::{ApexPayload, ApkPayload, Metadata};
 
 /// Reads a metadata from a reader
-pub fn read_metadata<T: Read>(mut r: T) -> io::Result<Metadata> {
+pub fn read_metadata<T: Read>(mut r: T) -> Result<Metadata> {
     let mut buf = [0u8; 4];
     r.read_exact(&mut buf)?;
     let size = i32::from_be_bytes(buf);
@@ -34,9 +34,10 @@
 }
 
 /// Writes a metadata to a writer
-pub fn write_metadata<T: Write>(metadata: &Metadata, mut w: T) -> io::Result<()> {
+pub fn write_metadata<T: Write>(metadata: &Metadata, mut w: T) -> Result<()> {
     let mut buf = Vec::new();
     metadata.write_to_writer(&mut buf)?;
     w.write_all(&(buf.len() as i32).to_be_bytes())?;
-    w.write_all(&buf)
+    w.write_all(&buf)?;
+    Ok(())
 }
diff --git a/microdroid/payload/mk_payload.cc b/microdroid/payload/mk_payload.cc
index c31dcff..b27683c 100644
--- a/microdroid/payload/mk_payload.cc
+++ b/microdroid/payload/mk_payload.cc
@@ -302,7 +302,7 @@
 
     // put metadata at the first partition
     partitions.push_back(MultipleImagePartition{
-            .label = "metadata",
+            .label = "payload-metadata",
             .image_file_paths = {metadata_file},
             .type = kLinuxFilesystem,
             .read_only = true,
diff --git a/microdroid/sepolicy/system/private/apkdmverity.te b/microdroid/sepolicy/system/private/apkdmverity.te
index c6160be..8974a1d 100644
--- a/microdroid/sepolicy/system/private/apkdmverity.te
+++ b/microdroid/sepolicy/system/private/apkdmverity.te
@@ -6,7 +6,7 @@
 # allow domain transition from init
 init_daemon_domain(apkdmverity)
 
-# apkdmverity accesses /dev/block/by-name/metadata which points to
+# apkdmverity accesses "payload metadata disk" which points to
 # a /dev/vd* block device file.
 allow apkdmverity block_device:dir r_dir_perms;
 allow apkdmverity block_device:lnk_file r_file_perms;
diff --git a/microdroid/sepolicy/system/private/microdroid_manager.te b/microdroid/sepolicy/system/private/microdroid_manager.te
index 81a6839..53c63ae 100644
--- a/microdroid/sepolicy/system/private/microdroid_manager.te
+++ b/microdroid/sepolicy/system/private/microdroid_manager.te
@@ -6,8 +6,7 @@
 # allow domain transition from init
 init_daemon_domain(microdroid_manager)
 
-# microdroid_manager accesses /dev/block/by-name/metadata which points to
-# a /dev/vd* block device file.
+# microdroid_manager accesses a virtual disk block device to read VM payload
 allow microdroid_manager block_device:dir r_dir_perms;
 allow microdroid_manager block_device:lnk_file r_file_perms;
 allow microdroid_manager vd_device:blk_file r_file_perms;
diff --git a/microdroid_manager/src/metadata.rs b/microdroid_manager/src/metadata.rs
index 81d9cc4..86a9e3e 100644
--- a/microdroid_manager/src/metadata.rs
+++ b/microdroid_manager/src/metadata.rs
@@ -12,17 +12,17 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-//! Payload metadata from /dev/block/by-name/metadata
+//! Payload metadata from /dev/block/by-name/payload-metadata
 
+use anyhow::Result;
 use log::info;
 use microdroid_metadata::{read_metadata, Metadata};
 use std::fs::File;
-use std::io;
 
-const METADATA_PATH: &str = "/dev/block/by-name/metadata";
+const PAYLOAD_METADATA_PATH: &str = "/dev/block/by-name/payload-metadata";
 
-/// loads payload metadata from /dev/block/by-name/metadata
-pub fn load() -> io::Result<Metadata> {
+/// loads payload metadata from /dev/block/by-name/paylaod-metadata
+pub fn load() -> Result<Metadata> {
     info!("loading payload metadata...");
-    read_metadata(File::open(METADATA_PATH)?)
+    read_metadata(File::open(PAYLOAD_METADATA_PATH)?)
 }
diff --git a/tests/hostside/helper/java/android/virt/test/VirtualizationTestCaseBase.java b/tests/hostside/helper/java/android/virt/test/VirtualizationTestCaseBase.java
index 5821309..6d43760 100644
--- a/tests/hostside/helper/java/android/virt/test/VirtualizationTestCaseBase.java
+++ b/tests/hostside/helper/java/android/virt/test/VirtualizationTestCaseBase.java
@@ -23,12 +23,15 @@
 import static org.junit.Assume.assumeThat;
 
 import com.android.compatibility.common.tradefed.build.CompatibilityBuildHelper;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.log.LogUtil.CLog;
 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 java.io.File;
+import java.io.FileNotFoundException;
 import java.util.Arrays;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -45,7 +48,9 @@
     // Set the maximum timeout value big enough.
     private static final long MICRODROID_BOOT_TIMEOUT_MINUTES = 5;
 
-    public void prepareVirtualizationTestSetup() throws Exception {
+    private static final long MICRODROID_ADB_CONNECT_TIMEOUT_MINUTES = 5;
+
+    public void prepareVirtualizationTestSetup() throws DeviceNotAvailableException {
         // kill stale crosvm processes
         tryRunOnAndroid("killall", "crosvm");
 
@@ -57,7 +62,7 @@
         tryRunOnHost("adb", "disconnect", MICRODROID_SERIAL);
     }
 
-    public void cleanUpVirtualizationTestSetup() throws Exception {
+    public void cleanUpVirtualizationTestSetup() throws DeviceNotAvailableException {
         // disconnect from microdroid
         tryRunOnHost("adb", "disconnect", MICRODROID_SERIAL);
 
@@ -67,7 +72,7 @@
         tryRunOnAndroid("stop", "virtualizationservice");
     }
 
-    public void testIfDeviceIsCapable() throws Exception {
+    public void testIfDeviceIsCapable() throws DeviceNotAvailableException {
         // Checks the preconditions to run microdroid. If the condition is not satisfied
         // don't run the test (instead of failing)
         skipIfFail("ls /dev/kvm");
@@ -96,7 +101,7 @@
     }
 
     // Run a shell command on Android. the default timeout is 2 min by tradefed
-    private String runOnAndroid(String... cmd) throws Exception {
+    public String runOnAndroid(String... cmd) throws DeviceNotAvailableException {
         CommandResult result = getDevice().executeShellV2Command(join(cmd));
         if (result.getStatus() != CommandStatus.SUCCESS) {
             fail(join(cmd) + " has failed: " + result);
@@ -104,13 +109,19 @@
         return result.getStdout().trim();
     }
 
-    // Same as runOnAndroid, but failure is not an error
-    private String tryRunOnAndroid(String... cmd) throws Exception {
+    // Same as runOnAndroid, but returns null on error.
+    public String tryRunOnAndroid(String... cmd) throws DeviceNotAvailableException {
         CommandResult result = getDevice().executeShellV2Command(join(cmd));
-        return result.getStdout().trim();
+        if (result.getStatus() == CommandStatus.SUCCESS) {
+            return result.getStdout().trim();
+        } else {
+            CLog.d(join(cmd) + " has failed (but ok): " + result);
+            return null;
+        }
     }
 
-    private String runOnAndroidWithTimeout(long timeoutMillis, String... cmd) throws Exception {
+    private String runOnAndroidWithTimeout(long timeoutMillis, String... cmd)
+            throws DeviceNotAvailableException {
         CommandResult result =
                 getDevice()
                         .executeShellV2Command(
@@ -125,26 +136,46 @@
 
     // Run a shell command on Microdroid
     public String runOnMicrodroid(String... cmd) {
-        final long timeout = 30000; // 30 sec. Microdroid is extremely slow on GCE-on-CF.
-        CommandResult result =
-                RunUtil.getDefault()
-                        .runTimedCmd(timeout, "adb", "-s", MICRODROID_SERIAL, "shell", join(cmd));
+        CommandResult result = runOnMicrodroidForResult(cmd);
         if (result.getStatus() != CommandStatus.SUCCESS) {
             fail(join(cmd) + " has failed: " + result);
         }
         return result.getStdout().trim();
     }
 
+    // Same as runOnMicrodroid, but returns null on error.
+    public String tryRunOnMicrodroid(String... cmd) {
+        CommandResult result = runOnMicrodroidForResult(cmd);
+        if (result.getStatus() == CommandStatus.SUCCESS) {
+            return result.getStdout().trim();
+        } else {
+            CLog.d(join(cmd) + " has failed (but ok): " + result);
+            return null;
+        }
+    }
+
+    public CommandResult runOnMicrodroidForResult(String... cmd) {
+        final long timeout = 30000; // 30 sec. Microdroid is extremely slow on GCE-on-CF.
+        return RunUtil.getDefault()
+                .runTimedCmd(timeout, "adb", "-s", MICRODROID_SERIAL, "shell", join(cmd));
+    }
+
     private String join(String... strs) {
         return String.join(" ", Arrays.asList(strs));
     }
 
-    public File findTestFile(String name) throws Exception {
-        return (new CompatibilityBuildHelper(getBuild())).getTestFile(name);
+    public File findTestFile(String name) {
+        try {
+            return (new CompatibilityBuildHelper(getBuild())).getTestFile(name);
+        } catch (FileNotFoundException e) {
+            fail("Missing test file: " + name);
+            return null;
+        }
     }
 
-    public String startMicrodroid(String apkName, String packageName, String configPath)
-            throws Exception {
+    public String startMicrodroid(
+            String apkName, String packageName, String configPath, boolean debug)
+            throws DeviceNotAvailableException {
         // Install APK
         File apkFile = findTestFile(apkName);
         getDevice().installPackage(apkFile, /* reinstall */ true);
@@ -162,6 +193,7 @@
         getDevice().pushFile(idsigOnHost, apkIdsigPath);
 
         final String logPath = TEST_ROOT + "log.txt";
+        final String debugFlag = debug ? "--debug " : "";
 
         // Run the VM
         runOnAndroid("start", "virtualizationservice");
@@ -171,6 +203,7 @@
                         "run-app",
                         "--daemonize",
                         "--log " + logPath,
+                        debugFlag,
                         apkPath,
                         apkIdsigPath,
                         configPath);
@@ -200,16 +233,38 @@
         return matcher.group(1);
     }
 
-    public void shutdownMicrodroid(String cid) throws Exception {
+    public void shutdownMicrodroid(String cid) throws DeviceNotAvailableException {
         // Shutdown microdroid
         runOnAndroid(VIRT_APEX + "bin/vm", "stop", cid);
+
+        // TODO(192660485): Figure out why shutting down the VM disconnects adb on cuttlefish
+        // temporarily. Without this wait, the rest of `runOnAndroid/skipIfFail` fails due to the
+        // connection loss, and results in assumption error exception for the rest of the tests.
+        try {
+            Thread.sleep(1000);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
+    }
+
+    public void rootMicrodroid() throws DeviceNotAvailableException {
+        runOnHost("adb", "-s", MICRODROID_SERIAL, "root");
+
+        // TODO(192660959): Figure out the root cause and remove the sleep. For unknown reason,
+        // even though `adb root` actually wait-for-disconnect then wait-for-device, the next
+        // `adb -s $MICRODROID_SERIAL shell ...` often fails with "adb: device offline".
+        try {
+            Thread.sleep(1000);
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+        }
     }
 
     // Establish an adb connection to microdroid by letting Android forward the connection to
     // microdroid. Wait until the connection is established and microdroid is booted.
-    public void adbConnectToMicrodroid(String cid, long timeoutMinutes) throws Exception {
+    public void adbConnectToMicrodroid(String cid) throws DeviceNotAvailableException {
         long start = System.currentTimeMillis();
-        long timeoutMillis = timeoutMinutes * 60 * 1000;
+        long timeoutMillis = MICRODROID_ADB_CONNECT_TIMEOUT_MINUTES * 60 * 1000;
         long elapsed = 0;
 
         final String serial = getDevice().getSerialNumber();
@@ -247,7 +302,7 @@
         assertThat(runOnMicrodroid("getprop", "ro.hardware"), is("microdroid"));
     }
 
-    private void skipIfFail(String command) throws Exception {
+    private void skipIfFail(String command) throws DeviceNotAvailableException {
         CommandResult result = getDevice().executeShellV2Command(command);
         assumeThat(result.getStatus(), is(CommandStatus.SUCCESS));
     }
diff --git a/tests/hostside/java/android/virt/test/MicrodroidTestCase.java b/tests/hostside/java/android/virt/test/MicrodroidTestCase.java
index 0b82d43..02fb7e5 100644
--- a/tests/hostside/java/android/virt/test/MicrodroidTestCase.java
+++ b/tests/hostside/java/android/virt/test/MicrodroidTestCase.java
@@ -29,15 +29,14 @@
 
 @RunWith(DeviceJUnit4ClassRunner.class)
 public class MicrodroidTestCase extends VirtualizationTestCaseBase {
-    private static final long MICRODROID_ADB_CONNECT_TIMEOUT_MINUTES = 5;
     private static final String APK_NAME = "MicrodroidTestApp.apk";
     private static final String PACKAGE_NAME = "com.android.microdroid.test";
 
     @Test
     public void testMicrodroidBoots() throws Exception {
         final String configPath = "assets/vm_config.json"; // path inside the APK
-        final String cid = startMicrodroid(APK_NAME, PACKAGE_NAME, configPath);
-        adbConnectToMicrodroid(cid, MICRODROID_ADB_CONNECT_TIMEOUT_MINUTES);
+        final String cid = startMicrodroid(APK_NAME, PACKAGE_NAME, configPath, /* debug */ false);
+        adbConnectToMicrodroid(cid);
 
         // Test writing to /data partition
         runOnMicrodroid("echo MicrodroidTest > /data/local/tmp/test.txt");
@@ -77,6 +76,18 @@
         shutdownMicrodroid(cid);
     }
 
+    @Test
+    public void testDebugMode() throws Exception {
+        final String configPath = "assets/vm_config.json"; // path inside the APK
+        final boolean debug = true;
+        final String cid = startMicrodroid(APK_NAME, PACKAGE_NAME, configPath, debug);
+        adbConnectToMicrodroid(cid);
+
+        assertThat(runOnMicrodroid("getenforce"), is("Permissive"));
+
+        shutdownMicrodroid(cid);
+    }
+
     @Before
     public void setUp() throws Exception {
         testIfDeviceIsCapable();
diff --git a/tests/testapk/src/java/com/android/microdroid/test/TestActivity.java b/tests/testapk/src/java/com/android/microdroid/test/TestActivity.java
index b25869b..f73772e 100644
--- a/tests/testapk/src/java/com/android/microdroid/test/TestActivity.java
+++ b/tests/testapk/src/java/com/android/microdroid/test/TestActivity.java
@@ -17,8 +17,35 @@
 
 import android.app.Activity;
 import android.os.Bundle;
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineException;
+import android.system.virtualmachine.VirtualMachineManager;
 
 public class TestActivity extends Activity {
+
     @Override
-    public void onCreate(Bundle savedInstanceState) {}
+    public void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+
+        VirtualMachine vm1 = createAndRunVirtualMachine("vm1");
+        VirtualMachine vm2 = createAndRunVirtualMachine("vm2");
+    }
+
+    private VirtualMachine createAndRunVirtualMachine(String name) {
+        VirtualMachine vm;
+        try {
+            VirtualMachineConfig config =
+                    new VirtualMachineConfig.Builder(this, "assets/vm_config.json")
+                            .idsigPath("/data/local/tmp/virt/MicrodroidTestApp.apk.idsig")
+                            .build();
+
+            VirtualMachineManager vmm = VirtualMachineManager.getInstance(this);
+            vm = vmm.create(name, config);
+            vm.run();
+        } catch (VirtualMachineException e) {
+            throw new RuntimeException(e);
+        }
+        return vm;
+    }
 }
diff --git a/virtualizationservice/Android.bp b/virtualizationservice/Android.bp
index c209f10..0b8f2e5 100644
--- a/virtualizationservice/Android.bp
+++ b/virtualizationservice/Android.bp
@@ -29,6 +29,7 @@
         "liblog_rust",
         "libmicrodroid_metadata",
         "libmicrodroid_payload_config",
+        "libonce_cell",
         "libprotobuf",
         "libprotos",
         "libregex",
diff --git a/virtualizationservice/aidl/Android.bp b/virtualizationservice/aidl/Android.bp
index a3311f2..f7cb339 100644
--- a/virtualizationservice/aidl/Android.bp
+++ b/virtualizationservice/aidl/Android.bp
@@ -5,8 +5,8 @@
 aidl_interface {
     name: "android.system.virtualizationservice",
     srcs: ["**/*.aidl"],
-    // This is never accessed directly. Apps are expected to use this indirectly via the java wrapper
-    // android.system.virtualmachine.
+    // This is never accessed directly. Apps are expected to use this indirectly via the Java
+    // wrapper android.system.virtualmachine.
     unstable: true,
     backend: {
         java: {
diff --git a/virtualizationservice/src/aidl.rs b/virtualizationservice/src/aidl.rs
index c0b2e11..bc19109 100644
--- a/virtualizationservice/src/aidl.rs
+++ b/virtualizationservice/src/aidl.rs
@@ -16,7 +16,7 @@
 
 use crate::composite::make_composite_image;
 use crate::crosvm::{CrosvmConfig, DiskFile, VmInstance};
-use crate::payload::{make_payload_disk, ApexInfoList};
+use crate::payload::make_payload_disk;
 use crate::{Cid, FIRST_GUEST_CID};
 
 use android_system_virtualizationservice::aidl::android::system::virtualizationservice::IVirtualizationService::IVirtualizationService;
@@ -63,19 +63,9 @@
     ["com.android.adbd", "com.android.i18n", "com.android.os.statsd", "com.android.sdkext"];
 
 /// Implementation of `IVirtualizationService`, the entry point of the AIDL service.
-#[derive(Debug)]
+#[derive(Debug, Default)]
 pub struct VirtualizationService {
     state: Mutex<State>,
-    apex_info_list: ApexInfoList,
-}
-
-impl VirtualizationService {
-    pub fn new() -> Result<VirtualizationService> {
-        Ok(VirtualizationService {
-            state: Default::default(),
-            apex_info_list: ApexInfoList::load()?,
-        })
-    }
 }
 
 impl Interface for VirtualizationService {}
@@ -120,15 +110,13 @@
 
         let config = match config {
             VirtualMachineConfig::AppConfig(config) => BorrowedOrOwned::Owned(
-                load_app_config(&self.apex_info_list, config, &temporary_directory).map_err(
-                    |e| {
-                        error!("Failed to load app config from {}: {}", &config.configPath, e);
-                        new_binder_exception(
-                            ExceptionCode::SERVICE_SPECIFIC,
-                            format!("Failed to load app config from {}: {}", &config.configPath, e),
-                        )
-                    },
-                )?,
+                load_app_config(config, &temporary_directory).map_err(|e| {
+                    error!("Failed to load app config from {}: {}", &config.configPath, e);
+                    new_binder_exception(
+                        ExceptionCode::SERVICE_SPECIFIC,
+                        format!("Failed to load app config from {}: {}", &config.configPath, e),
+                    )
+                })?,
             ),
             VirtualMachineConfig::RawConfig(config) => BorrowedOrOwned::Borrowed(config),
         };
@@ -300,7 +288,6 @@
 }
 
 fn load_app_config(
-    apex_info_list: &ApexInfoList,
     config: &VirtualMachineAppConfig,
     temporary_directory: &Path,
 ) -> Result<VirtualMachineRawConfig> {
@@ -330,7 +317,6 @@
         apexes.dedup_by(|a, b| a.name == b.name);
 
         vm_config.disks.push(make_payload_disk(
-            apex_info_list,
             format!("/proc/self/fd/{}", apk_file.as_raw_fd()).into(),
             format!("/proc/self/fd/{}", idsig_file.as_raw_fd()).into(),
             config_path,
diff --git a/virtualizationservice/src/main.rs b/virtualizationservice/src/main.rs
index 3f2e99c..658203b 100644
--- a/virtualizationservice/src/main.rs
+++ b/virtualizationservice/src/main.rs
@@ -39,7 +39,7 @@
         android_logger::Config::default().with_tag(LOG_TAG).with_min_level(Level::Trace),
     );
 
-    let service = VirtualizationService::new().unwrap();
+    let service = VirtualizationService::default();
     let service = BnVirtualizationService::new_binder(
         service,
         BinderFeatures { set_requesting_sid: true, ..BinderFeatures::default() },
diff --git a/virtualizationservice/src/payload.rs b/virtualizationservice/src/payload.rs
index d3dc289..76c55de 100644
--- a/virtualizationservice/src/payload.rs
+++ b/virtualizationservice/src/payload.rs
@@ -16,9 +16,10 @@
 
 use crate::composite::align_to_partition_size;
 
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, bail, Result};
 use microdroid_metadata::{ApexPayload, ApkPayload, Metadata};
 use microdroid_payload_config::ApexConfig;
+use once_cell::sync::OnceCell;
 use regex::Regex;
 use std::fs;
 use std::fs::OpenOptions;
@@ -29,7 +30,7 @@
 
 /// Represents the list of APEXes
 #[derive(Debug)]
-pub struct ApexInfoList {
+struct ApexInfoList {
     list: Vec<ApexInfo>,
 }
 
@@ -41,30 +42,38 @@
 
 impl ApexInfoList {
     /// Loads ApexInfoList
-    pub fn load() -> Result<ApexInfoList> {
-        // TODO(b/191601801): look up /apex/apex-info-list.xml instead of apexservice
-        // Each APEX prints the line:
-        //   Module: <...> Version: <...> VersionName: <...> Path: <...> IsActive: <...> IsFactory: <...>
-        // We only care about "Module:" and "Path:" tagged values for now.
-        let info_pattern = Regex::new(r"^Module: (?P<name>[^ ]*) .* Path: (?P<path>[^ ]*) .*$")?;
-        let output = Command::new("cmd")
-            .arg("-w")
-            .arg("apexservice")
-            .arg("getActivePackages")
-            .output()
-            .expect("failed to execute apexservice cmd");
-        let list = BufReader::new(output.stdout.as_slice())
-            .lines()
-            .map(|line| -> Result<ApexInfo> {
-                let line = line?;
-                let captures =
-                    info_pattern.captures(&line).ok_or_else(|| anyhow!("can't parse: {}", line))?;
-                let name = captures.name("name").unwrap();
-                let path = captures.name("path").unwrap();
-                Ok(ApexInfo { name: name.as_str().to_owned(), path: path.as_str().into() })
-            })
-            .collect::<Result<Vec<ApexInfo>>>()?;
-        Ok(ApexInfoList { list })
+    fn load() -> Result<&'static ApexInfoList> {
+        static INSTANCE: OnceCell<ApexInfoList> = OnceCell::new();
+        INSTANCE.get_or_try_init(|| {
+            // TODO(b/191601801): look up /apex/apex-info-list.xml instead of apexservice
+            // Each APEX prints the line:
+            //   Module: <...> Version: <...> VersionName: <...> Path: <...> IsActive: <...> IsFactory: <...>
+            // We only care about "Module:" and "Path:" tagged values for now.
+            let info_pattern =
+                Regex::new(r"^Module: (?P<name>[^ ]*) .* Path: (?P<path>[^ ]*) .*$")?;
+            let output = Command::new("cmd")
+                .arg("-w")
+                .arg("apexservice")
+                .arg("getActivePackages")
+                .output()
+                .expect("failed to execute apexservice cmd");
+            let list = BufReader::new(output.stdout.as_slice())
+                .lines()
+                .map(|line| -> Result<ApexInfo> {
+                    let line = line?;
+                    let captures = info_pattern
+                        .captures(&line)
+                        .ok_or_else(|| anyhow!("can't parse: {}", line))?;
+                    let name = captures.name("name").unwrap();
+                    let path = captures.name("path").unwrap();
+                    Ok(ApexInfo { name: name.as_str().to_owned(), path: path.as_str().into() })
+                })
+                .collect::<Result<Vec<ApexInfo>>>()?;
+            if list.is_empty() {
+                bail!("failed to load apex info: empty");
+            }
+            Ok(ApexInfoList { list })
+        })
     }
 
     fn get_path_for(&self, apex_name: &str) -> Result<PathBuf> {
@@ -120,7 +129,6 @@
 ///   microdroid-apk: [apk, zero filler]
 ///   microdroid-apk-idsig: idsig
 pub fn make_payload_disk(
-    apex_info_list: &ApexInfoList,
     apk_file: PathBuf,
     idsig_file: PathBuf,
     config_path: &str,
@@ -150,11 +158,12 @@
 
     // put metadata at the first partition
     let mut partitions = vec![Partition {
-        label: "metadata".to_owned(),
+        label: "payload-metadata".to_owned(),
         paths: vec![metadata_path],
         writable: false,
     }];
 
+    let apex_info_list = ApexInfoList::load()?;
     let mut filler_count = 0;
     for (i, apex) in apexes.iter().enumerate() {
         partitions.push(make_partition(