Merge "Update IAccessor implementation for getInstance" into main
diff --git a/Android.bp b/Android.bp
new file mode 100644
index 0000000..a246e08
--- /dev/null
+++ b/Android.bp
@@ -0,0 +1,3 @@
+package {
+    default_team: "trendy_team_android_kvm",
+}
diff --git a/README.md b/README.md
index fc4d389..4a10c89 100644
--- a/README.md
+++ b/README.md
@@ -16,8 +16,8 @@
 AVF components:
 * [pVM firmware](guest/pvmfw/README.md)
 * [Android Boot Loader (ABL)](docs/abl.md)
-* [Microdroid](microdroid/README.md)
-* [Microdroid kernel](microdroid/kernel/README.md)
+* [Microdroid](build/microdroid/README.md)
+* [Microdroid kernel](guest/kernel/README.md)
 * [Microdroid payload](libs/libmicrodroid_payload_metadata/README.md)
 * [vmbase](libs/libvmbase/README.md)
 * [Encrypted Storage](guest/encryptedstore/README.md)
diff --git a/TEST_MAPPING b/TEST_MAPPING
index a822210..2112125 100644
--- a/TEST_MAPPING
+++ b/TEST_MAPPING
@@ -46,6 +46,9 @@
     },
     {
       "name": "libdice_driver_test"
+    },
+    {
+      "name": "vm_accessor_test"
     }
   ],
   "avf-postsubmit": [
@@ -64,9 +67,6 @@
     {
       "name": "AvfRkpdAppGoogleIntegrationTests",
       "keywords": ["internal"]
-    },
-    {
-      "name": "vm_accessor_test"
     }
   ],
   "ferrochrome-postsubmit": [
@@ -144,7 +144,7 @@
       "path": "packages/modules/Virtualization/android/vm"
     },
     {
-      "path": "packages/modules/Virtualization/libs/libvmbase"
+      "path": "packages/modules/Virtualization/tests/vmbase_example"
     },
     {
       "path": "packages/modules/Virtualization/guest/zipfuse"
diff --git a/android/FerrochromeApp/Android.bp b/android/FerrochromeApp/Android.bp
index 9f0c735..3e4ad14 100644
--- a/android/FerrochromeApp/Android.bp
+++ b/android/FerrochromeApp/Android.bp
@@ -2,17 +2,22 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
+java_defaults {
+    name: "VmPayloadInstaller",
+    init_rc: [":custom_vm_setup.rc"],
+    required: ["custom_vm_setup"],
+    // TODO(b/348113995): move this app to product partition
+    system_ext_specific: true,
+    platform_apis: true,
+    privileged: true,
+}
+
 android_app {
     name: "FerrochromeApp",
     srcs: ["java/**/*.java"],
     resource_dirs: ["res"],
-    platform_apis: true,
-    // TODO(b/348113995): move this app to product partition
-    system_ext_specific: true,
-    privileged: true,
-    init_rc: ["custom_vm_setup.rc"],
+    defaults: ["VmPayloadInstaller"],
     required: [
-        "custom_vm_setup",
         "privapp-permissions-ferrochrome.xml",
     ],
 }
@@ -24,6 +29,11 @@
     system_ext_specific: true,
 }
 
+filegroup {
+    name: "custom_vm_setup.rc",
+    srcs: ["custom_vm_setup.rc"],
+}
+
 sh_binary {
     name: "custom_vm_setup",
     src: "custom_vm_setup.sh",
diff --git a/android/FerrochromeApp/AndroidManifest.xml b/android/FerrochromeApp/AndroidManifest.xml
index d640c4a..f6d3f6a 100644
--- a/android/FerrochromeApp/AndroidManifest.xml
+++ b/android/FerrochromeApp/AndroidManifest.xml
@@ -12,6 +12,9 @@
         <intent>
             <action android:name="android.virtualization.VM_LAUNCHER" />
         </intent>
+        <intent>
+            <action android:name="android.virtualization.FERROCHROME_DOWNLOADER" />
+        </intent>
     </queries>
     <application
         android:label="Ferrochrome">
diff --git a/android/FerrochromeApp/custom_vm_setup.sh b/android/FerrochromeApp/custom_vm_setup.sh
index a5480ff..df1a3a6 100644
--- a/android/FerrochromeApp/custom_vm_setup.sh
+++ b/android/FerrochromeApp/custom_vm_setup.sh
@@ -7,22 +7,23 @@
 }
 
 function install() {
-  user=$(cmd user get-main-user)
-  src_dir=/data/media/${user}/ferrochrome/
+  src_dir=$(getprop debug.custom_vm_setup.path)
+  src_dir=${src_dir/#\/storage\/emulated\//\/data\/media\/}
   dst_dir=/data/local/tmp/
 
   cat $(find ${src_dir} -name "images.tar.gz*" | sort) | tar xz -C ${dst_dir}
-  cp -u ${src_dir}vm_config.json ${dst_dir}
-  chmod 666 ${dst_dir}*
+  cp -u ${src_dir}/vm_config.json ${dst_dir}
+  chmod 666 ${dst_dir}/*
 
-  # increase the size of state.img to the multiple of 4096
-  num_blocks=$(du -b -K ${dst_dir}state.img | cut -f 1)
-  required_num_blocks=$(round_up ${num_blocks} 4)
-  additional_blocks=$((( ${required_num_blocks} - ${num_blocks} )))
-  dd if=/dev/zero bs=512 count=${additional_blocks} >> ${dst_dir}state.img
-
-  rm ${src_dir}images.tar.gz*
-  rm ${src_dir}vm_config.json
+  if [ -f ${dst_dir}state.img ]; then
+    # increase the size of state.img to the multiple of 4096
+    num_blocks=$(du -b -K ${dst_dir}state.img | cut -f 1)
+    required_num_blocks=$(round_up ${num_blocks} 4)
+    additional_blocks=$((( ${required_num_blocks} - ${num_blocks} )))
+    dd if=/dev/zero bs=512 count=${additional_blocks} >> ${dst_dir}state.img
+  fi
+  rm ${src_dir}/images.tar.gz*
+  rm ${src_dir}/vm_config.json
 }
 
 setprop debug.custom_vm_setup.done false
diff --git a/android/FerrochromeApp/java/com/android/virtualization/ferrochrome/FerrochromeActivity.java b/android/FerrochromeApp/java/com/android/virtualization/ferrochrome/FerrochromeActivity.java
index 2df5cab..dba0078 100644
--- a/android/FerrochromeApp/java/com/android/virtualization/ferrochrome/FerrochromeActivity.java
+++ b/android/FerrochromeApp/java/com/android/virtualization/ferrochrome/FerrochromeActivity.java
@@ -16,14 +16,17 @@
 
 package com.android.virtualization.ferrochrome;
 
+import android.annotation.WorkerThread;
 import android.app.Activity;
 import android.app.ActivityManager;
 import android.content.Intent;
+import android.content.pm.ActivityInfo;
 import android.content.pm.PackageManager;
 import android.content.pm.ResolveInfo;
 import android.os.Bundle;
 import android.os.Environment;
 import android.os.SystemProperties;
+import android.text.TextUtils;
 import android.util.Log;
 import android.view.WindowManager;
 import android.widget.TextView;
@@ -43,12 +46,20 @@
 public class FerrochromeActivity extends Activity {
     private static final String TAG = FerrochromeActivity.class.getName();
     private static final String ACTION_VM_LAUNCHER = "android.virtualization.VM_LAUNCHER";
+    private static final String ACTION_FERROCHROME_DOWNLOAD =
+            "android.virtualization.FERROCHROME_DOWNLOADER";
+    private static final String EXTRA_FERROCHROME_DEST_DIR = "dest_dir";
+    private static final String EXTRA_FERROCHROME_UPDATE_NEEDED = "update_needed";
 
     private static final Path DEST_DIR =
             Path.of(Environment.getExternalStorageDirectory().getPath(), "ferrochrome");
+    private static final String ASSET_DIR = "ferrochrome";
     private static final Path VERSION_FILE = Path.of(DEST_DIR.toString(), "version");
 
     private static final int REQUEST_CODE_VMLAUNCHER = 1;
+    private static final int REQUEST_CODE_FERROCHROME_DOWNLOADER = 2;
+
+    private ResolvedActivity mVmLauncher;
 
     ExecutorService executorService = Executors.newSingleThreadExecutor();
 
@@ -66,25 +77,28 @@
         getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
 
         // Find VM Launcher
-        Intent intent = new Intent(ACTION_VM_LAUNCHER).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
-        PackageManager pm = getPackageManager();
-        List<ResolveInfo> resolveInfos =
-                pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
-        if (resolveInfos == null || resolveInfos.size() != 1) {
+        mVmLauncher = ResolvedActivity.resolve(getPackageManager(), ACTION_VM_LAUNCHER);
+        if (mVmLauncher == null) {
             updateStatus("Failed to resolve VM Launcher");
             return;
         }
 
         // Clean up the existing vm launcher process if there is
         ActivityManager am = getSystemService(ActivityManager.class);
-        am.killBackgroundProcesses(resolveInfos.get(0).activityInfo.packageName);
+        am.killBackgroundProcesses(mVmLauncher.activityInfo.packageName);
 
         executorService.execute(
                 () -> {
-                    if (updateImageIfNeeded()) {
-                        updateStatus("Starting Ferrochrome...");
-                        runOnUiThread(
-                                () -> startActivityForResult(intent, REQUEST_CODE_VMLAUNCHER));
+                    if (hasLocalAssets()) {
+                        if (updateImageIfNeeded()) {
+                            updateStatus("Starting Ferrochrome...");
+                            runOnUiThread(
+                                    () ->
+                                            startActivityForResult(
+                                                    mVmLauncher.intent, REQUEST_CODE_VMLAUNCHER));
+                        }
+                    } else {
+                        tryLaunchDownloader();
                     }
                 });
     }
@@ -93,9 +107,55 @@
     protected void onActivityResult(int requestCode, int resultCode, Intent data) {
         if (requestCode == REQUEST_CODE_VMLAUNCHER) {
             finishAndRemoveTask();
+        } else if (requestCode == REQUEST_CODE_FERROCHROME_DOWNLOADER) {
+            String destDir = data.getStringExtra(EXTRA_FERROCHROME_DEST_DIR);
+            boolean updateNeeded =
+                    data.getBooleanExtra(EXTRA_FERROCHROME_UPDATE_NEEDED, /* default= */ true);
+
+            if (resultCode != RESULT_OK || TextUtils.isEmpty(destDir)) {
+                Log.w(
+                        TAG,
+                        "Ferrochrome downloader returned error, code="
+                                + resultCode
+                                + ", dest="
+                                + destDir);
+                updateStatus("User didn't accepted ferrochrome download..");
+                return;
+            }
+
+            Log.w(TAG, "Ferrochrome downloader returned OK");
+
+            if (!updateNeeded) {
+                updateStatus("Starting Ferrochrome...");
+                startActivityForResult(mVmLauncher.intent, REQUEST_CODE_VMLAUNCHER);
+            }
+
+            executorService.execute(
+                    () -> {
+                        if (!extractImages(destDir)) {
+                            updateStatus("Images from downloader looks bad..");
+                            return;
+                        }
+                        updateStatus("Starting Ferrochrome...");
+                        runOnUiThread(
+                                () ->
+                                        startActivityForResult(
+                                                mVmLauncher.intent, REQUEST_CODE_VMLAUNCHER));
+                    });
         }
     }
 
+    @WorkerThread
+    private boolean hasLocalAssets() {
+        try {
+            String[] files = getAssets().list(ASSET_DIR);
+            return files != null && files.length > 0;
+        } catch (IOException e) {
+            return false;
+        }
+    }
+
+    @WorkerThread
     private boolean updateImageIfNeeded() {
         if (!isUpdateNeeded()) {
             Log.d(TAG, "No update needed.");
@@ -107,13 +167,8 @@
                 Files.createDirectory(DEST_DIR);
             }
 
-            String[] files = getAssets().list("ferrochrome");
-            if (files == null || files.length == 0) {
-                updateStatus("ChromeOS image not found. Please go/try-ferrochrome");
-                return false;
-            }
-
             updateStatus("Copying images...");
+            String[] files = getAssets().list("ferrochrome");
             for (String file : files) {
                 updateStatus(file);
                 Path dst = Path.of(DEST_DIR.toString(), file);
@@ -126,7 +181,38 @@
         }
         updateStatus("Done.");
 
+        return extractImages(DEST_DIR.toAbsolutePath().toString());
+    }
+
+    @WorkerThread
+    private void tryLaunchDownloader() {
+        // TODO(jaewan): Add safeguard to check whether ferrochrome downloader is valid.
+        Log.w(TAG, "No built-in assets found. Try again with ferrochrome downloader");
+
+        ResolvedActivity downloader =
+                ResolvedActivity.resolve(getPackageManager(), ACTION_FERROCHROME_DOWNLOAD);
+        if (downloader == null) {
+            Log.d(TAG, "Ferrochrome downloader doesn't exist");
+            updateStatus("ChromeOS image not found. Please go/try-ferrochrome");
+            return;
+        }
+        String pkgName = downloader.activityInfo.packageName;
+        Log.d(TAG, "Resolved Ferrochrome Downloader, pkgName=" + pkgName);
+        updateStatus("Launching Ferrochrome downloader for update");
+
+        // onActivityResult() will handle downloader result.
+        startActivityForResult(downloader.intent, REQUEST_CODE_FERROCHROME_DOWNLOADER);
+    }
+
+    @WorkerThread
+    private boolean extractImages(String destDir) {
         updateStatus("Extracting images...");
+
+        if (TextUtils.isEmpty(destDir)) {
+            throw new RuntimeException("Internal error: destDir shouldn't be null");
+        }
+
+        SystemProperties.set("debug.custom_vm_setup.path", destDir);
         SystemProperties.set("debug.custom_vm_setup.done", "false");
         SystemProperties.set("debug.custom_vm_setup.start", "true");
         while (!SystemProperties.getBoolean("debug.custom_vm_setup.done", false)) {
@@ -143,6 +229,7 @@
         return true;
     }
 
+    @WorkerThread
     private boolean isUpdateNeeded() {
         Path[] pathsToCheck = {DEST_DIR, VERSION_FILE};
         for (Path p : pathsToCheck) {
@@ -188,4 +275,33 @@
                     statusView.append(line + "\n");
                 });
     }
+
+    private static final class ResolvedActivity {
+        public final ActivityInfo activityInfo;
+        public final Intent intent;
+
+        private ResolvedActivity(ActivityInfo activityInfo, Intent intent) {
+            this.activityInfo = activityInfo;
+            this.intent = intent;
+        }
+
+        /* synthetic access */
+        static ResolvedActivity resolve(PackageManager pm, String action) {
+            Intent intent = new Intent(action).setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
+            List<ResolveInfo> resolveInfos =
+                    pm.queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
+            if (resolveInfos == null || resolveInfos.size() != 1) {
+                Log.w(
+                        TAG,
+                        "Failed to resolve activity, action="
+                                + action
+                                + ", resolved="
+                                + resolveInfos);
+                return null;
+            }
+            ActivityInfo activityInfo = resolveInfos.get(0).activityInfo;
+            intent.setClassName(activityInfo.packageName, activityInfo.name);
+            return new ResolvedActivity(activityInfo, intent);
+        }
+    }
 }
diff --git a/android/FerrochromeApp/java/com/android/virtualization/ferrochrome/OpenUrlActivity.java b/android/FerrochromeApp/java/com/android/virtualization/ferrochrome/OpenUrlActivity.java
index c32d017..433e89c 100644
--- a/android/FerrochromeApp/java/com/android/virtualization/ferrochrome/OpenUrlActivity.java
+++ b/android/FerrochromeApp/java/com/android/virtualization/ferrochrome/OpenUrlActivity.java
@@ -30,8 +30,8 @@
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
-        boolean isRoot = isTaskRoot();
         finish();
+
         if (!Intent.ACTION_SEND.equals(getIntent().getAction())) {
             return;
         }
@@ -49,16 +49,6 @@
             return;
         }
         Log.i(TAG, "Sending " + scheme + " URL to VM");
-        if (isRoot) {
-            Log.w(
-                    TAG,
-                    "Cannot open URL without starting "
-                            + FerrochromeActivity.class.getSimpleName()
-                            + " first, starting it now");
-            startActivity(
-                    new Intent(this, FerrochromeActivity.class).setAction(Intent.ACTION_MAIN));
-            return;
-        }
         startActivity(
                 new Intent(ACTION_VM_OPEN_URL)
                         .setFlags(
diff --git a/android/FerrochromeApp/repack.sh b/android/FerrochromeApp/repack.sh
index d47b529..b2a96dd 100755
--- a/android/FerrochromeApp/repack.sh
+++ b/android/FerrochromeApp/repack.sh
@@ -31,9 +31,6 @@
 echo Archiving. This can take long...
 tar czvS -f images.tar.gz *.img
 
-echo Calculating hash...
-hash=$(sha1sum images.tar.gz | cut -d' ' -f 1)
-
 echo Splitting...
 split -b 100M -d images.tar.gz images.tar.gz.part
 
@@ -45,6 +42,9 @@
 rm ${asset_dir}/images.tar.gz.part*
 mv ${tempdir}/images.tar.gz.part* ${asset_dir}
 sed -E s/GUID/${root_guid}/ ${vm_config_template} > ${asset_dir}/vm_config.json
+
+echo Calculating hash...
+hash=$(cat ${tempdir}/images.tar.gz ${asset_dir}/vm_config.json | sha1sum | cut -d' ' -f 1)
 echo ${hash} > ${asset_dir}/version
 
 echo Cleanup...
diff --git a/android/FerrochromeApp/vm_config.json.template b/android/FerrochromeApp/vm_config.json.template
index d1a7cfa..380f016 100644
--- a/android/FerrochromeApp/vm_config.json.template
+++ b/android/FerrochromeApp/vm_config.json.template
@@ -29,6 +29,22 @@
     "cpu_topology": "match_host",
     "platform_version": "~1.0",
     "memory_mib": 8096,
+    "debuggable": true,
+    "console_out": true,
+    "connect_console": true,
+    "console_input_device": "hvc0",
+    "network": true,
+    "input": {
+        "touchscreen": true,
+        "keyboard": true,
+        "mouse": true,
+        "trackpad": true,
+        "switches": true
+    },
+    "audio": {
+        "speaker": true,
+        "microphone": true
+    },
     "gpu": {
         "backend": "virglrenderer",
         "context_types": ["virgl2"]
@@ -36,6 +52,5 @@
     "display": {
         "scale": "0.77",
         "refresh_rate": "30"
-     },
-    "console_input_device": "hvc0"
+    }
 }
diff --git a/android/LinuxInstaller/.gitignore b/android/LinuxInstaller/.gitignore
new file mode 100644
index 0000000..e81da29
--- /dev/null
+++ b/android/LinuxInstaller/.gitignore
@@ -0,0 +1,2 @@
+assets/*
+!assets/.gitkeep
diff --git a/android/LinuxInstaller/Android.bp b/android/LinuxInstaller/Android.bp
new file mode 100644
index 0000000..f70452d
--- /dev/null
+++ b/android/LinuxInstaller/Android.bp
@@ -0,0 +1,41 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+    name: "LinuxInstallerApp",
+    srcs: ["java/**/*.java"],
+    resource_dirs: ["res"],
+    asset_dirs: ["assets"],
+    manifest: "AndroidManifest.xml",
+    defaults: ["VmPayloadInstaller"],
+    overrides: ["LinuxInstallerAppStub"],
+    required: [
+        "privapp-permissions-linuxinstaller.xml",
+    ],
+    certificate: ":com.android.virtualization.linuxinstaller_certificate",
+}
+
+android_app {
+    name: "LinuxInstallerAppStub",
+    srcs: ["java/**/*.java"],
+    resource_dirs: ["res"],
+    manifest: "AndroidManifest_stub.xml",
+    defaults: ["VmPayloadInstaller"],
+    required: [
+        "privapp-permissions-linuxinstaller.xml",
+    ],
+    certificate: ":com.android.virtualization.linuxinstaller_certificate",
+}
+
+prebuilt_etc {
+    name: "privapp-permissions-linuxinstaller.xml",
+    src: "privapp-permissions-linuxinstaller.xml",
+    sub_dir: "permissions",
+    system_ext_specific: true,
+}
+
+android_app_certificate {
+    name: "com.android.virtualization.linuxinstaller_certificate",
+    certificate: "com_android_virtualization_linuxinstaller",
+}
diff --git a/android/LinuxInstaller/AndroidManifest.xml b/android/LinuxInstaller/AndroidManifest.xml
new file mode 100644
index 0000000..e5653f6
--- /dev/null
+++ b/android/LinuxInstaller/AndroidManifest.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.virtualization.linuxinstaller"
+    android:versionCode="2100000000" >
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.CHANGE_COMPONENT_ENABLED_STATE" />
+    <queries>
+        <intent>
+            <action android:name="android.virtualization.VM_TERMINAL" />
+        </intent>
+    </queries>
+    <application
+        android:label="LinuxInstaller">
+        <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/android/LinuxInstaller/AndroidManifest_stub.xml b/android/LinuxInstaller/AndroidManifest_stub.xml
new file mode 100644
index 0000000..49365ea
--- /dev/null
+++ b/android/LinuxInstaller/AndroidManifest_stub.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.virtualization.linuxinstaller" >
+    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
+    <uses-permission android:name="android.permission.CHANGE_COMPONENT_ENABLED_STATE" />
+    <queries>
+        <intent>
+            <action android:name="android.virtualization.VM_TERMINAL" />
+        </intent>
+    </queries>
+    <application
+        android:label="LinuxInstaller">
+        <activity android:name=".MainActivity"
+                  android:exported="true">
+        </activity>
+    </application>
+
+</manifest>
diff --git a/android/LinuxInstaller/assets/.gitkeep b/android/LinuxInstaller/assets/.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/android/LinuxInstaller/assets/.gitkeep
diff --git a/android/LinuxInstaller/com_android_virtualization_linuxinstaller.pk8 b/android/LinuxInstaller/com_android_virtualization_linuxinstaller.pk8
new file mode 100644
index 0000000..3f74303
--- /dev/null
+++ b/android/LinuxInstaller/com_android_virtualization_linuxinstaller.pk8
Binary files differ
diff --git a/android/LinuxInstaller/com_android_virtualization_linuxinstaller.x509.pem b/android/LinuxInstaller/com_android_virtualization_linuxinstaller.x509.pem
new file mode 100644
index 0000000..3ca64b7
--- /dev/null
+++ b/android/LinuxInstaller/com_android_virtualization_linuxinstaller.x509.pem
@@ -0,0 +1,24 @@
+-----BEGIN CERTIFICATE-----
+MIIEETCCAvmgAwIBAgIUfBxyELS+ri3QErq8DXHu+47xx4EwDQYJKoZIhvcNAQEL
+BQAwgZYxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQH
+DA1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKDAtHb29nbGUgSW5jLjEQMA4GA1UECwwH
+QW5kcm9pZDEyMDAGA1UEAwwpY29tX2FuZHJvaWRfdmlydHVhbGl6YXRpb25fbGlu
+dXhpbnN0YWxsZXIwIBcNMjQwODMwMTIyNjU2WhgPMjA1MjAxMTYxMjI2NTZaMIGW
+MQswCQYDVQQGEwJVUzETMBEGA1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNTW91
+bnRhaW4gVmlldzEUMBIGA1UECgwLR29vZ2xlIEluYy4xEDAOBgNVBAsMB0FuZHJv
+aWQxMjAwBgNVBAMMKWNvbV9hbmRyb2lkX3ZpcnR1YWxpemF0aW9uX2xpbnV4aW5z
+dGFsbGVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8V/rH9ju6Wce
+1BdWuxfWaLmZJHGShXeDO6MB86Wrm10m26j9PFzd8/8FRKsZaujZphwNZsqBsdlt
+pWeNKts9T9luZn19Ci4E8A2EtgSxmfI8Fjwj/OJHHO0hG5+JcwIlUnmFQPcGtu/r
+EL3i7SfcF2ok+IC6aKYohnSbo+YkjyCSwb39i6POe6v6cPIZJtmOnecThS+fYCYR
+2yoMSSr3Bf8ayySrG0pJp7xZ1I5NixK6hUFZhQRLusyiv/KYTpAElMd+n1YJEYbf
+pW30DYAu+31S0hx8JXncFmI0uG3Zxx+LgNQwY8OPV6NPFfVwMPluZR6ep0tZ6q7e
+KIV2w5uC7QIDAQABo1MwUTAdBgNVHQ4EFgQU6FBYv7mW+9DR9q0c9uS4NNdX4Acw
+HwYDVR0jBBgwFoAU6FBYv7mW+9DR9q0c9uS4NNdX4AcwDwYDVR0TAQH/BAUwAwEB
+/zANBgkqhkiG9w0BAQsFAAOCAQEAj3bvUpwKjvpCggXzjMNkn7fAaQ0s1BubnkFe
+ge4zwz4tObP3OGRcxt5V9R5EZ7UY6bPcybA/rfg9FCzjcUQOBjmuepcQpbNHFW2I
+lasFa42UHkHSUFzeg2n9UC5iO3B+sclOr4EPaEE4HbG4B2vj++BYMW3C7PDyHc7R
+fq5ZsEEWcYUa8qZCO46I8AbMZ8iv1HpR4mZeQMkSxhD3uVHDQW+VqDTpzne/YBkJ
+yNfjpgFVZ/Y1E6BvvjzWZpBfj668fo7P3DekWHbvPPr/DiZ7OA6PCmAH1FBsi2c+
+xPgb9clDc2Zjb2Cd9lAoZdeB14zDOh6ZCF1c/i+qYt5tA9t+GA==
+-----END CERTIFICATE-----
diff --git a/android/LinuxInstaller/java/com/android/virtualization/linuxinstaller/MainActivity.java b/android/LinuxInstaller/java/com/android/virtualization/linuxinstaller/MainActivity.java
new file mode 100644
index 0000000..1d875cb
--- /dev/null
+++ b/android/LinuxInstaller/java/com/android/virtualization/linuxinstaller/MainActivity.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright (C) 2024 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.virtualization.linuxinstaller;
+
+import android.annotation.WorkerThread;
+import android.app.Activity;
+import android.content.ComponentName;
+import android.content.Intent;
+import android.content.pm.ActivityInfo;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+import android.os.Environment;
+import android.os.SystemProperties;
+import android.text.TextUtils;
+import android.util.Log;
+import android.widget.TextView;
+
+import libcore.io.Streams;
+
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class MainActivity extends Activity {
+    private static final String TAG = "LinuxInstaller";
+    private static final String ACTION_VM_TERMINAL = "android.virtualization.VM_TERMINAL";
+
+    private static final Path DEST_DIR =
+            Path.of(Environment.getExternalStorageDirectory().getPath(), "linux");
+
+    private static final String ASSET_DIR = "linux";
+    private static final String HASH_FILE_NAME = "hash";
+    private static final Path HASH_FILE = Path.of(DEST_DIR.toString(), HASH_FILE_NAME);
+
+    ExecutorService executorService = Executors.newSingleThreadExecutor();
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_main);
+
+        executorService.execute(this::installLinuxImage);
+    }
+
+    private void installLinuxImage() {
+        ComponentName vmTerminalComponent = resolve(getPackageManager(), ACTION_VM_TERMINAL);
+        if (vmTerminalComponent == null) {
+            updateStatus("Failed to resolve VM terminal");
+            return;
+        }
+
+        if (!hasLocalAssets()) {
+            updateStatus("No local assets");
+            return;
+        }
+        try {
+            updateImageIfNeeded();
+        } catch (IOException e) {
+            Log.e(TAG, "failed to update image", e);
+            return;
+        }
+        updateStatus("Enabling terminal app...");
+        getPackageManager()
+                .setComponentEnabledSetting(
+                        vmTerminalComponent,
+                        PackageManager.COMPONENT_ENABLED_STATE_ENABLED,
+                        PackageManager.DONT_KILL_APP);
+        updateStatus("Done.");
+    }
+
+    @WorkerThread
+    private boolean hasLocalAssets() {
+        try {
+            String[] files = getAssets().list(ASSET_DIR);
+            return files != null && files.length > 0;
+        } catch (IOException e) {
+            Log.e(TAG, "there is an error during listing up assets", e);
+            return false;
+        }
+    }
+
+    @WorkerThread
+    private void updateImageIfNeeded() throws IOException {
+        if (!isUpdateNeeded()) {
+            Log.d(TAG, "No update needed.");
+            return;
+        }
+
+        try {
+            if (Files.notExists(DEST_DIR)) {
+                Files.createDirectory(DEST_DIR);
+            }
+
+            updateStatus("Copying images...");
+            String[] files = getAssets().list(ASSET_DIR);
+            for (String file : files) {
+                updateStatus(file);
+                Path dst = Path.of(DEST_DIR.toString(), file);
+                updateFile(getAssets().open(ASSET_DIR + "/" + file), dst);
+            }
+        } catch (IOException e) {
+            Log.e(TAG, "Error while updating image: " + e);
+            updateStatus("Failed to update image.");
+            throw e;
+        }
+        extractImages(DEST_DIR.toAbsolutePath().toString());
+    }
+
+    @WorkerThread
+    private void extractImages(String destDir) throws IOException {
+        updateStatus("Extracting images...");
+
+        if (TextUtils.isEmpty(destDir)) {
+            throw new RuntimeException("Internal error: destDir shouldn't be null");
+        }
+
+        SystemProperties.set("debug.custom_vm_setup.path", destDir);
+        SystemProperties.set("debug.custom_vm_setup.done", "false");
+        SystemProperties.set("debug.custom_vm_setup.start", "true");
+        while (!SystemProperties.getBoolean("debug.custom_vm_setup.done", false)) {
+            try {
+                Thread.sleep(1000);
+            } catch (InterruptedException e) {
+                Log.e(TAG, "Error while extracting image: " + e);
+                updateStatus("Failed to extract image.");
+                throw new IOException("extracting image is interrupted", e);
+            }
+        }
+    }
+
+    @WorkerThread
+    private boolean isUpdateNeeded() {
+        Path[] pathsToCheck = {DEST_DIR, HASH_FILE};
+        for (Path p : pathsToCheck) {
+            if (Files.notExists(p)) {
+                Log.d(TAG, p.toString() + " does not exist.");
+                return true;
+            }
+        }
+
+        try {
+            String installedHash = readAll(new FileInputStream(HASH_FILE.toFile()));
+            String updatedHash = readAll(getAssets().open(ASSET_DIR + "/" + HASH_FILE_NAME));
+            if (installedHash.equals(updatedHash)) {
+                return false;
+            }
+            Log.d(TAG, "Hash mismatch. Installed: " + installedHash + "  Updated: " + updatedHash);
+        } catch (IOException e) {
+            Log.e(TAG, "Error while checking hash: " + e);
+        }
+        return true;
+    }
+
+    private static String readAll(InputStream input) throws IOException {
+        return Streams.readFully(new InputStreamReader(input)).strip();
+    }
+
+    private static void updateFile(InputStream input, Path path) throws IOException {
+        try (input) {
+            Files.copy(input, path, StandardCopyOption.REPLACE_EXISTING);
+        }
+    }
+
+    private void updateStatus(String line) {
+        runOnUiThread(
+                () -> {
+                    TextView statusView = findViewById(R.id.status_txt_view);
+                    statusView.append(line + "\n");
+                });
+    }
+
+    private ComponentName resolve(PackageManager pm, String action) {
+        Intent intent = new Intent(action);
+        List<ResolveInfo> resolveInfos = pm.queryIntentActivities(intent, PackageManager.MATCH_ALL);
+        if (resolveInfos.size() != 1) {
+            Log.w(
+                    TAG,
+                    "Failed to resolve activity, action=" + action + ", resolved=" + resolveInfos);
+            return null;
+        }
+        ActivityInfo activityInfo = resolveInfos.getFirst().activityInfo;
+        // MainActivityAlias shows in Launcher
+        return new ComponentName(activityInfo.packageName, activityInfo.name + "Alias");
+    }
+}
diff --git a/android/LinuxInstaller/linux_image_builder/commands b/android/LinuxInstaller/linux_image_builder/commands
new file mode 100644
index 0000000..4d27475
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/commands
@@ -0,0 +1,11 @@
+upload init.sh:/root
+upload vsock.py:/usr/local/bin
+upload /tmp/ttyd:/usr/local/bin
+upload ttyd.service:/etc/systemd/system
+upload vsockip.service:/etc/systemd/system
+chmod 0777:/root/init.sh
+firstboot-command "/root/init.sh"
+chmod 0644:/etc/systemd/system/vsockip.service
+chmod 0644:/etc/systemd/system/ttyd.service
+chmod 0777:/usr/local/bin/vsock.py
+chmod 0777:/usr/local/bin/ttyd
diff --git a/android/LinuxInstaller/linux_image_builder/init.sh b/android/LinuxInstaller/linux_image_builder/init.sh
new file mode 100644
index 0000000..bec5ac5
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/init.sh
@@ -0,0 +1,4 @@
+#!/bin/bash
+systemctl daemon-reload
+systemctl start ttyd && sudo systemctl enable ttyd
+systemctl start vsockip && sudo systemctl enable vsockip
diff --git a/android/LinuxInstaller/linux_image_builder/setup.sh b/android/LinuxInstaller/linux_image_builder/setup.sh
new file mode 100755
index 0000000..2883e61
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/setup.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+pushd $(dirname $0) > /dev/null
+tempdir=$(mktemp -d)
+echo Get Debian image and dependencies...
+wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-nocloud-arm64.raw -O ${tempdir}/debian.img
+wget https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.aarch64 -O ${tempdir}/ttyd
+
+echo Customize the image...
+virt-customize --commands-from-file <(sed "s|/tmp|$tempdir|g" commands) -a ${tempdir}/debian.img
+
+asset_dir=../assets/linux
+mkdir -p ${asset_dir}
+
+echo Copy files...
+
+pushd ${tempdir} > /dev/null
+tar czvS -f images.tar.gz debian.img
+popd > /dev/null
+mv ${tempdir}/images.tar.gz ${asset_dir}/images.tar.gz
+cp vm_config.json ${asset_dir}
+
+echo Calculating hash...
+hash=$(cat ${asset_dir}/images.tar.gz ${asset_dir}/vm_config.json | sha1sum | cut -d' ' -f 1)
+echo ${hash} > ${asset_dir}/hash
+
+popd > /dev/null
+echo Cleaning up...
+rm -rf ${tempdir}
\ No newline at end of file
diff --git a/android/LinuxInstaller/linux_image_builder/setup_x86_64.sh b/android/LinuxInstaller/linux_image_builder/setup_x86_64.sh
new file mode 100755
index 0000000..9748ce2
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/setup_x86_64.sh
@@ -0,0 +1,29 @@
+#!/bin/bash
+
+pushd $(dirname $0) > /dev/null
+tempdir=$(mktemp -d)
+echo Get Debian image and dependencies...
+wget https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-nocloud-amd64.raw -O ${tempdir}/debian.img
+wget https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.tyd.x86_64 -O ${tempdir}/ttyd
+
+echo Customize the image...
+virt-customize --commands-from-file <(sed "s|/tmp|$tempdir|g" commands) -a ${tempdir}/debian.img
+
+asset_dir=../assets/linux
+mkdir -p ${asset_dir}
+
+echo Copy files...
+
+pushd ${tempdir} > /dev/null
+tar czvS -f images.tar.gz debian.img
+popd > /dev/null
+mv ${tempdir}/images.tar.gz ${asset_dir}/images.tar.gz
+cp vm_config.json ${asset_dir}
+
+echo Calculating hash...
+hash=$(cat ${asset_dir}/images.tar.gz ${asset_dir}/vm_config.json | sha1sum | cut -d' ' -f 1)
+echo ${hash} > ${asset_dir}/hash
+
+popd > /dev/null
+echo Cleaning up...
+rm -rf ${tempdir}
\ No newline at end of file
diff --git a/android/LinuxInstaller/linux_image_builder/ttyd.service b/android/LinuxInstaller/linux_image_builder/ttyd.service
new file mode 100644
index 0000000..3a8f181
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/ttyd.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=TTYD
+After=syslog.target
+After=network.target
+[Service]
+ExecStart=/usr/local/bin/ttyd -W login
+Type=simple
+Restart=always
+User=root
+Group=root
+[Install]
+WantedBy=multi-user.target
diff --git a/android/LinuxInstaller/linux_image_builder/vm_config.json b/android/LinuxInstaller/linux_image_builder/vm_config.json
new file mode 100644
index 0000000..21462b8
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/vm_config.json
@@ -0,0 +1,19 @@
+{
+    "name": "debian",
+    "disks": [
+        {
+            "image": "/data/local/tmp/debian.img",
+            "partitions": [],
+            "writable": true
+        }
+    ],
+    "protected": false,
+    "cpu_topology": "match_host",
+    "platform_version": "~1.0",
+    "memory_mib": 4096,
+    "debuggable": true,
+    "console_out": true,
+    "connect_console": true,
+    "console_input_device": "ttyS0",
+    "network": true
+}
diff --git a/android/LinuxInstaller/linux_image_builder/vsock.py b/android/LinuxInstaller/linux_image_builder/vsock.py
new file mode 100644
index 0000000..292d953
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/vsock.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+
+import socket
+
+# Constants for vsock (from linux/vm_sockets.h)
+AF_VSOCK = 40
+SOCK_STREAM = 1
+VMADDR_CID_ANY = -1
+
+def get_local_ip():
+    """Retrieves the first IPv4 address found on the system.
+
+    Returns:
+        str: The local IPv4 address, or '127.0.0.1' if no IPv4 address is found.
+    """
+
+    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
+    try:
+        s.connect(('8.8.8.8', 80))
+        ip = s.getsockname()[0]
+    except Exception:
+        ip = '127.0.0.1'
+    finally:
+        s.close()
+    return ip
+
+def main():
+    PORT = 1024
+
+    # Create a vsock socket
+    server_socket = socket.socket(AF_VSOCK, SOCK_STREAM)
+
+    # Bind the socket to the server address
+    server_address = (VMADDR_CID_ANY, PORT)
+    server_socket.bind(server_address)
+
+    # Listen for incoming connections
+    server_socket.listen(1)
+    print(f"VSOCK server listening on port {PORT}...")
+
+    while True:
+        # Accept a connection
+        connection, client_address = server_socket.accept()
+        print(f"Connection from: {client_address}")
+
+        try:
+            # Get the local IP address
+            local_ip = get_local_ip()
+
+            # Send the IP address to the client
+            connection.sendall(local_ip.encode())
+        finally:
+            # Close the connection
+            connection.close()
+
+if __name__ == "__main__":
+    main()
diff --git a/android/LinuxInstaller/linux_image_builder/vsockip.service b/android/LinuxInstaller/linux_image_builder/vsockip.service
new file mode 100644
index 0000000..a29020b
--- /dev/null
+++ b/android/LinuxInstaller/linux_image_builder/vsockip.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=vsock ip service
+After=syslog.target
+After=network.target
+[Service]
+ExecStart=/usr/bin/python3 /usr/local/bin/vsock.py
+Type=simple
+Restart=always
+User=root
+Group=root
+[Install]
+WantedBy=multi-user.target
diff --git a/android/LinuxInstaller/privapp-permissions-linuxinstaller.xml b/android/LinuxInstaller/privapp-permissions-linuxinstaller.xml
new file mode 100644
index 0000000..e46ec97
--- /dev/null
+++ b/android/LinuxInstaller/privapp-permissions-linuxinstaller.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<permissions>
+    <privapp-permissions package="com.android.virtualization.linuxinstaller">
+        <permission name="android.permission.CHANGE_COMPONENT_ENABLED_STATE"/>
+    </privapp-permissions>
+</permissions>
\ No newline at end of file
diff --git a/android/LinuxInstaller/res/layout/activity_main.xml b/android/LinuxInstaller/res/layout/activity_main.xml
new file mode 100644
index 0000000..3967167
--- /dev/null
+++ b/android/LinuxInstaller/res/layout/activity_main.xml
@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<RelativeLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:fitsSystemWindows="true"
+    android:paddingLeft="16dp"
+    android:paddingRight="16dp">
+  <TextView
+      android:layout_width="match_parent"
+      android:layout_height="wrap_content"
+      android:textSize="28sp"
+      android:id="@+id/status_txt_view"/>
+
+</RelativeLayout>
diff --git a/android/TerminalApp/Android.bp b/android/TerminalApp/Android.bp
new file mode 100644
index 0000000..3ae014e
--- /dev/null
+++ b/android/TerminalApp/Android.bp
@@ -0,0 +1,19 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+android_app {
+    name: "VmTerminalApp",
+    srcs: ["java/**/*.java"],
+    resource_dirs: ["res"],
+    static_libs: [
+        "vm_launcher_lib",
+    ],
+    sdk_version: "system_current",
+    optimize: {
+        shrink_resources: true,
+    },
+    apex_available: [
+        "com.android.virt",
+    ],
+}
diff --git a/android/TerminalApp/AndroidManifest.xml b/android/TerminalApp/AndroidManifest.xml
new file mode 100644
index 0000000..c92da67
--- /dev/null
+++ b/android/TerminalApp/AndroidManifest.xml
@@ -0,0 +1,32 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="com.android.virtualization.terminal" >
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="com.android.virtualization.vmlauncher.permission.USE_VM_LAUNCHER"/>
+
+    <application
+	android:label="@string/app_name"
+        android:icon="@mipmap/ic_launcher"
+        android:usesCleartextTraffic="true">
+        <activity android:name=".MainActivity"
+                  android:configChanges="orientation|screenSize|keyboard|keyboardHidden|navigation|uiMode|screenLayout|smallestScreenSize"
+                  android:exported="true">
+            <intent-filter>
+                <action android:name="android.virtualization.VM_TERMINAL" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </activity>
+        <activity-alias
+            android:name=".MainActivityAlias"
+            android:targetActivity="com.android.virtualization.terminal.MainActivity"
+            android:exported="true"
+            android:enabled="false" >
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity-alias>
+    </application>
+
+</manifest>
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
new file mode 100644
index 0000000..a6723fb
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2024 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.virtualization.terminal;
+
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.util.Log;
+import android.view.Menu;
+import android.view.MenuItem;
+import android.webkit.WebChromeClient;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+import android.widget.TextView;
+import android.widget.Toast;
+
+import com.android.virtualization.vmlauncher.VmLauncherServices;
+
+public class MainActivity extends Activity implements VmLauncherServices.VmLauncherServiceCallback {
+    private static final String TAG = "VmTerminalApp";
+    private String mVmIpAddr;
+    private WebView mWebView;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        Toast.makeText(this, R.string.vm_creation_message, Toast.LENGTH_SHORT).show();
+        VmLauncherServices.startVmLauncherService(this, this);
+
+        setContentView(R.layout.activity_headless);
+        mWebView = (WebView) findViewById(R.id.webview);
+        mWebView.getSettings().setDatabaseEnabled(true);
+        mWebView.getSettings().setDomStorageEnabled(true);
+        mWebView.getSettings().setJavaScriptEnabled(true);
+        mWebView.setWebChromeClient(new WebChromeClient());
+        mWebView.setWebViewClient(
+                new WebViewClient() {
+                    @Override
+                    public boolean shouldOverrideUrlLoading(WebView view, String url) {
+                        view.loadUrl(url);
+                        return true;
+                    }
+                });
+    }
+
+    @Override
+    protected void onDestroy() {
+        VmLauncherServices.stopVmLauncherService(this);
+        super.onDestroy();
+    }
+
+    private void gotoURL(String url) {
+        runOnUiThread(() -> mWebView.loadUrl(url));
+    }
+
+    public void onVmStart() {
+        Log.i(TAG, "onVmStart()");
+    }
+
+    public void onVmStop() {
+        Toast.makeText(this, R.string.vm_stop_message, Toast.LENGTH_SHORT).show();
+        Log.i(TAG, "onVmStop()");
+        finish();
+    }
+
+    public void onVmError() {
+        Toast.makeText(this, R.string.vm_error_message, Toast.LENGTH_SHORT).show();
+        Log.i(TAG, "onVmError()");
+        finish();
+    }
+
+    public void onIpAddrAvailable(String ipAddr) {
+        mVmIpAddr = ipAddr;
+        ((TextView) findViewById(R.id.ip_addr_textview)).setText(mVmIpAddr);
+
+        // TODO(b/359523803): Use AVF API to be notified when shell is ready instead of using dealy
+        new Handler(Looper.getMainLooper())
+                .postDelayed(() -> gotoURL("http://" + mVmIpAddr + ":7681"), 2000);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        getMenuInflater().inflate(R.menu.main_menu, menu);
+        return true;
+    }
+
+    @Override
+    public boolean onMenuItemSelected(int featureId, MenuItem item) {
+        int id = item.getItemId();
+        if (id == R.id.copy_ip_addr) {
+            // TODO(b/340126051): remove this menu item when port forwarding is supported.
+            getSystemService(ClipboardManager.class)
+                    .setPrimaryClip(ClipData.newPlainText("A VM's IP address", mVmIpAddr));
+            return true;
+        } else if (id == R.id.stop_vm) {
+            VmLauncherServices.stopVmLauncherService(this);
+            return true;
+        }
+        return super.onMenuItemSelected(featureId, item);
+    }
+}
diff --git a/android/TerminalApp/res/drawable/ic_launcher_background.xml b/android/TerminalApp/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..07d5da9
--- /dev/null
+++ b/android/TerminalApp/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="108"
+    android:viewportHeight="108">
+    <path
+        android:fillColor="#3DDC84"
+        android:pathData="M0,0h108v108h-108z" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M9,0L9,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,0L19,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,0L29,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,0L39,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,0L49,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,0L59,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,0L69,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,0L79,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M89,0L89,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M99,0L99,108"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,9L108,9"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,19L108,19"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,29L108,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,39L108,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,49L108,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,59L108,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,69L108,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,79L108,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,89L108,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M0,99L108,99"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,29L89,29"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,39L89,39"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,49L89,49"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,59L89,59"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,69L89,69"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M19,79L89,79"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M29,19L29,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M39,19L39,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M49,19L49,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M59,19L59,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M69,19L69,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+    <path
+        android:fillColor="#00000000"
+        android:pathData="M79,19L79,89"
+        android:strokeWidth="0.8"
+        android:strokeColor="#33FFFFFF" />
+</vector>
diff --git a/android/TerminalApp/res/drawable/ic_launcher_foreground.xml b/android/TerminalApp/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..8b28c8e
--- /dev/null
+++ b/android/TerminalApp/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="142"
+    android:viewportHeight="168.75">
+  <group android:scaleX="0.37325713"
+      android:scaleY="0.44357142"
+      android:translateX="43.332314"
+      android:translateY="39.324776">
+    <group android:translateY="133.59375">
+      <path android:pathData="M9.078125,-77.484375L69.75,-51.40625L69.75,-37.765625L9.078125,-11.609375L9.078125,-28.40625L52.53125,-44.71875L9.078125,-60.75L9.078125,-77.484375Z"
+          android:fillColor="#3BBA46"/>
+      <path android:pathData="M139.76562,0L139.76562,13.5L75.21875,13.5L75.21875,0L139.76562,0Z"
+          android:fillColor="#3BBA46"/>
+    </group>
+  </group>
+</vector>
\ No newline at end of file
diff --git a/android/TerminalApp/res/layout/activity_headless.xml b/android/TerminalApp/res/layout/activity_headless.xml
new file mode 100644
index 0000000..3fe5271
--- /dev/null
+++ b/android/TerminalApp/res/layout/activity_headless.xml
@@ -0,0 +1,20 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout 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:orientation="vertical"
+    android:fitsSystemWindows="true"
+    tools:context=".MainActivity">
+    <TextView
+        android:id="@+id/ip_addr_textview"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content" />
+  <WebView
+      android:id="@+id/webview"
+      android:layout_width="match_parent"
+      android:layout_height="match_parent"
+      android:layout_marginBottom="5dp" />
+
+</LinearLayout>
diff --git a/android/TerminalApp/res/menu/main_menu.xml b/android/TerminalApp/res/menu/main_menu.xml
new file mode 100644
index 0000000..cc34cda
--- /dev/null
+++ b/android/TerminalApp/res/menu/main_menu.xml
@@ -0,0 +1,7 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android">
+    <item android:id="@+id/copy_ip_addr"
+        android:title="Copy the IP address"/>
+    <item android:id="@+id/stop_vm"
+        android:title="Stop the existing VM instance"/>
+</menu>
\ No newline at end of file
diff --git a/android/TerminalApp/res/mipmap-anydpi-v26/ic_launcher.xml b/android/TerminalApp/res/mipmap-anydpi-v26/ic_launcher.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/android/TerminalApp/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+</adaptive-icon>
\ No newline at end of file
diff --git a/android/TerminalApp/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/TerminalApp/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/android/TerminalApp/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+</adaptive-icon>
\ No newline at end of file
diff --git a/android/TerminalApp/res/mipmap-hdpi/ic_launcher_round.webp b/android/TerminalApp/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..9be8219
--- /dev/null
+++ b/android/TerminalApp/res/mipmap-hdpi/ic_launcher_round.webp
Binary files differ
diff --git a/android/TerminalApp/res/mipmap-mdpi/ic_launcher_round.webp b/android/TerminalApp/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..662c81e
--- /dev/null
+++ b/android/TerminalApp/res/mipmap-mdpi/ic_launcher_round.webp
Binary files differ
diff --git a/android/TerminalApp/res/mipmap-xhdpi/ic_launcher_round.webp b/android/TerminalApp/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..2d7990d
--- /dev/null
+++ b/android/TerminalApp/res/mipmap-xhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/android/TerminalApp/res/mipmap-xxhdpi/ic_launcher_round.webp b/android/TerminalApp/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..7941000
--- /dev/null
+++ b/android/TerminalApp/res/mipmap-xxhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/android/TerminalApp/res/mipmap-xxxhdpi/ic_launcher_round.webp b/android/TerminalApp/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..55f8020
--- /dev/null
+++ b/android/TerminalApp/res/mipmap-xxxhdpi/ic_launcher_round.webp
Binary files differ
diff --git a/android/TerminalApp/res/values/ic_launcher_background.xml b/android/TerminalApp/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..337764a
--- /dev/null
+++ b/android/TerminalApp/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_launcher_background">#070E1E</color>
+</resources>
\ No newline at end of file
diff --git a/android/TerminalApp/res/values/strings.xml b/android/TerminalApp/res/values/strings.xml
new file mode 100644
index 0000000..79da7cd
--- /dev/null
+++ b/android/TerminalApp/res/values/strings.xml
@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--  Copyright (C) 2024 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.
+ -->
+
+<resources xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
+    <string name="app_name">Terminal</string>
+    <string name="vm_creation_message">Virtual machine is booting. Please wait.</string>
+    <string name="vm_stop_message">Virtual machine is stopped. Exiting.</string>
+    <string name="vm_error_message">Virtual machine crashed. Exiting.</string>
+</resources>
diff --git a/android/VmLauncherApp/AndroidManifest.xml b/android/VmLauncherApp/AndroidManifest.xml
index 67b7a45..583fce7 100644
--- a/android/VmLauncherApp/AndroidManifest.xml
+++ b/android/VmLauncherApp/AndroidManifest.xml
@@ -6,6 +6,8 @@
     <uses-permission android:name="android.permission.USE_CUSTOM_VIRTUAL_MACHINE" />
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.RECORD_AUDIO" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
+    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
     <uses-feature android:name="android.software.virtualization_framework" android:required="true" />
 
     <permission android:name="com.android.virtualization.vmlauncher.permission.USE_VM_LAUNCHER"
@@ -26,6 +28,21 @@
                 <category android:name="android.intent.category.DEFAULT" />
             </intent-filter>
         </activity>
+        <service
+            android:name=".VmLauncherService"
+            android:enabled="true"
+            android:exported="true"
+            android:permission="com.android.virtualization.vmlauncher.permission.USE_VM_LAUNCHER"
+            android:foregroundServiceType="specialUse">
+            <property
+                android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
+                android:value="Run VM instances" />
+            <intent-filter>
+                <action android:name="android.virtualization.START_VM_LAUNCHER_SERVICE" />
+                <category android:name="android.intent.category.DEFAULT" />
+            </intent-filter>
+        </service>
+
     </application>
 
 </manifest>
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ClipboardHandler.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ClipboardHandler.java
new file mode 100644
index 0000000..def464e
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ClipboardHandler.java
@@ -0,0 +1,84 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.util.Log;
+
+import java.nio.charset.StandardCharsets;
+
+/** Provide methods to synchronize clipboard across Android and VM. */
+class ClipboardHandler {
+    private static final String TAG = MainActivity.TAG;
+    private final ClipboardManager mClipboardManager;
+    private final VmAgent mVmAgent;
+
+    ClipboardHandler(Context context, VmAgent vmAgent) {
+        mClipboardManager = context.getSystemService(ClipboardManager.class);
+        mVmAgent = vmAgent;
+    }
+
+    private VmAgent.Connection getConnection() throws InterruptedException {
+        return mVmAgent.connect();
+    }
+
+    /** Read a text clip from Android's clipboard and send it to VM. */
+    void writeClipboardToVm() {
+        if (!mClipboardManager.hasPrimaryClip()) {
+            return;
+        }
+
+        ClipData clip = mClipboardManager.getPrimaryClip();
+        String text = clip.getItemAt(0).getText().toString();
+        // TODO: remove this trailing null character. The size is already encoded in the header.
+        text = text + '\0';
+        // TODO: use UTF-8 encoding
+        byte[] data = text.getBytes();
+
+        try {
+            getConnection().sendData(VmAgent.WRITE_CLIPBOARD_TYPE_TEXT_PLAIN, data);
+        } catch (InterruptedException | RuntimeException e) {
+            Log.e(TAG, "Failed to write clipboard data to VM", e);
+        }
+    }
+
+    /** Read a text clip from VM and paste it to Android's clipboard. */
+    void readClipboardFromVm() {
+        VmAgent.Data data;
+        try {
+            data = getConnection().sendAndReceive(VmAgent.READ_CLIPBOARD_FROM_VM, null);
+        } catch (InterruptedException | RuntimeException e) {
+            Log.e(TAG, "Failed to read clipboard data from VM", e);
+            return;
+        }
+
+        switch (data.type) {
+            case VmAgent.WRITE_CLIPBOARD_TYPE_EMPTY:
+                Log.d(TAG, "clipboard data from VM is empty");
+                break;
+            case VmAgent.WRITE_CLIPBOARD_TYPE_TEXT_PLAIN:
+                String text = new String(data.data, StandardCharsets.UTF_8);
+                ClipData clip = ClipData.newPlainText(null, text);
+                mClipboardManager.setPrimaryClip(clip);
+                break;
+            default:
+                Log.e(TAG, "Unknown clipboard response type: " + data.type);
+        }
+    }
+}
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmConfigJson.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ConfigJson.java
similarity index 73%
rename from android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmConfigJson.java
rename to android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ConfigJson.java
index 332b9f5..6d39b46 100644
--- a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmConfigJson.java
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/ConfigJson.java
@@ -36,10 +36,10 @@
 import java.util.Arrays;
 
 /** This class and its inner classes model vm_config.json. */
-class VmConfigJson {
+class ConfigJson {
     private static final boolean DEBUG = true;
 
-    private VmConfigJson() {}
+    private ConfigJson() {}
 
     @SerializedName("protected")
     private boolean isProtected;
@@ -53,14 +53,20 @@
     private String kernel;
     private String initrd;
     private String params;
+    private boolean debuggable;
+    private boolean console_out;
+    private boolean connect_console;
+    private boolean network;
+    private InputJson input;
+    private AudioJson audio;
     private DiskJson[] disks;
     private DisplayJson display;
     private GpuJson gpu;
 
     /** Parses JSON file at jsonPath */
-    static VmConfigJson from(String jsonPath) {
+    static ConfigJson from(String jsonPath) {
         try (FileReader r = new FileReader(jsonPath)) {
-            return new Gson().fromJson(r, VmConfigJson.class);
+            return new Gson().fromJson(r, ConfigJson.class);
         } catch (Exception e) {
             throw new RuntimeException("Failed to parse " + jsonPath, e);
         }
@@ -77,23 +83,24 @@
         }
     }
 
+    private int getDebugLevel() {
+        return debuggable
+                ? VirtualMachineConfig.DEBUG_LEVEL_FULL
+                : VirtualMachineConfig.DEBUG_LEVEL_NONE;
+    }
+
     /** Converts this parsed JSON into VirtualMachieConfig */
     VirtualMachineConfig toConfig(Context context) {
-        VirtualMachineConfig.Builder builder = new VirtualMachineConfig.Builder(context);
-        builder.setProtectedVm(isProtected)
+        return new VirtualMachineConfig.Builder(context)
+                .setProtectedVm(isProtected)
                 .setMemoryBytes((long) memory_mib * 1024 * 1024)
                 .setConsoleInputDevice(console_input_device)
                 .setCpuTopology(getCpuTopology())
-                .setCustomImageConfig(toCustomImageConfig(context));
-
-        // TODO: make these configurable via json
-        if (DEBUG) {
-            builder.setDebugLevel(VirtualMachineConfig.DEBUG_LEVEL_FULL)
-                    .setVmOutputCaptured(true)
-                    .setConnectVmConsole(true);
-        }
-
-        return builder.build();
+                .setCustomImageConfig(toCustomImageConfig(context))
+                .setDebugLevel(getDebugLevel())
+                .setVmOutputCaptured(console_out)
+                .setConnectVmConsole(connect_console)
+                .build();
     }
 
     private VirtualMachineCustomImageConfig toCustomImageConfig(Context context) {
@@ -103,32 +110,64 @@
         builder.setName(name)
                 .setBootloaderPath(bootloader)
                 .setKernelPath(kernel)
-                .setInitrdPath(initrd);
+                .setInitrdPath(initrd)
+                .useNetwork(network);
+
+        if (input != null) {
+            builder.useTouch(input.touchscreen)
+                    .useKeyboard(input.keyboard)
+                    .useMouse(input.mouse)
+                    .useTrackpad(input.trackpad)
+                    .useSwitches(input.switches);
+        }
+
+        if (audio != null) {
+            builder.setAudioConfig(audio.toConfig());
+        }
+
+        if (display != null) {
+            builder.setDisplayConfig(display.toConfig(context));
+        }
+
+        if (gpu != null) {
+            builder.setGpuConfig(gpu.toConfig());
+        }
+
         if (params != null) {
             Arrays.stream(params.split(" ")).forEach(builder::addParam);
         }
 
-        // TODO: make these configurable via json
-        builder.useTouch(true)
-                .useKeyboard(true)
-                .useMouse(true)
-                .useSwitches(true)
-                .useTrackpad(true)
-                .useNetwork(true)
-                .setAudioConfig(
-                        new AudioConfig.Builder()
-                                .setUseMicrophone(true)
-                                .setUseSpeaker(true)
-                                .build());
-
-        for (DiskJson d : disks) {
-            builder.addDisk(d.toConfig());
+        if (disks != null) {
+            Arrays.stream(disks).map(d -> d.toConfig()).forEach(builder::addDisk);
         }
-        builder.setDisplayConfig(display.toConfig(context)).setGpuConfig(gpu.toConfig());
 
         return builder.build();
     }
 
+    private static class InputJson {
+        private InputJson() {}
+
+        private boolean touchscreen;
+        private boolean keyboard;
+        private boolean mouse;
+        private boolean switches;
+        private boolean trackpad;
+    }
+
+    private static class AudioJson {
+        private AudioJson() {}
+
+        private boolean microphone;
+        private boolean speaker;
+
+        private AudioConfig toConfig() {
+            return new AudioConfig.Builder()
+                    .setUseMicrophone(microphone)
+                    .setUseSpeaker(speaker)
+                    .build();
+        }
+    }
+
     private static class DiskJson {
         private DiskJson() {}
 
@@ -160,15 +199,16 @@
 
         private float scale;
         private int refresh_rate;
+        private int width_pixels;
+        private int height_pixels;
 
         private DisplayConfig toConfig(Context context) {
             WindowManager wm = context.getSystemService(WindowManager.class);
             WindowMetrics metrics = wm.getCurrentWindowMetrics();
             Rect dispBounds = metrics.getBounds();
 
-            // TODO: make this overridable by json
-            int width = dispBounds.right;
-            int height = dispBounds.bottom;
+            int width = width_pixels > 0 ? width_pixels : dispBounds.right;
+            int height = height_pixels > 0 ? height_pixels : dispBounds.bottom;
 
             int dpi = (int) (DisplayMetrics.DENSITY_DEFAULT * metrics.getDensity());
             if (scale > 0.0f) {
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/DisplayProvider.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/DisplayProvider.java
new file mode 100644
index 0000000..6eba709
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/DisplayProvider.java
@@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.crosvm.ICrosvmAndroidDisplayService;
+import android.graphics.PixelFormat;
+import android.os.IBinder;
+import android.os.ParcelFileDescriptor;
+import android.os.RemoteException;
+import android.os.ServiceManager;
+import android.system.virtualizationservice_internal.IVirtualizationServiceInternal;
+import android.util.Log;
+import android.view.SurfaceControl;
+import android.view.SurfaceHolder;
+import android.view.SurfaceView;
+
+import libcore.io.IoBridge;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/** Presents Android-side surface where VM can use as a display */
+class DisplayProvider {
+    private static final String TAG = MainActivity.TAG;
+    private final SurfaceView mMainView;
+    private final SurfaceView mCursorView;
+    private final IVirtualizationServiceInternal mVirtService;
+    private CursorHandler mCursorHandler;
+
+    DisplayProvider(SurfaceView mainView, SurfaceView cursorView) {
+        mMainView = mainView;
+        mCursorView = cursorView;
+
+        mMainView.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT);
+        mMainView.getHolder().addCallback(new Callback(Callback.SurfaceKind.MAIN));
+
+        mCursorView.setSurfaceLifecycle(SurfaceView.SURFACE_LIFECYCLE_FOLLOWS_ATTACHMENT);
+        mCursorView.getHolder().addCallback(new Callback(Callback.SurfaceKind.CURSOR));
+        mCursorView.getHolder().setFormat(PixelFormat.RGBA_8888);
+        // TODO: do we need this z-order?
+        mCursorView.setZOrderMediaOverlay(true);
+
+        IBinder b = ServiceManager.waitForService("android.system.virtualizationservice");
+        mVirtService = IVirtualizationServiceInternal.Stub.asInterface(b);
+        try {
+            // To ensure that the previous display service is removed.
+            mVirtService.clearDisplayService();
+        } catch (RemoteException e) {
+            throw new RuntimeException("Failed to clear prior display service", e);
+        }
+    }
+
+    void notifyDisplayIsGoingToInvisible() {
+        // When the display is going to be invisible (by putting in the background), save the frame
+        // of the main surface so that we can re-draw it next time the display becomes visible. This
+        // is to save the duration of time where nothing is drawn by VM.
+        try {
+            getDisplayService().saveFrameForSurface(false /* forCursor */);
+        } catch (RemoteException e) {
+            throw new RuntimeException("Failed to save frame for the main surface", e);
+        }
+    }
+
+    private synchronized ICrosvmAndroidDisplayService getDisplayService() {
+        try {
+            IBinder b = mVirtService.waitDisplayService();
+            return ICrosvmAndroidDisplayService.Stub.asInterface(b);
+        } catch (Exception e) {
+            throw new RuntimeException("Error while getting display service", e);
+        }
+    }
+
+    private class Callback implements SurfaceHolder.Callback {
+        enum SurfaceKind {
+            MAIN,
+            CURSOR
+        }
+
+        private final SurfaceKind mSurfaceKind;
+
+        Callback(SurfaceKind kind) {
+            mSurfaceKind = kind;
+        }
+
+        private boolean isForCursor() {
+            return mSurfaceKind == SurfaceKind.CURSOR;
+        }
+
+        @Override
+        public void surfaceCreated(SurfaceHolder holder) {
+            try {
+                getDisplayService().setSurface(holder.getSurface(), isForCursor());
+            } catch (Exception e) {
+                // TODO: don't consume this exception silently. For some unknown reason, setSurface
+                // call above throws IllegalArgumentException and that fails the surface
+                // configuration.
+                Log.e(TAG, "Failed to present surface " + mSurfaceKind + " to VM", e);
+            }
+
+            try {
+                switch (mSurfaceKind) {
+                    case MAIN:
+                        getDisplayService().drawSavedFrameForSurface(isForCursor());
+                        break;
+                    case CURSOR:
+                        ParcelFileDescriptor stream = createNewCursorStream();
+                        getDisplayService().setCursorStream(stream);
+                        break;
+                }
+            } catch (Exception e) {
+                // TODO: don't consume exceptions here too
+                Log.e(TAG, "Failed to configure surface " + mSurfaceKind, e);
+            }
+        }
+
+        @Override
+        public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
+            // TODO: support resizeable display. We could actually change the display size that the
+            // VM sees, or keep the size and render it by fitting it in the new surface.
+        }
+
+        @Override
+        public void surfaceDestroyed(SurfaceHolder holder) {
+            try {
+                getDisplayService().removeSurface(isForCursor());
+            } catch (RemoteException e) {
+                throw new RuntimeException("Error while destroying surface for " + mSurfaceKind, e);
+            }
+        }
+    }
+
+    private ParcelFileDescriptor createNewCursorStream() {
+        if (mCursorHandler != null) {
+            mCursorHandler.interrupt();
+        }
+        ParcelFileDescriptor[] pfds;
+        try {
+            pfds = ParcelFileDescriptor.createSocketPair();
+        } catch (IOException e) {
+            throw new RuntimeException("Failed to create socketpair for cursor stream", e);
+        }
+        mCursorHandler = new CursorHandler(pfds[0]);
+        mCursorHandler.start();
+        return pfds[1];
+    }
+
+    /**
+     * Thread reading cursor coordinate from a stream, and updating the position of the cursor
+     * surface accordingly.
+     */
+    private class CursorHandler extends Thread {
+        private final ParcelFileDescriptor mStream;
+        private final SurfaceControl mCursor;
+        private final SurfaceControl.Transaction mTransaction;
+
+        CursorHandler(ParcelFileDescriptor stream) {
+            mStream = stream;
+            mCursor = DisplayProvider.this.mCursorView.getSurfaceControl();
+            mTransaction = new SurfaceControl.Transaction();
+
+            SurfaceControl main = DisplayProvider.this.mMainView.getSurfaceControl();
+            mTransaction.reparent(mCursor, main).apply();
+        }
+
+        @Override
+        public void run() {
+            try {
+                ByteBuffer byteBuffer = ByteBuffer.allocate(8 /* (x: u32, y: u32) */);
+                byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
+                while (true) {
+                    if (Thread.interrupted()) {
+                        Log.d(TAG, "CursorHandler thread interrupted!");
+                        return;
+                    }
+                    byteBuffer.clear();
+                    int bytes =
+                            IoBridge.read(
+                                    mStream.getFileDescriptor(),
+                                    byteBuffer.array(),
+                                    0,
+                                    byteBuffer.array().length);
+                    if (bytes == -1) {
+                        Log.e(TAG, "cannot read from cursor stream, stop the handler");
+                        return;
+                    }
+                    float x = (float) (byteBuffer.getInt() & 0xFFFFFFFF);
+                    float y = (float) (byteBuffer.getInt() & 0xFFFFFFFF);
+                    mTransaction.setPosition(mCursor, x, y).apply();
+                }
+            } catch (IOException e) {
+                Log.e(TAG, "failed to run CursorHandler", e);
+            }
+        }
+    }
+}
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/InputForwarder.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/InputForwarder.java
new file mode 100644
index 0000000..1be362b
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/InputForwarder.java
@@ -0,0 +1,148 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.content.Context;
+import android.hardware.input.InputManager;
+import android.os.Handler;
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig;
+import android.util.Log;
+import android.view.InputDevice;
+import android.view.KeyEvent;
+import android.view.View;
+
+/** Forwards input events (touch, mouse, ...) from Android to VM */
+class InputForwarder {
+    private static final String TAG = MainActivity.TAG;
+    private final Context mContext;
+    private final VirtualMachine mVirtualMachine;
+    private InputManager.InputDeviceListener mInputDeviceListener;
+
+    private boolean isTabletMode = false;
+
+    InputForwarder(
+            Context context,
+            VirtualMachine vm,
+            View touchReceiver,
+            View mouseReceiver,
+            View keyReceiver) {
+        mContext = context;
+        mVirtualMachine = vm;
+
+        VirtualMachineCustomImageConfig config = vm.getConfig().getCustomImageConfig();
+        if (config.useTouch()) {
+            setupTouchReceiver(touchReceiver);
+        }
+        if (config.useMouse() || config.useTrackpad()) {
+            setupMouseReceiver(mouseReceiver);
+        }
+        if (config.useKeyboard()) {
+            setupKeyReceiver(keyReceiver);
+        }
+        if (config.useSwitches()) {
+            // Any view's handler is fine.
+            setupTabletModeHandler(touchReceiver.getHandler());
+        }
+    }
+
+    void cleanUp() {
+        if (mInputDeviceListener != null) {
+            InputManager im = mContext.getSystemService(InputManager.class);
+            im.unregisterInputDeviceListener(mInputDeviceListener);
+            mInputDeviceListener = null;
+        }
+    }
+
+    private void setupTouchReceiver(View receiver) {
+        receiver.setOnTouchListener(
+                (v, event) -> {
+                    return mVirtualMachine.sendMultiTouchEvent(event);
+                });
+    }
+
+    private void setupMouseReceiver(View receiver) {
+        receiver.requestUnbufferedDispatch(InputDevice.SOURCE_ANY);
+        receiver.setOnCapturedPointerListener(
+                (v, event) -> {
+                    int eventSource = event.getSource();
+                    if ((eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0) {
+                        return mVirtualMachine.sendTrackpadEvent(event);
+                    }
+                    return mVirtualMachine.sendMouseEvent(event);
+                });
+    }
+
+    private void setupKeyReceiver(View receiver) {
+        receiver.setOnKeyListener(
+                (v, code, event) -> {
+                    // TODO: this is guest-os specific. It shouldn't be handled here.
+                    if (isVolumeKey(code)) {
+                        return false;
+                    }
+                    return mVirtualMachine.sendKeyEvent(event);
+                });
+    }
+
+    private static boolean isVolumeKey(int keyCode) {
+        return keyCode == KeyEvent.KEYCODE_VOLUME_UP
+                || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
+                || keyCode == KeyEvent.KEYCODE_VOLUME_MUTE;
+    }
+
+    private void setupTabletModeHandler(Handler handler) {
+        InputManager im = mContext.getSystemService(InputManager.class);
+        mInputDeviceListener =
+                new InputManager.InputDeviceListener() {
+                    @Override
+                    public void onInputDeviceAdded(int deviceId) {
+                        setTabletModeConditionally();
+                    }
+
+                    @Override
+                    public void onInputDeviceRemoved(int deviceId) {
+                        setTabletModeConditionally();
+                    }
+
+                    @Override
+                    public void onInputDeviceChanged(int deviceId) {
+                        setTabletModeConditionally();
+                    }
+                };
+        im.registerInputDeviceListener(mInputDeviceListener, handler);
+    }
+
+    private static boolean hasPhysicalKeyboard() {
+        for (int id : InputDevice.getDeviceIds()) {
+            InputDevice d = InputDevice.getDevice(id);
+            if (!d.isVirtual() && d.isEnabled() && d.isFullKeyboard()) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    void setTabletModeConditionally() {
+        boolean tabletModeNeeded = !hasPhysicalKeyboard();
+        if (tabletModeNeeded != isTabletMode) {
+            String mode = tabletModeNeeded ? "tablet mode" : "desktop mode";
+            Log.d(TAG, "switching to " + mode);
+            isTabletMode = tabletModeNeeded;
+            mVirtualMachine.sendTabletModeEvent(tabletModeNeeded);
+        }
+    }
+}
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/Logger.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/Logger.java
new file mode 100644
index 0000000..e1cb285
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/Logger.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineException;
+import android.util.Log;
+
+import libcore.io.Streams;
+
+import java.io.BufferedOutputStream;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Forwards VM's console output to a file on the Android side, and VM's log output to Android logd.
+ */
+class Logger {
+    private Logger() {}
+
+    static void setup(VirtualMachine vm, Path path, ExecutorService executor) {
+        if (vm.getConfig().getDebugLevel() != VirtualMachineConfig.DEBUG_LEVEL_FULL) {
+            return;
+        }
+
+        try {
+            InputStream console = vm.getConsoleOutput();
+            OutputStream file = Files.newOutputStream(path, StandardOpenOption.CREATE);
+            executor.submit(() -> Streams.copy(console, new LineBufferedOutputStream(file)));
+
+            InputStream log = vm.getLogOutput();
+            executor.submit(() -> writeToLogd(log, vm.getName()));
+        } catch (VirtualMachineException | IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static boolean writeToLogd(InputStream input, String vmName) throws IOException {
+        BufferedReader reader = new BufferedReader(new InputStreamReader(input));
+        String line;
+        while ((line = reader.readLine()) != null && !Thread.interrupted()) {
+            Log.d(vmName, line);
+        }
+        // TODO: find out why javac complains when the return type of this method is void. It
+        // (incorrectly?) thinks that IOException should be caught inside the lambda.
+        return true;
+    }
+
+    private static class LineBufferedOutputStream extends BufferedOutputStream {
+        LineBufferedOutputStream(OutputStream out) {
+            super(out);
+        }
+
+        @Override
+        public void write(byte[] buf, int off, int len) throws IOException {
+            super.write(buf, off, len);
+            for (int i = 0; i < len; ++i) {
+                if (buf[off + i] == '\n') {
+                    flush();
+                    break;
+                }
+            }
+        }
+    }
+}
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
index 160140a..fb75533 100644
--- a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/MainActivity.java
@@ -20,54 +20,27 @@
 
 import android.Manifest.permission;
 import android.app.Activity;
-import android.content.ClipData;
-import android.content.ClipboardManager;
 import android.content.Intent;
-import android.crosvm.ICrosvmAndroidDisplayService;
-import android.graphics.PixelFormat;
-import android.hardware.input.InputManager;
 import android.os.Bundle;
-import android.os.ParcelFileDescriptor;
-import android.os.RemoteException;
-import android.os.ServiceManager;
-import android.system.virtualizationservice_internal.IVirtualizationServiceInternal;
 import android.system.virtualmachine.VirtualMachine;
-import android.system.virtualmachine.VirtualMachineCallback;
 import android.system.virtualmachine.VirtualMachineConfig;
 import android.system.virtualmachine.VirtualMachineException;
-import android.system.virtualmachine.VirtualMachineManager;
 import android.util.Log;
-import android.view.InputDevice;
-import android.view.KeyEvent;
-import android.view.SurfaceControl;
-import android.view.SurfaceHolder;
 import android.view.SurfaceView;
 import android.view.View;
+import android.view.Window;
 import android.view.WindowInsets;
 import android.view.WindowInsetsController;
-import android.view.WindowManager;
 
-import libcore.io.IoBridge;
-
-import java.io.BufferedOutputStream;
-import java.io.BufferedReader;
-import java.io.FileInputStream;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.OutputStream;
-import java.nio.ByteBuffer;
-import java.nio.ByteOrder;
-import java.nio.charset.StandardCharsets;
+import java.nio.file.Path;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
-public class MainActivity extends Activity implements InputManager.InputDeviceListener {
-    private static final String TAG = "VmLauncherApp";
-    private static final String VM_NAME = "my_custom_vm";
+public class MainActivity extends Activity {
+    static final String TAG = "VmLauncherApp";
+    // TODO: this path should be from outside of this activity
+    private static final String VM_CONFIG_PATH = "/data/local/tmp/vm_config.json";
 
-    private static final boolean DEBUG = true;
     private static final int RECORD_AUDIO_PERMISSION_REQUEST_CODE = 101;
 
     private static final String ACTION_VM_LAUNCHER = "android.virtualization.VM_LAUNCHER";
@@ -75,565 +48,138 @@
 
     private ExecutorService mExecutorService;
     private VirtualMachine mVirtualMachine;
-    private CursorHandler mCursorHandler;
-    private ClipboardManager mClipboardManager;
-
-
-    private static boolean isVolumeKey(int keyCode) {
-        return keyCode == KeyEvent.KEYCODE_VOLUME_UP
-                || keyCode == KeyEvent.KEYCODE_VOLUME_DOWN
-                || keyCode == KeyEvent.KEYCODE_VOLUME_MUTE;
-    }
-
-    @Override
-    public boolean onKeyDown(int keyCode, KeyEvent event) {
-        if (mVirtualMachine == null) {
-            return false;
-        }
-        return !isVolumeKey(keyCode) && mVirtualMachine.sendKeyEvent(event);
-    }
-
-    @Override
-    public boolean onKeyUp(int keyCode, KeyEvent event) {
-        if (mVirtualMachine == null) {
-            return false;
-        }
-        return !isVolumeKey(keyCode) && mVirtualMachine.sendKeyEvent(event);
-    }
-
-    private void registerInputDeviceListener() {
-        InputManager inputManager = getSystemService(InputManager.class);
-        if (inputManager == null) {
-            Log.e(TAG, "failed to registerInputDeviceListener because InputManager is null");
-            return;
-        }
-        inputManager.registerInputDeviceListener(this, null);
-    }
-
-    private void unregisterInputDeviceListener() {
-        InputManager inputManager = getSystemService(InputManager.class);
-        if (inputManager == null) {
-            Log.e(TAG, "failed to unregisterInputDeviceListener because InputManager is null");
-            return;
-        }
-        inputManager.unregisterInputDeviceListener(this);
-    }
-
-    private void setTabletModeConditionally() {
-        if (mVirtualMachine == null) {
-            Log.e(TAG, "failed to setTabletModeConditionally because VirtualMachine is null");
-            return;
-        }
-        for (int id : InputDevice.getDeviceIds()) {
-            InputDevice d = InputDevice.getDevice(id);
-            if (!d.isVirtual() && d.isEnabled() && d.isFullKeyboard()) {
-                Log.d(TAG, "the device has a physical keyboard, turn off tablet mode");
-                mVirtualMachine.sendTabletModeEvent(false);
-                return;
-            }
-        }
-        mVirtualMachine.sendTabletModeEvent(true);
-        Log.d(TAG, "the device doesn't have a physical keyboard, turn on tablet mode");
-    }
-
-    @Override
-    public void onInputDeviceAdded(int deviceId) {
-        setTabletModeConditionally();
-    }
-
-    @Override
-    public void onInputDeviceRemoved(int deviceId) {
-        setTabletModeConditionally();
-    }
-
-    @Override
-    public void onInputDeviceChanged(int deviceId) {
-        setTabletModeConditionally();
-    }
+    private InputForwarder mInputForwarder;
+    private DisplayProvider mDisplayProvider;
+    private VmAgent mVmAgent;
+    private ClipboardHandler mClipboardHandler;
+    private OpenUrlHandler mOpenUrlHandler;
 
     @Override
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
-        String action = getIntent().getAction();
-        if (!ACTION_VM_LAUNCHER.equals(action)) {
-            finish();
-            Log.e(TAG, "onCreate unsupported intent action: " + action);
-            return;
-        }
+        Log.d(TAG, "onCreate intent: " + getIntent());
         checkAndRequestRecordAudioPermission();
         mExecutorService = Executors.newCachedThreadPool();
+
+        ConfigJson json = ConfigJson.from(VM_CONFIG_PATH);
+        VirtualMachineConfig config = json.toConfig(this);
+
+        Runner runner;
         try {
-            // To ensure that the previous display service is removed.
-            IVirtualizationServiceInternal.Stub.asInterface(
-                            ServiceManager.waitForService("android.system.virtualizationservice"))
-                    .clearDisplayService();
-        } catch (RemoteException e) {
-            Log.d(TAG, "failed to clearDisplayService");
-        }
-        getWindow().setDecorFitsSystemWindows(false);
-        setContentView(R.layout.activity_main);
-        VirtualMachineCallback callback =
-                new VirtualMachineCallback() {
-                    // store reference to ExecutorService to avoid race condition
-                    private final ExecutorService mService = mExecutorService;
-
-                    @Override
-                    public void onPayloadStarted(VirtualMachine vm) {
-                        // This event is only from Microdroid-based VM. Custom VM shouldn't emit
-                        // this.
-                    }
-
-                    @Override
-                    public void onPayloadReady(VirtualMachine vm) {
-                        // This event is only from Microdroid-based VM. Custom VM shouldn't emit
-                        // this.
-                    }
-
-                    @Override
-                    public void onPayloadFinished(VirtualMachine vm, int exitCode) {
-                        // This event is only from Microdroid-based VM. Custom VM shouldn't emit
-                        // this.
-                    }
-
-                    @Override
-                    public void onError(VirtualMachine vm, int errorCode, String message) {
-                        Log.e(TAG, "Error from VM. code: " + errorCode + " (" + message + ")");
-                        setResult(RESULT_CANCELED);
-                        finish();
-                    }
-
-                    @Override
-                    public void onStopped(VirtualMachine vm, int reason) {
-                        Log.d(TAG, "VM stopped. Reason: " + reason);
-                        setResult(RESULT_OK);
-                        finish();
-                    }
-                };
-
-        try {
-            VirtualMachineConfig config =
-                    VmConfigJson.from("/data/local/tmp/vm_config.json").toConfig(this);
-            VirtualMachineManager vmm =
-                    getApplication().getSystemService(VirtualMachineManager.class);
-            if (vmm == null) {
-                Log.e(TAG, "vmm is null");
-                return;
-            }
-            mVirtualMachine = vmm.getOrCreate(VM_NAME, config);
-            try {
-                mVirtualMachine.setConfig(config);
-            } catch (VirtualMachineException e) {
-                vmm.delete(VM_NAME);
-                mVirtualMachine = vmm.create(VM_NAME, config);
-                Log.e(TAG, "error for setting VM config", e);
-            }
-
-            Log.d(TAG, "vm start");
-            mVirtualMachine.run();
-            mVirtualMachine.setCallback(Executors.newSingleThreadExecutor(), callback);
-            if (DEBUG) {
-                InputStream console = mVirtualMachine.getConsoleOutput();
-                InputStream log = mVirtualMachine.getLogOutput();
-                OutputStream consoleLogFile =
-                        new LineBufferedOutputStream(
-                                getApplicationContext().openFileOutput("console.log", 0));
-                mExecutorService.execute(new CopyStreamTask("console", console, consoleLogFile));
-                mExecutorService.execute(new Reader("log", log));
-            }
-        } catch (VirtualMachineException | IOException e) {
+            runner = Runner.create(this, config);
+        } catch (VirtualMachineException e) {
             throw new RuntimeException(e);
         }
-
-        SurfaceView surfaceView = findViewById(R.id.surface_view);
-        SurfaceView cursorSurfaceView = findViewById(R.id.cursor_surface_view);
-        cursorSurfaceView.setZOrderMediaOverlay(true);
-        View backgroundTouchView = findViewById(R.id.background_touch_view);
-        backgroundTouchView.setOnTouchListener(
-                (v, event) -> {
-                    if (mVirtualMachine == null) {
-                        return false;
-                    }
-                    return mVirtualMachine.sendMultiTouchEvent(event);
-                });
-        surfaceView.requestUnbufferedDispatch(InputDevice.SOURCE_ANY);
-        surfaceView.setOnCapturedPointerListener(
-                (v, event) -> {
-                    if (mVirtualMachine == null) {
-                        return false;
-                    }
-                    int eventSource = event.getSource();
-                    if ((eventSource & InputDevice.SOURCE_CLASS_POSITION) != 0) {
-                        return mVirtualMachine.sendTrackpadEvent(event);
-                    }
-                    return mVirtualMachine.sendMouseEvent(event);
-                });
-        surfaceView
-                .getHolder()
-                .addCallback(
-                        // TODO(b/331708504): it should be handled in AVF framework.
-                        new SurfaceHolder.Callback() {
-                            @Override
-                            public void surfaceCreated(SurfaceHolder holder) {
-                                Log.d(
-                                        TAG,
-                                        "surface size: "
-                                                + holder.getSurfaceFrame().flattenToString());
-                                Log.d(
-                                        TAG,
-                                        "ICrosvmAndroidDisplayService.setSurface("
-                                                + holder.getSurface()
-                                                + ")");
-                                runWithDisplayService(
-                                        s ->
-                                                s.setSurface(
-                                                        holder.getSurface(),
-                                                        false /* forCursor */));
-                                // TODO  execute the above and the below togther with the same call
-                                // to runWithDisplayService. Currently this doesn't work because
-                                // setSurface somtimes trigger an exception and as a result
-                                // drawSavedFrameForSurface is skipped.
-                                runWithDisplayService(
-                                        s -> s.drawSavedFrameForSurface(false /* forCursor */));
-                            }
-
-                            @Override
-                            public void surfaceChanged(
-                                    SurfaceHolder holder, int format, int width, int height) {
-                                Log.d(
-                                        TAG,
-                                        "surface changed, width: " + width + ", height: " + height);
-                            }
-
-                            @Override
-                            public void surfaceDestroyed(SurfaceHolder holder) {
-                                Log.d(TAG, "ICrosvmAndroidDisplayService.removeSurface()");
-                                runWithDisplayService(
-                                        (service) -> service.removeSurface(false /* forCursor */));
-                            }
+        mVirtualMachine = runner.getVm();
+        runner.getExitStatus()
+                .thenAcceptAsync(
+                        success -> {
+                            setResult(success ? RESULT_OK : RESULT_CANCELED);
+                            finish();
                         });
-        cursorSurfaceView.getHolder().setFormat(PixelFormat.RGBA_8888);
-        cursorSurfaceView
-                .getHolder()
-                .addCallback(
-                        new SurfaceHolder.Callback() {
-                            @Override
-                            public void surfaceCreated(SurfaceHolder holder) {
-                                try {
-                                    ParcelFileDescriptor[] pfds =
-                                            ParcelFileDescriptor.createSocketPair();
-                                    if (mCursorHandler != null) {
-                                        mCursorHandler.interrupt();
-                                    }
-                                    mCursorHandler =
-                                            new CursorHandler(
-                                                    surfaceView.getSurfaceControl(),
-                                                    cursorSurfaceView.getSurfaceControl(),
-                                                    pfds[0]);
-                                    mCursorHandler.start();
-                                    runWithDisplayService(
-                                            (service) -> service.setCursorStream(pfds[1]));
-                                } catch (Exception e) {
-                                    Log.d(TAG, "failed to run cursor stream handler", e);
-                                }
-                                Log.d(
-                                        TAG,
-                                        "ICrosvmAndroidDisplayService.setSurface("
-                                                + holder.getSurface()
-                                                + ")");
-                                runWithDisplayService(
-                                        (service) ->
-                                                service.setSurface(
-                                                        holder.getSurface(), true /* forCursor */));
-                            }
 
-                            @Override
-                            public void surfaceChanged(
-                                    SurfaceHolder holder, int format, int width, int height) {
-                                Log.d(
-                                        TAG,
-                                        "cursor surface changed, width: "
-                                                + width
-                                                + ", height: "
-                                                + height);
-                            }
+        // Setup UI
+        setContentView(R.layout.activity_main);
+        SurfaceView mainView = findViewById(R.id.surface_view);
+        SurfaceView cursorView = findViewById(R.id.cursor_surface_view);
+        View touchView = findViewById(R.id.background_touch_view);
+        makeFullscreen();
 
-                            @Override
-                            public void surfaceDestroyed(SurfaceHolder holder) {
-                                Log.d(TAG, "ICrosvmAndroidDisplayService.removeSurface()");
-                                runWithDisplayService(
-                                        (service) -> service.removeSurface(true /* forCursor */));
-                            }
-                        });
-        getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
+        // Connect the views to the VM
+        mInputForwarder = new InputForwarder(this, mVirtualMachine, touchView, mainView, mainView);
+        mDisplayProvider = new DisplayProvider(mainView, cursorView);
 
-        // Fullscreen:
-        WindowInsetsController windowInsetsController = surfaceView.getWindowInsetsController();
-        windowInsetsController.setSystemBarsBehavior(
+        Path logPath = getFileStreamPath(mVirtualMachine.getName() + ".log").toPath();
+        Logger.setup(mVirtualMachine, logPath, mExecutorService);
+
+        mVmAgent = new VmAgent(mVirtualMachine);
+        mClipboardHandler = new ClipboardHandler(this, mVmAgent);
+        mOpenUrlHandler = new OpenUrlHandler(mVmAgent);
+        handleIntent(getIntent());
+    }
+
+    private void makeFullscreen() {
+        Window w = getWindow();
+        w.setDecorFitsSystemWindows(false);
+        WindowInsetsController insetsCtrl = w.getInsetsController();
+        insetsCtrl.hide(WindowInsets.Type.systemBars());
+        insetsCtrl.setSystemBarsBehavior(
                 WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
-        windowInsetsController.hide(WindowInsets.Type.systemBars());
-        registerInputDeviceListener();
     }
 
     @Override
     protected void onResume() {
         super.onResume();
-        setTabletModeConditionally();
+        mInputForwarder.setTabletModeConditionally();
     }
 
     @Override
     protected void onPause() {
         super.onPause();
-        runWithDisplayService(s -> s.saveFrameForSurface(false /* forCursor */));
+        mDisplayProvider.notifyDisplayIsGoingToInvisible();
     }
 
     @Override
     protected void onStop() {
         super.onStop();
-        if (mVirtualMachine != null) {
-            try {
-                mVirtualMachine.sendLidEvent(/* close */ true);
-                mVirtualMachine.suspend();
-            } catch (VirtualMachineException e) {
-                Log.e(TAG, "Failed to suspend VM" + e);
-            }
+        try {
+            mVirtualMachine.suspend();
+        } catch (VirtualMachineException e) {
+            Log.e(TAG, "Failed to suspend VM" + e);
         }
     }
 
     @Override
     protected void onRestart() {
         super.onRestart();
-        if (mVirtualMachine != null) {
-            try {
-                mVirtualMachine.resume();
-                mVirtualMachine.sendLidEvent(/* close */ false);
-            } catch (VirtualMachineException e) {
-                Log.e(TAG, "Failed to resume VM" + e);
-            }
+        try {
+            mVirtualMachine.resume();
+        } catch (VirtualMachineException e) {
+            Log.e(TAG, "Failed to resume VM" + e);
         }
     }
 
     @Override
     protected void onDestroy() {
         super.onDestroy();
-        if (mExecutorService != null) {
-            mExecutorService.shutdownNow();
-        }
-        unregisterInputDeviceListener();
+        mExecutorService.shutdownNow();
+        mInputForwarder.cleanUp();
+        mOpenUrlHandler.shutdown();
         Log.d(TAG, "destroyed");
     }
 
-    private static final int DATA_SHARING_SERVICE_PORT = 3580;
-    private static final byte READ_CLIPBOARD_FROM_VM = 0;
-    private static final byte WRITE_CLIPBOARD_TYPE_EMPTY = 1;
-    private static final byte WRITE_CLIPBOARD_TYPE_TEXT_PLAIN = 2;
-    private static final byte OPEN_URL = 3;
-
-    private ClipboardManager getClipboardManager() {
-        if (mClipboardManager == null) {
-            mClipboardManager = getSystemService(ClipboardManager.class);
-        }
-        return mClipboardManager;
-    }
-
-    // Construct header for the clipboard data.
-    // Byte 0: Data type
-    // Byte 1-3: Padding alignment & Reserved for other use cases in the future
-    // Byte 4-7: Data size of the payload
-    private byte[] constructClipboardHeader(byte type, int dataSize) {
-        ByteBuffer header = ByteBuffer.allocate(8);
-        header.clear();
-        header.order(ByteOrder.LITTLE_ENDIAN);
-        header.put(0, type);
-        header.putInt(4, dataSize);
-        return header.array();
-    }
-
-    private ParcelFileDescriptor connectDataSharingService() throws VirtualMachineException {
-        // TODO(349702313): Consider when clipboard sharing server is started to run in VM.
-        return mVirtualMachine.connectVsock(DATA_SHARING_SERVICE_PORT);
-    }
-
-    private void writeClipboardToVm() {
-        Log.d(TAG, "running writeClipboardToVm");
-        try (ParcelFileDescriptor pfd = connectDataSharingService()) {
-            ClipboardManager clipboardManager = getClipboardManager();
-            if (!clipboardManager.hasPrimaryClip()) {
-                Log.d(TAG, "host device has no clipboard data");
-                return;
-            }
-            ClipData clip = clipboardManager.getPrimaryClip();
-            String text = clip.getItemAt(0).getText().toString();
-
-            byte[] header =
-                    constructClipboardHeader(
-                            WRITE_CLIPBOARD_TYPE_TEXT_PLAIN, text.getBytes().length + 1);
-            try (OutputStream stream = new FileOutputStream(pfd.getFileDescriptor())) {
-                stream.write(header);
-                stream.write(text.getBytes());
-                stream.write('\0');
-                Log.d(TAG, "successfully wrote clipboard data to the VM");
-            } catch (IOException e) {
-                Log.e(TAG, "failed to write clipboard data to the VM", e);
-            }
-        } catch (Exception e) {
-            Log.e(TAG, "error on writeClipboardToVm", e);
-        }
-    }
-
-    private byte[] readExactly(InputStream stream, int len) throws IOException {
-        byte[] buf = stream.readNBytes(len);
-        if (buf.length != len) {
-            throw new IOException("Cannot read enough bytes");
-        }
-        return buf;
-    }
-
-    private void readClipboardFromVm() {
-        Log.d(TAG, "running readClipboardFromVm");
-        try (ParcelFileDescriptor pfd = connectDataSharingService()) {
-            byte[] request = constructClipboardHeader(READ_CLIPBOARD_FROM_VM, 0);
-            try (OutputStream output = new FileOutputStream(pfd.getFileDescriptor())) {
-                output.write(request);
-                Log.d(TAG, "successfully send request to the VM for reading clipboard");
-            } catch (IOException e) {
-                Log.e(TAG, "failed to send request to the VM for reading clipboard");
-                throw e;
-            }
-
-            try (InputStream input = new FileInputStream(pfd.getFileDescriptor())) {
-                ByteBuffer header = ByteBuffer.wrap(readExactly(input, 8));
-                header.order(ByteOrder.LITTLE_ENDIAN);
-                switch (header.get(0)) {
-                    case WRITE_CLIPBOARD_TYPE_EMPTY:
-                        Log.d(TAG, "clipboard data in VM is empty");
-                        break;
-                    case WRITE_CLIPBOARD_TYPE_TEXT_PLAIN:
-                        int dataSize = header.getInt(4);
-                        String text_data =
-                                new String(readExactly(input, dataSize), StandardCharsets.UTF_8);
-                        getClipboardManager()
-                                .setPrimaryClip(ClipData.newPlainText(null, text_data));
-                        Log.d(TAG, "successfully received clipboard data from VM");
-                        break;
-                    default:
-                        Log.e(TAG, "unknown clipboard response type");
-                        break;
-                }
-            } catch (IOException e) {
-                Log.e(TAG, "failed to receive clipboard content from VM");
-                throw e;
-            }
-        } catch (Exception e) {
-            Log.e(TAG, "error on readClipboardFromVm", e);
-        }
-    }
-
     @Override
     public void onWindowFocusChanged(boolean hasFocus) {
         super.onWindowFocusChanged(hasFocus);
+
+        // TODO: explain why we have to do this on every focus change
         if (hasFocus) {
-            SurfaceView surfaceView = findViewById(R.id.surface_view);
-            Log.d(TAG, "requestPointerCapture()");
-            surfaceView.requestPointerCapture();
+            SurfaceView mainView = findViewById(R.id.surface_view);
+            mainView.requestPointerCapture();
         }
-        if (mVirtualMachine != null) {
-            if (hasFocus) {
-                mExecutorService.execute(() -> writeClipboardToVm());
-            } else {
-                mExecutorService.execute(() -> readClipboardFromVm());
-            }
-        }
+
+        // TODO: remove executor here. Let clipboard handler handle this.
+        mExecutorService.execute(
+                () -> {
+                    if (hasFocus) {
+                        mClipboardHandler.writeClipboardToVm();
+                    } else {
+                        mClipboardHandler.readClipboardFromVm();
+                    }
+                });
     }
 
     @Override
     protected void onNewIntent(Intent intent) {
-        String action = intent.getAction();
-        if (!ACTION_VM_OPEN_URL.equals(action)) {
-            Log.e(TAG, "onNewIntent unsupported intent action: " + action);
-            return;
-        }
-        Log.d(TAG, "onNewIntent intent action: " + action);
-        String text = intent.getStringExtra(Intent.EXTRA_TEXT);
-        if (text != null) {
-            mExecutorService.execute(
-                    () -> {
-                        byte[] data = text.getBytes();
-                        try (ParcelFileDescriptor pfd = connectDataSharingService();
-                                OutputStream stream =
-                                        new FileOutputStream(pfd.getFileDescriptor())) {
-                            stream.write(constructClipboardHeader(OPEN_URL, data.length));
-                            stream.write(data);
-                            Log.d(TAG, "Successfully sent URL to the VM");
-                        } catch (IOException | VirtualMachineException e) {
-                            Log.e(TAG, "Failed to send URL to the VM", e);
-                        }
-                    });
-        }
+        Log.d(TAG, "onNewIntent intent: " + intent);
+        handleIntent(intent);
     }
 
-    @FunctionalInterface
-    public interface RemoteExceptionCheckedFunction<T> {
-        void apply(T t) throws RemoteException;
-    }
-
-    private void runWithDisplayService(
-            RemoteExceptionCheckedFunction<ICrosvmAndroidDisplayService> func) {
-        IVirtualizationServiceInternal vs =
-                IVirtualizationServiceInternal.Stub.asInterface(
-                        ServiceManager.waitForService("android.system.virtualizationservice"));
-        try {
-            Log.d(TAG, "wait for the display service");
-            ICrosvmAndroidDisplayService service =
-                    ICrosvmAndroidDisplayService.Stub.asInterface(vs.waitDisplayService());
-            assert service != null;
-            func.apply(service);
-            Log.d(TAG, "display service runs successfully");
-        } catch (Exception e) {
-            Log.d(TAG, "error on running display service", e);
-        }
-    }
-
-    static class CursorHandler extends Thread {
-        private final SurfaceControl mCursor;
-        private final ParcelFileDescriptor mStream;
-        private final SurfaceControl.Transaction mTransaction;
-
-        CursorHandler(SurfaceControl main, SurfaceControl cursor, ParcelFileDescriptor stream) {
-            mCursor = cursor;
-            mStream = stream;
-            mTransaction = new SurfaceControl.Transaction();
-
-            mTransaction.reparent(cursor, main).apply();
-        }
-
-        @Override
-        public void run() {
-            Log.d(TAG, "running CursorHandler");
-            try {
-                ByteBuffer byteBuffer = ByteBuffer.allocate(8 /* (x: u32, y: u32) */);
-                byteBuffer.order(ByteOrder.LITTLE_ENDIAN);
-                while (true) {
-                    if (Thread.interrupted()) {
-                        Log.d(TAG, "interrupted: exiting CursorHandler");
-                        return;
-                    }
-                    byteBuffer.clear();
-                    int bytes =
-                            IoBridge.read(
-                                    mStream.getFileDescriptor(),
-                                    byteBuffer.array(),
-                                    0,
-                                    byteBuffer.array().length);
-                    if (bytes == -1) {
-                        Log.e(TAG, "cannot read from cursor stream, stop the handler");
-                        return;
-                    }
-                    float x = (float) (byteBuffer.getInt() & 0xFFFFFFFF);
-                    float y = (float) (byteBuffer.getInt() & 0xFFFFFFFF);
-                    mTransaction.setPosition(mCursor, x, y).apply();
-                }
-            } catch (IOException e) {
-                Log.e(TAG, "failed to run CursorHandler", e);
+    private void handleIntent(Intent intent) {
+        if (ACTION_VM_OPEN_URL.equals(intent.getAction())) {
+            String url = intent.getStringExtra(Intent.EXTRA_TEXT);
+            if (url != null) {
+                mOpenUrlHandler.sendUrlToVm(url);
             }
         }
     }
@@ -645,73 +191,4 @@
                     new String[] {permission.RECORD_AUDIO}, RECORD_AUDIO_PERMISSION_REQUEST_CODE);
         }
     }
-
-    /** Reads data from an input stream and posts it to the output data */
-    static class Reader implements Runnable {
-        private final String mName;
-        private final InputStream mStream;
-
-        Reader(String name, InputStream stream) {
-            mName = name;
-            mStream = stream;
-        }
-
-        @Override
-        public void run() {
-            try {
-                BufferedReader reader = new BufferedReader(new InputStreamReader(mStream));
-                String line;
-                while ((line = reader.readLine()) != null && !Thread.interrupted()) {
-                    Log.d(TAG, mName + ": " + line);
-                }
-            } catch (IOException e) {
-                Log.e(TAG, "Exception while posting " + mName + " output: " + e.getMessage());
-            }
-        }
-    }
-
-    private static class CopyStreamTask implements Runnable {
-        private final String mName;
-        private final InputStream mIn;
-        private final OutputStream mOut;
-
-        CopyStreamTask(String name, InputStream in, OutputStream out) {
-            mName = name;
-            mIn = in;
-            mOut = out;
-        }
-
-        @Override
-        public void run() {
-            try {
-                byte[] buffer = new byte[2048];
-                while (!Thread.interrupted()) {
-                    int len = mIn.read(buffer);
-                    if (len < 0) {
-                        break;
-                    }
-                    mOut.write(buffer, 0, len);
-                }
-            } catch (Exception e) {
-                Log.e(TAG, "Exception while posting " + mName, e);
-            }
-        }
-    }
-
-    private static class LineBufferedOutputStream extends BufferedOutputStream {
-        LineBufferedOutputStream(OutputStream out) {
-            super(out);
-        }
-
-        @Override
-        public void write(byte[] buf, int off, int len) throws IOException {
-            super.write(buf, off, len);
-            for (int i = 0; i < len; ++i) {
-                if (buf[off + i] == '\n') {
-                    flush();
-                    break;
-                }
-            }
-        }
-    }
 }
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/OpenUrlHandler.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/OpenUrlHandler.java
new file mode 100644
index 0000000..fb0c6bf
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/OpenUrlHandler.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.util.Log;
+
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+class OpenUrlHandler {
+    private static final String TAG = MainActivity.TAG;
+
+    private final VmAgent mVmAgent;
+    private final ExecutorService mExecutorService;
+
+    OpenUrlHandler(VmAgent vmAgent) {
+        mVmAgent = vmAgent;
+        mExecutorService = Executors.newSingleThreadExecutor();
+    }
+
+    void shutdown() {
+        mExecutorService.shutdownNow();
+    }
+
+    void sendUrlToVm(String url) {
+        mExecutorService.execute(
+                () -> {
+                    try {
+                        mVmAgent.connect().sendData(VmAgent.OPEN_URL, url.getBytes());
+                        Log.d(TAG, "Successfully sent URL to the VM");
+                    } catch (InterruptedException | RuntimeException e) {
+                        Log.e(TAG, "Failed to send URL to the VM", e);
+                    }
+                });
+    }
+}
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/Runner.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/Runner.java
new file mode 100644
index 0000000..a5f58fe
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/Runner.java
@@ -0,0 +1,114 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.content.Context;
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineCallback;
+import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineCustomImageConfig;
+import android.system.virtualmachine.VirtualMachineException;
+import android.system.virtualmachine.VirtualMachineManager;
+import android.util.Log;
+
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ForkJoinPool;
+
+/** Utility class for creating a VM and waiting for it to finish. */
+class Runner {
+    private static final String TAG = MainActivity.TAG;
+    private final VirtualMachine mVirtualMachine;
+    private final Callback mCallback;
+
+    private Runner(VirtualMachine vm, Callback cb) {
+        mVirtualMachine = vm;
+        mCallback = cb;
+    }
+
+    /** Create a virtual machine of the given config, under the given context. */
+    static Runner create(Context context, VirtualMachineConfig config)
+            throws VirtualMachineException {
+        // context may already be the app context, but calling this again is not harmful.
+        // See b/359439878 on why vmm should be obtained from the app context.
+        Context appContext = context.getApplicationContext();
+        VirtualMachineManager vmm = appContext.getSystemService(VirtualMachineManager.class);
+        VirtualMachineCustomImageConfig customConfig = config.getCustomImageConfig();
+        if (customConfig == null) {
+            throw new RuntimeException("CustomImageConfig is missing");
+        }
+
+        String name = customConfig.getName();
+        if (name == null || name.isEmpty()) {
+            throw new RuntimeException("Virtual machine's name is missing in the config");
+        }
+
+        VirtualMachine vm = vmm.getOrCreate(name, config);
+        try {
+            vm.setConfig(config);
+        } catch (VirtualMachineException e) {
+            vmm.delete(name);
+            vm = vmm.create(name, config);
+            Log.w(TAG, "Re-creating virtual machine (" + name + ")", e);
+        }
+
+        Callback cb = new Callback();
+        vm.setCallback(ForkJoinPool.commonPool(), cb);
+        vm.run();
+        return new Runner(vm, cb);
+    }
+
+    /** Give access to the underlying VirtualMachine object. */
+    VirtualMachine getVm() {
+        return mVirtualMachine;
+    }
+
+    /** Get future about VM's exit status. */
+    CompletableFuture<Boolean> getExitStatus() {
+        return mCallback.mFinishedSuccessfully;
+    }
+
+    private static class Callback implements VirtualMachineCallback {
+        final CompletableFuture<Boolean> mFinishedSuccessfully = new CompletableFuture<>();
+
+        @Override
+        public void onPayloadStarted(VirtualMachine vm) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        @Override
+        public void onPayloadReady(VirtualMachine vm) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        @Override
+        public void onPayloadFinished(VirtualMachine vm, int exitCode) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        @Override
+        public void onError(VirtualMachine vm, int errorCode, String message) {
+            Log.e(TAG, "Error from VM. code: " + errorCode + " (" + message + ")");
+            mFinishedSuccessfully.complete(false);
+        }
+
+        @Override
+        public void onStopped(VirtualMachine vm, int reason) {
+            Log.d(TAG, "VM stopped. Reason: " + reason);
+            mFinishedSuccessfully.complete(true);
+        }
+    }
+}
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmAgent.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmAgent.java
new file mode 100644
index 0000000..af1d298
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmAgent.java
@@ -0,0 +1,145 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.os.ParcelFileDescriptor;
+import android.os.SystemClock;
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineException;
+import android.util.Log;
+
+import libcore.io.Streams;
+
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+
+/**
+ * Agent running in the VM. This class provides connection to the agent and ways to communicate with
+ * it.
+ */
+class VmAgent {
+    private static final String TAG = MainActivity.TAG;
+    private static final int DATA_SHARING_SERVICE_PORT = 3580;
+    private static final int HEADER_SIZE = 8; // size of the header
+    private static final int SIZE_OFFSET = 4; // offset of the size field in the header
+    private static final long RETRY_INTERVAL_MS = 1_000;
+
+    static final byte READ_CLIPBOARD_FROM_VM = 0;
+    static final byte WRITE_CLIPBOARD_TYPE_EMPTY = 1;
+    static final byte WRITE_CLIPBOARD_TYPE_TEXT_PLAIN = 2;
+    static final byte OPEN_URL = 3;
+
+    private final VirtualMachine mVirtualMachine;
+
+    VmAgent(VirtualMachine vm) {
+        mVirtualMachine = vm;
+    }
+
+    /**
+     * Connects to the agent and returns the established communication channel. This can block.
+     *
+     * @throws InterruptedException If the current thread was interrupted
+     */
+    Connection connect() throws InterruptedException {
+        boolean shouldLog = true;
+        while (true) {
+            if (Thread.interrupted()) {
+                throw new InterruptedException();
+            }
+            try {
+                return new Connection(mVirtualMachine.connectVsock(DATA_SHARING_SERVICE_PORT));
+            } catch (VirtualMachineException e) {
+                if (shouldLog) {
+                    shouldLog = false;
+                    Log.d(TAG, "Still waiting for VM agent to start", e);
+                }
+            }
+            SystemClock.sleep(RETRY_INTERVAL_MS);
+        }
+    }
+
+    static class Data {
+        final int type;
+        final byte[] data;
+
+        Data(int type, byte[] data) {
+            this.type = type;
+            this.data = data;
+        }
+    }
+
+    /** Represents a connection to the agent */
+    class Connection {
+        private final ParcelFileDescriptor mConn;
+
+        private Connection(ParcelFileDescriptor conn) {
+            mConn = conn;
+        }
+
+        /** Send data of a given type. This can block. */
+        void sendData(byte type, byte[] data) {
+            // Byte 0: Data type
+            // Byte 1-3: Padding alignment & Reserved for other use cases in the future
+            // Byte 4-7: Data size of the payload
+            ByteBuffer header = ByteBuffer.allocate(HEADER_SIZE);
+            header.clear();
+            header.order(ByteOrder.LITTLE_ENDIAN);
+            header.put(0, type);
+            int dataSize = data == null ? 0 : data.length;
+            header.putInt(SIZE_OFFSET, dataSize);
+
+            try (OutputStream out = new FileOutputStream(mConn.getFileDescriptor())) {
+                out.write(header.array());
+                if (data != null) {
+                    out.write(data);
+                }
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to send message of type: " + type, e);
+            }
+        }
+
+        /** Read data from agent. This can block. */
+        Data readData() {
+            ByteBuffer header = ByteBuffer.allocate(HEADER_SIZE);
+            header.clear();
+            header.order(ByteOrder.LITTLE_ENDIAN);
+            byte[] data;
+
+            try (InputStream in = new FileInputStream(mConn.getFileDescriptor())) {
+                Streams.readFully(in, header.array());
+                byte type = header.get(0);
+                int dataSize = header.getInt(SIZE_OFFSET);
+                data = new byte[dataSize];
+                Streams.readFully(in, data);
+                return new Data(type, data);
+            } catch (IOException e) {
+                throw new RuntimeException("Failed to read data", e);
+            }
+        }
+
+        /** Convenient method for sending data and then reading response for it. This can block. */
+        Data sendAndReceive(byte type, byte[] data) {
+            sendData(type, data);
+            return readData();
+        }
+    }
+}
diff --git a/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmLauncherService.java b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmLauncherService.java
new file mode 100644
index 0000000..5e78f99
--- /dev/null
+++ b/android/VmLauncherApp/java/com/android/virtualization/vmlauncher/VmLauncherService.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.app.Notification;
+import android.app.NotificationChannel;
+import android.app.NotificationManager;
+import android.app.Service;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.IBinder;
+import android.os.Looper;
+import android.os.ParcelFileDescriptor;
+import android.os.ResultReceiver;
+import android.system.virtualmachine.VirtualMachine;
+import android.system.virtualmachine.VirtualMachineConfig;
+import android.system.virtualmachine.VirtualMachineException;
+import android.util.Log;
+
+import java.io.BufferedReader;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.nio.file.Path;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class VmLauncherService extends Service {
+    private static final String TAG = "VmLauncherService";
+    // TODO: this path should be from outside of this service
+    private static final String VM_CONFIG_PATH = "/data/local/tmp/vm_config.json";
+
+    private static final int RESULT_START = 0;
+    private static final int RESULT_STOP = 1;
+    private static final int RESULT_ERROR = 2;
+    private static final int RESULT_IPADDR = 3;
+    private static final String KEY_VM_IP_ADDR = "ip_addr";
+
+    private ExecutorService mExecutorService;
+    private VirtualMachine mVirtualMachine;
+    private ResultReceiver mResultReceiver;
+
+    @Override
+    public IBinder onBind(Intent intent) {
+        return null;
+    }
+
+    private void startForeground() {
+        NotificationManager notificationManager = getSystemService(NotificationManager.class);
+        NotificationChannel notificationChannel =
+                new NotificationChannel(TAG, TAG, NotificationManager.IMPORTANCE_LOW);
+        notificationManager.createNotificationChannel(notificationChannel);
+        startForeground(
+                this.hashCode(),
+                new Notification.Builder(this, TAG)
+                        .setChannelId(TAG)
+                        .setSmallIcon(android.R.drawable.ic_dialog_info)
+                        .setContentText("A VM " + mVirtualMachine.getName() + " is running")
+                        .build());
+    }
+
+    @Override
+    public int onStartCommand(Intent intent, int flags, int startId) {
+        if (isVmRunning()) {
+            Log.d(TAG, "there is already the running VM instance");
+            return START_NOT_STICKY;
+        }
+        mExecutorService = Executors.newCachedThreadPool();
+
+        ConfigJson json = ConfigJson.from(VM_CONFIG_PATH);
+        VirtualMachineConfig config = json.toConfig(this);
+
+        Runner runner;
+        try {
+            runner = Runner.create(this, config);
+        } catch (VirtualMachineException e) {
+            Log.e(TAG, "cannot create runner", e);
+            stopSelf();
+            return START_NOT_STICKY;
+        }
+        mVirtualMachine = runner.getVm();
+        mResultReceiver =
+                intent.getParcelableExtra(Intent.EXTRA_RESULT_RECEIVER, ResultReceiver.class);
+
+        runner.getExitStatus()
+                .thenAcceptAsync(
+                        success -> {
+                            if (mResultReceiver != null) {
+                                mResultReceiver.send(success ? RESULT_STOP : RESULT_ERROR, null);
+                            }
+                            if (!success) {
+                                stopSelf();
+                            }
+                        });
+        Path logPath = getFileStreamPath(mVirtualMachine.getName() + ".log").toPath();
+        Logger.setup(mVirtualMachine, logPath, mExecutorService);
+
+        startForeground();
+
+        mResultReceiver.send(RESULT_START, null);
+        if (config.getCustomImageConfig().useNetwork()) {
+            Handler handler = new Handler(Looper.getMainLooper());
+            gatherIpAddrFromVm(handler);
+        }
+        return START_NOT_STICKY;
+    }
+
+    @Override
+    public void onDestroy() {
+        super.onDestroy();
+        if (isVmRunning()) {
+            try {
+                mVirtualMachine.stop();
+                stopForeground(STOP_FOREGROUND_REMOVE);
+            } catch (VirtualMachineException e) {
+                Log.e(TAG, "failed to stop a VM instance", e);
+            }
+            mExecutorService.shutdownNow();
+            mExecutorService = null;
+            mVirtualMachine = null;
+        }
+    }
+
+    private boolean isVmRunning() {
+        return mVirtualMachine != null
+                && mVirtualMachine.getStatus() == VirtualMachine.STATUS_RUNNING;
+    }
+
+    // TODO(b/359523803): Use AVF API to get ip addr when it exists
+    private void gatherIpAddrFromVm(Handler handler) {
+        handler.postDelayed(
+                () -> {
+                    if (!isVmRunning()) {
+                        Log.d(TAG, "A virtual machine instance isn't running");
+                        return;
+                    }
+                    int INTERNAL_VSOCK_SERVER_PORT = 1024;
+                    try (ParcelFileDescriptor pfd =
+                            mVirtualMachine.connectVsock(INTERNAL_VSOCK_SERVER_PORT)) {
+                        try (BufferedReader input =
+                                new BufferedReader(
+                                        new InputStreamReader(
+                                                new FileInputStream(pfd.getFileDescriptor())))) {
+                            String vmIpAddr = input.readLine().strip();
+                            Bundle b = new Bundle();
+                            b.putString(KEY_VM_IP_ADDR, vmIpAddr);
+                            mResultReceiver.send(RESULT_IPADDR, b);
+                            return;
+                        } catch (IOException e) {
+                            Log.e(TAG, e.toString());
+                        }
+                    } catch (Exception e) {
+                        Log.e(TAG, e.toString());
+                    }
+                    gatherIpAddrFromVm(handler);
+                },
+                1000);
+    }
+}
diff --git a/android/VmLauncherApp/proguard.flags b/android/VmLauncherApp/proguard.flags
index 5e05ecf..13ec24e 100644
--- a/android/VmLauncherApp/proguard.flags
+++ b/android/VmLauncherApp/proguard.flags
@@ -1,7 +1,7 @@
 # Keep the no-args constructor of the deserialized class
--keepclassmembers class com.android.virtualization.vmlauncher.VmConfigJson {
+-keepclassmembers class com.android.virtualization.vmlauncher.ConfigJson {
   <init>();
 }
--keepclassmembers class com.android.virtualization.vmlauncher.VmConfigJson$* {
+-keepclassmembers class com.android.virtualization.vmlauncher.ConfigJson$* {
   <init>();
 }
diff --git a/android/fd_server/Android.bp b/android/fd_server/Android.bp
index b02c104..32a8fec 100644
--- a/android/fd_server/Android.bp
+++ b/android/fd_server/Android.bp
@@ -18,6 +18,7 @@
         "liblog_rust",
         "libnix",
         "librpcbinder_rs",
+        "libsafe_ownedfd",
     ],
     prefer_rlib: true,
     apex_available: ["com.android.virt"],
@@ -39,6 +40,7 @@
         "liblog_rust",
         "libnix",
         "librpcbinder_rs",
+        "libsafe_ownedfd",
     ],
     prefer_rlib: true,
     test_suites: ["general-tests"],
diff --git a/android/fd_server/src/aidl.rs b/android/fd_server/src/aidl.rs
index 5f91987..2f3697c 100644
--- a/android/fd_server/src/aidl.rs
+++ b/android/fd_server/src/aidl.rs
@@ -14,20 +14,21 @@
  * limitations under the License.
  */
 
-use anyhow::Result;
+use anyhow::{Context, Result};
 use log::error;
 use nix::{
     errno::Errno, fcntl::openat, fcntl::OFlag, sys::stat::fchmod, sys::stat::mkdirat,
     sys::stat::mode_t, sys::stat::Mode, sys::statvfs::statvfs, sys::statvfs::Statvfs,
     unistd::unlinkat, unistd::UnlinkatFlags,
 };
+use safe_ownedfd::take_fd_ownership;
 use std::cmp::min;
 use std::collections::{btree_map, BTreeMap};
 use std::convert::TryInto;
 use std::fs::File;
 use std::io;
 use std::os::unix::fs::FileExt;
-use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, FromRawFd, OwnedFd};
+use std::os::unix::io::{AsFd, AsRawFd, BorrowedFd, OwnedFd};
 use std::path::{Component, Path, PathBuf, MAIN_SEPARATOR};
 use std::sync::{Arc, RwLock};
 
@@ -38,7 +39,8 @@
     get_fsverity_metadata_path, parse_fsverity_metadata, FSVerityMetadata,
 };
 use binder::{
-    BinderFeatures, ExceptionCode, Interface, Result as BinderResult, Status, StatusCode, Strong,
+    BinderFeatures, ExceptionCode, Interface, IntoBinderResult, Result as BinderResult, Status,
+    StatusCode, Strong,
 };
 
 /// Bitflags of forbidden file mode, e.g. setuid, setgid and sticky bit.
@@ -299,9 +301,11 @@
                     mode,
                 )
                 .map_err(new_errno_error)?;
-                // SAFETY: new_fd is just created and not an error.
-                let new_file = unsafe { File::from_raw_fd(new_fd) };
-                Ok((new_fd, FdConfig::ReadWrite(new_file)))
+                let new_fd = take_fd_ownership(new_fd)
+                    .context("Failed to take ownership of fd for file")
+                    .or_service_specific_exception(-1)?;
+                let new_file = File::from(new_fd);
+                Ok((new_file.as_raw_fd(), FdConfig::ReadWrite(new_file)))
             }
             _ => Err(new_errno_error(Errno::ENOTDIR)),
         })
@@ -327,9 +331,10 @@
                     Mode::empty(),
                 )
                 .map_err(new_errno_error)?;
-                // SAFETY: new_dir_fd is just created and not an error.
-                let fd_owner = unsafe { OwnedFd::from_raw_fd(new_dir_fd) };
-                Ok((new_dir_fd, FdConfig::OutputDir(fd_owner)))
+                let fd_owner = take_fd_ownership(new_dir_fd)
+                    .context("Failed to take ownership of the fd for directory")
+                    .or_service_specific_exception(-1)?;
+                Ok((fd_owner.as_raw_fd(), FdConfig::OutputDir(fd_owner)))
             }
             _ => Err(new_errno_error(Errno::ENOTDIR)),
         })
@@ -408,9 +413,11 @@
 
 fn open_readonly_at(dir_fd: BorrowedFd, path: &Path) -> nix::Result<File> {
     let new_fd = openat(Some(dir_fd.as_raw_fd()), path, OFlag::O_RDONLY, Mode::empty())?;
-    // SAFETY: new_fd is just created successfully and not owned.
-    let new_file = unsafe { File::from_raw_fd(new_fd) };
-    Ok(new_file)
+    let new_fd = take_fd_ownership(new_fd).map_err(|e| match e {
+        safe_ownedfd::Error::Errno(e) => e,
+        _ => Errno::UnknownErrno,
+    })?;
+    Ok(File::from(new_fd))
 }
 
 fn validate_and_cast_offset(offset: i64) -> Result<u64, Status> {
diff --git a/android/virtmgr/Android.bp b/android/virtmgr/Android.bp
index a21ee6c..62ff8d8 100644
--- a/android/virtmgr/Android.bp
+++ b/android/virtmgr/Android.bp
@@ -44,7 +44,6 @@
         "libglob",
         "libhex",
         "libhypervisor_props",
-        "liblazy_static",
         "liblibc",
         "liblog_rust",
         "libmicrodroid_metadata",
@@ -55,6 +54,7 @@
         "libregex",
         "librpcbinder_rs",
         "librustutils",
+        "libsafe_ownedfd",
         "libsemver",
         "libselinux_bindgen",
         "libserde",
diff --git a/android/virtmgr/fsfdt/src/lib.rs b/android/virtmgr/fsfdt/src/lib.rs
index e176b7b..ff15efa 100644
--- a/android/virtmgr/fsfdt/src/lib.rs
+++ b/android/virtmgr/fsfdt/src/lib.rs
@@ -76,7 +76,7 @@
                     stack.push(entry.path());
                     subnode_names.push(name);
                 } else if entry_type.is_file() {
-                    let value = fs::read(&entry.path())?;
+                    let value = fs::read(entry.path())?;
 
                     node.setprop(&name, &value)
                         .map_err(|e| anyhow!("Failed to set FDT property, {e:?}"))?;
diff --git a/android/virtmgr/src/aidl.rs b/android/virtmgr/src/aidl.rs
index f1bfd8c..0acf4be 100644
--- a/android/virtmgr/src/aidl.rs
+++ b/android/virtmgr/src/aidl.rs
@@ -17,7 +17,7 @@
 use crate::{get_calling_pid, get_calling_uid, get_this_pid};
 use crate::atom::{write_vm_booted_stats, write_vm_creation_stats};
 use crate::composite::make_composite_image;
-use crate::crosvm::{AudioConfig, CrosvmConfig, DiskFile, DisplayConfig, GpuConfig, InputDeviceOption, PayloadState, VmContext, VmInstance, VmState};
+use crate::crosvm::{AudioConfig, CrosvmConfig, DiskFile, DisplayConfig, GpuConfig, InputDeviceOption, PayloadState, UsbConfig, VmContext, VmInstance, VmState};
 use crate::debug_config::DebugConfig;
 use crate::dt_overlay::{create_device_tree_overlay, VM_DT_OVERLAY_MAX_SIZE, VM_DT_OVERLAY_PATH};
 use crate::payload::{add_microdroid_payload_images, add_microdroid_system_images, add_microdroid_vendor_image};
@@ -67,12 +67,12 @@
 };
 use cstr::cstr;
 use glob::glob;
-use lazy_static::lazy_static;
 use log::{debug, error, info, warn};
 use microdroid_payload_config::{ApkConfig, Task, TaskType, VmPayloadConfig};
 use nix::unistd::pipe;
 use rpcbinder::RpcServer;
 use rustutils::system_properties;
+use safe_ownedfd::take_fd_ownership;
 use semver::VersionReq;
 use std::collections::HashSet;
 use std::convert::TryInto;
@@ -82,10 +82,10 @@
 use std::io::{BufRead, BufReader, Error, ErrorKind, Seek, SeekFrom, Write};
 use std::iter;
 use std::num::{NonZeroU16, NonZeroU32};
-use std::os::unix::io::{AsRawFd, FromRawFd, IntoRawFd};
+use std::os::unix::io::{AsRawFd, IntoRawFd};
 use std::os::unix::raw::pid_t;
 use std::path::{Path, PathBuf};
-use std::sync::{Arc, Mutex, Weak};
+use std::sync::{Arc, Mutex, Weak, LazyLock};
 use vbmeta::VbMetaImage;
 use vmconfig::{VmConfig, get_debug_level};
 use vsock::VsockStream;
@@ -118,13 +118,13 @@
 
 const VM_REFERENCE_DT_ON_HOST_PATH: &str = "/proc/device-tree/avf/reference";
 
-lazy_static! {
-    pub static ref GLOBAL_SERVICE: Strong<dyn IVirtualizationServiceInternal> =
+pub static GLOBAL_SERVICE: LazyLock<Strong<dyn IVirtualizationServiceInternal>> =
+    LazyLock::new(|| {
         wait_for_interface(BINDER_SERVICE_IDENTIFIER)
-            .expect("Could not connect to VirtualizationServiceInternal");
-    static ref SUPPORTED_OS_NAMES: HashSet<String> =
-        get_supported_os_names().expect("Failed to get list of supported os names");
-}
+            .expect("Could not connect to VirtualizationServiceInternal")
+    });
+static SUPPORTED_OS_NAMES: LazyLock<HashSet<String>> =
+    LazyLock::new(|| get_supported_os_names().expect("Failed to get list of supported os names"));
 
 fn create_or_update_idsig_file(
     input_fd: &ParcelFileDescriptor,
@@ -411,9 +411,9 @@
 
         let state = &mut *self.state.lock().unwrap();
         let console_out_fd =
-            clone_or_prepare_logger_fd(&debug_config, console_out_fd, format!("Console({})", cid))?;
+            clone_or_prepare_logger_fd(console_out_fd, format!("Console({})", cid))?;
         let console_in_fd = console_in_fd.map(clone_file).transpose()?;
-        let log_fd = clone_or_prepare_logger_fd(&debug_config, log_fd, format!("Log({})", cid))?;
+        let log_fd = clone_or_prepare_logger_fd(log_fd, format!("Log({})", cid))?;
 
         // Counter to generate unique IDs for temporary image files.
         let mut next_temporary_image_id = 0;
@@ -584,6 +584,13 @@
             None
         };
 
+        let usb_config = config
+            .usbConfig
+            .as_ref()
+            .map(UsbConfig::new)
+            .unwrap_or(Ok(UsbConfig { controller: false }))
+            .or_binder_exception(ExceptionCode::BAD_PARCELABLE)?;
+
         // Actually start the VM.
         let crosvm_config = CrosvmConfig {
             cid,
@@ -622,6 +629,8 @@
             boost_uclamp: config.boostUclamp,
             gpu_config,
             audio_config,
+            no_balloon: config.noBalloon,
+            usb_config,
         };
         let instance = Arc::new(
             VmInstance::new(
@@ -988,6 +997,10 @@
 
         vm_config.devices.clone_from(&custom_config.devices);
         vm_config.networkSupported = custom_config.networkSupported;
+
+        for param in custom_config.extraKernelCmdlineParams.iter() {
+            append_kernel_param(param, &mut vm_config);
+        }
     }
 
     if config.memoryMib > 0 {
@@ -1274,7 +1287,7 @@
         let stream = VsockStream::connect_with_cid_port(self.instance.cid, port)
             .context("Failed to connect")
             .or_service_specific_exception(-1)?;
-        Ok(vsock_stream_to_pfd(stream))
+        vsock_stream_to_pfd(stream)
     }
 
     fn setHostConsoleName(&self, ptsname: &str) -> binder::Result<()> {
@@ -1433,10 +1446,12 @@
 }
 
 /// Converts a `VsockStream` to a `ParcelFileDescriptor`.
-fn vsock_stream_to_pfd(stream: VsockStream) -> ParcelFileDescriptor {
-    // SAFETY: ownership is transferred from stream to f
-    let f = unsafe { File::from_raw_fd(stream.into_raw_fd()) };
-    ParcelFileDescriptor::new(f)
+fn vsock_stream_to_pfd(stream: VsockStream) -> binder::Result<ParcelFileDescriptor> {
+    let owned_fd = take_fd_ownership(stream.into_raw_fd())
+        .context("Failed to take ownership of the vsock stream")
+        .with_log()
+        .or_service_specific_exception(-1)?;
+    Ok(ParcelFileDescriptor::new(owned_fd))
 }
 
 /// Parses the platform version requirement string.
@@ -1538,6 +1553,17 @@
     Ok(())
 }
 
+fn check_no_extra_kernel_cmdline_params(config: &VirtualMachineConfig) -> binder::Result<()> {
+    let VirtualMachineConfig::AppConfig(config) = config else { return Ok(()) };
+    if let Some(custom_config) = &config.customConfig {
+        if !custom_config.extraKernelCmdlineParams.is_empty() {
+            return Err(anyhow!("debuggable_vms_improvements feature is disabled"))
+                .or_binder_exception(ExceptionCode::UNSUPPORTED_OPERATION);
+        }
+    }
+    Ok(())
+}
+
 fn check_protected_vm_is_supported() -> binder::Result<()> {
     let is_pvm_supported =
         hypervisor_props::is_protected_vm_supported().or_service_specific_exception(-1)?;
@@ -1559,11 +1585,13 @@
     if !cfg!(multi_tenant) {
         check_no_extra_apks(config)?;
     }
+    if !cfg!(debuggable_vms_improvements) {
+        check_no_extra_kernel_cmdline_params(config)?;
+    }
     Ok(())
 }
 
 fn clone_or_prepare_logger_fd(
-    debug_config: &DebugConfig,
     fd: Option<&ParcelFileDescriptor>,
     tag: String,
 ) -> Result<Option<File>, Status> {
@@ -1571,10 +1599,6 @@
         return Ok(Some(clone_file(fd)?));
     }
 
-    if !debug_config.should_prepare_console_output() {
-        return Ok(None);
-    };
-
     let (read_fd, write_fd) =
         pipe().context("Failed to create pipe").or_service_specific_exception(-1)?;
 
diff --git a/android/virtmgr/src/crosvm.rs b/android/virtmgr/src/crosvm.rs
index f9fbd16..5886535 100644
--- a/android/virtmgr/src/crosvm.rs
+++ b/android/virtmgr/src/crosvm.rs
@@ -20,7 +20,6 @@
 use anyhow::{anyhow, bail, Context, Error, Result};
 use binder::ParcelFileDescriptor;
 use command_fds::CommandFdExt;
-use lazy_static::lazy_static;
 use libc::{sysconf, _SC_CLK_TCK};
 use log::{debug, error, info};
 use semver::{Version, VersionReq};
@@ -35,11 +34,11 @@
 use std::io::{self, Read};
 use std::mem;
 use std::num::{NonZeroU16, NonZeroU32};
-use std::os::unix::io::{AsRawFd, OwnedFd, RawFd};
+use std::os::unix::io::{AsRawFd, OwnedFd};
 use std::os::unix::process::ExitStatusExt;
 use std::path::{Path, PathBuf};
 use std::process::{Command, ExitStatus};
-use std::sync::{Arc, Condvar, Mutex};
+use std::sync::{Arc, Condvar, Mutex, LazyLock};
 use std::time::{Duration, SystemTime};
 use std::thread::{self, JoinHandle};
 use android_system_virtualizationcommon::aidl::android::system::virtualizationcommon::DeathReason::DeathReason;
@@ -48,6 +47,7 @@
     AudioConfig::AudioConfig as AudioConfigParcelable,
     DisplayConfig::DisplayConfig as DisplayConfigParcelable,
     GpuConfig::GpuConfig as GpuConfigParcelable,
+    UsbConfig::UsbConfig as UsbConfigParcelable,
 };
 use android_system_virtualizationservice_internal::aidl::android::system::virtualizationservice_internal::IGlobalVmContext::IGlobalVmContext;
 use android_system_virtualizationservice_internal::aidl::android::system::virtualizationservice_internal::IBoundDevice::IBoundDevice;
@@ -57,7 +57,6 @@
 use rpcbinder::RpcServer;
 
 /// external/crosvm
-use base::AsRawDescriptor;
 use base::UnixSeqpacketListener;
 use vm_control::{BalloonControlCommand, VmRequest, VmResponse};
 
@@ -90,16 +89,16 @@
 /// Serial (emulated uart)
 const CONSOLE_TTYS0: &str = "ttyS0";
 
-lazy_static! {
-    /// If the VM doesn't move to the Started state within this amount time, a hang-up error is
-    /// triggered.
-    static ref BOOT_HANGUP_TIMEOUT: Duration = if nested_virt::is_nested_virtualization().unwrap() {
+/// If the VM doesn't move to the Started state within this amount time, a hang-up error is
+/// triggered.
+static BOOT_HANGUP_TIMEOUT: LazyLock<Duration> = LazyLock::new(|| {
+    if nested_virt::is_nested_virtualization().unwrap() {
         // Nested virtualization is slow, so we need a longer timeout.
         Duration::from_secs(300)
     } else {
         Duration::from_secs(30)
-    };
-}
+    }
+});
 
 /// Configuration for a VM to run with crosvm.
 #[derive(Debug)]
@@ -135,6 +134,8 @@
     pub boost_uclamp: bool,
     pub gpu_config: Option<GpuConfig>,
     pub audio_config: Option<AudioConfig>,
+    pub no_balloon: bool,
+    pub usb_config: UsbConfig,
 }
 
 #[derive(Debug)]
@@ -150,6 +151,17 @@
 }
 
 #[derive(Debug)]
+pub struct UsbConfig {
+    pub controller: bool,
+}
+
+impl UsbConfig {
+    pub fn new(raw_config: &UsbConfigParcelable) -> Result<UsbConfig> {
+        Ok(UsbConfig { controller: raw_config.controller })
+    }
+}
+
+#[derive(Debug)]
 pub struct DisplayConfig {
     pub width: NonZeroU32,
     pub height: NonZeroU32,
@@ -872,26 +884,6 @@
     }
 }
 
-fn append_platform_devices(
-    command: &mut Command,
-    preserved_fds: &mut Vec<RawFd>,
-    config: &CrosvmConfig,
-) -> Result<(), Error> {
-    if config.vfio_devices.is_empty() {
-        return Ok(());
-    }
-
-    let Some(dtbo) = &config.dtbo else {
-        bail!("VFIO devices assigned but no DTBO available");
-    };
-    command.arg(format!("--device-tree-overlay={},filter", add_preserved_fd(preserved_fds, dtbo)));
-
-    for device in &config.vfio_devices {
-        command.arg(vfio_argument_for_platform_device(device)?);
-    }
-    Ok(())
-}
-
 /// Starts an instance of `crosvm` to manage a new VM.
 fn run_vm(
     config: CrosvmConfig,
@@ -913,12 +905,18 @@
         .arg("--cid")
         .arg(config.cid.to_string());
 
-    if system_properties::read_bool("hypervisor.memory_reclaim.supported", false)? {
+    if system_properties::read_bool("hypervisor.memory_reclaim.supported", false)?
+        && !config.no_balloon
+    {
         command.arg("--balloon-page-reporting");
     } else {
         command.arg("--no-balloon");
     }
 
+    if !config.usb_config.controller {
+        command.arg("--no-usb");
+    }
+
     let mut memory_mib = config.memory_mib;
 
     if config.protected {
@@ -986,7 +984,7 @@
     }
 
     // Keep track of what file descriptors should be mapped to the crosvm process.
-    let mut preserved_fds = config.indirect_files.iter().map(|file| file.as_raw_fd()).collect();
+    let mut preserved_fds = config.indirect_files.into_iter().map(|f| f.into()).collect();
 
     // Setup the serial devices.
     // 1. uart device: used as the output device by bootloaders and as early console by linux
@@ -997,15 +995,14 @@
     //
     // When [console|log]_fd is not specified, the devices are attached to sink, which means what's
     // written there is discarded.
-    let console_out_arg = format_serial_out_arg(&mut preserved_fds, &config.console_out_fd);
+    let console_out_arg = format_serial_out_arg(&mut preserved_fds, config.console_out_fd);
     let console_in_arg = config
         .console_in_fd
-        .as_ref()
         .map(|fd| format!(",input={}", add_preserved_fd(&mut preserved_fds, fd)))
         .unwrap_or_default();
-    let log_arg = format_serial_out_arg(&mut preserved_fds, &config.log_fd);
-    let failure_serial_path = add_preserved_fd(&mut preserved_fds, &failure_pipe_write);
-    let ramdump_arg = format_serial_out_arg(&mut preserved_fds, &config.ramdump);
+    let log_arg = format_serial_out_arg(&mut preserved_fds, config.log_fd);
+    let failure_serial_path = add_preserved_fd(&mut preserved_fds, failure_pipe_write);
+    let ramdump_arg = format_serial_out_arg(&mut preserved_fds, config.ramdump);
     let console_input_device = config.console_input_device.as_deref().unwrap_or(CONSOLE_HVC0);
     match console_input_device {
         CONSOLE_HVC0 | CONSOLE_TTYS0 => {}
@@ -1035,11 +1032,11 @@
     // /dev/hvc2
     command.arg(format!("--serial={},hardware=virtio-console,num=3", &log_arg));
 
-    if let Some(bootloader) = &config.bootloader {
+    if let Some(bootloader) = config.bootloader {
         command.arg("--bios").arg(add_preserved_fd(&mut preserved_fds, bootloader));
     }
 
-    if let Some(initrd) = &config.initrd {
+    if let Some(initrd) = config.initrd {
         command.arg("--initrd").arg(add_preserved_fd(&mut preserved_fds, initrd));
     }
 
@@ -1047,25 +1044,23 @@
         command.arg("--params").arg(params);
     }
 
-    for disk in &config.disks {
+    for disk in config.disks {
         command.arg("--block").arg(format!(
             "path={},ro={}",
-            add_preserved_fd(&mut preserved_fds, &disk.image),
+            add_preserved_fd(&mut preserved_fds, disk.image),
             !disk.writable,
         ));
     }
 
-    if let Some(kernel) = &config.kernel {
+    if let Some(kernel) = config.kernel {
         command.arg(add_preserved_fd(&mut preserved_fds, kernel));
     }
 
-    let control_server_socket = UnixSeqpacketListener::bind(crosvm_control_socket_path)
+    let control_sock = UnixSeqpacketListener::bind(crosvm_control_socket_path)
         .context("failed to create control server")?;
-    command
-        .arg("--socket")
-        .arg(add_preserved_fd(&mut preserved_fds, &control_server_socket.as_raw_descriptor()));
+    command.arg("--socket").arg(add_preserved_fd(&mut preserved_fds, control_sock));
 
-    if let Some(dt_overlay) = &config.device_tree_overlay {
+    if let Some(dt_overlay) = config.device_tree_overlay {
         command.arg("--device-tree-overlay").arg(add_preserved_fd(&mut preserved_fds, dt_overlay));
     }
 
@@ -1116,15 +1111,15 @@
     }
 
     if cfg!(network) {
-        if let Some(tap) = &config.tap {
-            let tap_fd = tap.as_raw_fd();
-            preserved_fds.push(tap_fd);
-            command.arg("--net").arg(format!("tap-fd={}", tap_fd));
+        if let Some(tap) = config.tap {
+            add_preserved_fd(&mut preserved_fds, tap);
+            let tap_fd = preserved_fds.last().unwrap().as_raw_fd();
+            command.arg("--net").arg(format!("tap-fd={tap_fd}"));
         }
     }
 
     if cfg!(paravirtualized_devices) {
-        for input_device_option in config.input_device_options.iter() {
+        for input_device_option in config.input_device_options.into_iter() {
             command.arg("--input");
             command.arg(match input_device_option {
                 InputDeviceOption::EvDev(file) => {
@@ -1172,7 +1167,19 @@
         command.arg("--boost-uclamp");
     }
 
-    append_platform_devices(&mut command, &mut preserved_fds, &config)?;
+    if !config.vfio_devices.is_empty() {
+        if let Some(dtbo) = config.dtbo {
+            command.arg(format!(
+                "--device-tree-overlay={},filter",
+                add_preserved_fd(&mut preserved_fds, dtbo)
+            ));
+        } else {
+            bail!("VFIO devices assigned but no DTBO available");
+        }
+    };
+    for device in config.vfio_devices {
+        command.arg(vfio_argument_for_platform_device(&device)?);
+    }
 
     debug!("Preserving FDs {:?}", preserved_fds);
     command.preserved_fds(preserved_fds);
@@ -1242,15 +1249,16 @@
 
 /// Adds the file descriptor for `file` to `preserved_fds`, and returns a string of the form
 /// "/proc/self/fd/N" where N is the file descriptor.
-fn add_preserved_fd(preserved_fds: &mut Vec<RawFd>, file: &dyn AsRawFd) -> String {
-    let fd = file.as_raw_fd();
+fn add_preserved_fd<F: Into<OwnedFd>>(preserved_fds: &mut Vec<OwnedFd>, file: F) -> String {
+    let fd = file.into();
+    let raw_fd = fd.as_raw_fd();
     preserved_fds.push(fd);
-    format!("/proc/self/fd/{}", fd)
+    format!("/proc/self/fd/{}", raw_fd)
 }
 
 /// Adds the file descriptor for `file` (if any) to `preserved_fds`, and returns the appropriate
 /// string for a crosvm `--serial` flag. If `file` is none, creates a dummy sink device.
-fn format_serial_out_arg(preserved_fds: &mut Vec<RawFd>, file: &Option<File>) -> String {
+fn format_serial_out_arg(preserved_fds: &mut Vec<OwnedFd>, file: Option<File>) -> String {
     if let Some(file) = file {
         format!("type=file,path={}", add_preserved_fd(preserved_fds, file))
     } else {
diff --git a/android/virtmgr/src/debug_config.rs b/android/virtmgr/src/debug_config.rs
index 52ac964..74559de 100644
--- a/android/virtmgr/src/debug_config.rs
+++ b/android/virtmgr/src/debug_config.rs
@@ -18,7 +18,6 @@
     VirtualMachineAppConfig::DebugLevel::DebugLevel, VirtualMachineConfig::VirtualMachineConfig,
 };
 use anyhow::{anyhow, Context, Error, Result};
-use lazy_static::lazy_static;
 use libfdt::{Fdt, FdtError};
 use log::{info, warn};
 use rustutils::system_properties;
@@ -26,6 +25,7 @@
 use std::fs;
 use std::io::ErrorKind;
 use std::path::{Path, PathBuf};
+use std::sync::LazyLock;
 use vmconfig::get_debug_level;
 
 const CUSTOM_DEBUG_POLICY_OVERLAY_SYSPROP: &str =
@@ -56,11 +56,12 @@
     }
 }
 
-lazy_static! {
-    static ref DP_LOG_PATH: DPPath = DPPath::new("/avf/guest/common", "log").unwrap();
-    static ref DP_RAMDUMP_PATH: DPPath = DPPath::new("/avf/guest/common", "ramdump").unwrap();
-    static ref DP_ADB_PATH: DPPath = DPPath::new("/avf/guest/microdroid", "adb").unwrap();
-}
+static DP_LOG_PATH: LazyLock<DPPath> =
+    LazyLock::new(|| DPPath::new("/avf/guest/common", "log").unwrap());
+static DP_RAMDUMP_PATH: LazyLock<DPPath> =
+    LazyLock::new(|| DPPath::new("/avf/guest/common", "ramdump").unwrap());
+static DP_ADB_PATH: LazyLock<DPPath> =
+    LazyLock::new(|| DPPath::new("/avf/guest/microdroid", "adb").unwrap());
 
 /// Get debug policy value in bool. It's true iff the value is explicitly set to <1>.
 fn get_debug_policy_bool(path: &Path) -> Result<bool> {
diff --git a/android/virtmgr/src/main.rs b/android/virtmgr/src/main.rs
index 445260f..a4e75a7 100644
--- a/android/virtmgr/src/main.rs
+++ b/android/virtmgr/src/main.rs
@@ -25,24 +25,22 @@
 
 use crate::aidl::{GLOBAL_SERVICE, VirtualizationService};
 use android_system_virtualizationservice::aidl::android::system::virtualizationservice::IVirtualizationService::BnVirtualizationService;
-use anyhow::{bail, Context, Result};
+use anyhow::{bail, Result};
 use binder::{BinderFeatures, ProcessState};
-use lazy_static::lazy_static;
 use log::{info, LevelFilter};
 use rpcbinder::{FileDescriptorTransportMode, RpcServer};
-use std::os::unix::io::{AsFd, FromRawFd, OwnedFd, RawFd};
+use std::os::unix::io::{AsFd, RawFd};
+use std::sync::LazyLock;
 use clap::Parser;
-use nix::fcntl::{fcntl, F_GETFD, F_SETFD, FdFlag};
 use nix::unistd::{write, Pid, Uid};
 use std::os::unix::raw::{pid_t, uid_t};
+use safe_ownedfd::take_fd_ownership;
 
 const LOG_TAG: &str = "virtmgr";
 
-lazy_static! {
-    static ref PID_CURRENT: Pid = Pid::this();
-    static ref PID_PARENT: Pid = Pid::parent();
-    static ref UID_CURRENT: Uid = Uid::current();
-}
+static PID_CURRENT: LazyLock<Pid> = LazyLock::new(Pid::this);
+static PID_PARENT: LazyLock<Pid> = LazyLock::new(Pid::parent);
+static UID_CURRENT: LazyLock<Uid> = LazyLock::new(Uid::current);
 
 fn get_this_pid() -> pid_t {
     // Return the process ID of this process.
@@ -73,32 +71,6 @@
     ready_fd: RawFd,
 }
 
-fn take_fd_ownership(raw_fd: RawFd, owned_fds: &mut Vec<RawFd>) -> Result<OwnedFd, anyhow::Error> {
-    // Basic check that the integer value does correspond to a file descriptor.
-    fcntl(raw_fd, F_GETFD).with_context(|| format!("Invalid file descriptor {raw_fd}"))?;
-
-    // The file descriptor had CLOEXEC disabled to be inherited from the parent.
-    // Re-enable it to make sure it is not accidentally inherited further.
-    fcntl(raw_fd, F_SETFD(FdFlag::FD_CLOEXEC))
-        .with_context(|| format!("Could not set CLOEXEC on file descriptor {raw_fd}"))?;
-
-    // Creating OwnedFd for stdio FDs is not safe.
-    if [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO].contains(&raw_fd) {
-        bail!("File descriptor {raw_fd} is standard I/O descriptor");
-    }
-
-    // Reject RawFds that already have a corresponding OwnedFd.
-    if owned_fds.contains(&raw_fd) {
-        bail!("File descriptor {raw_fd} already owned");
-    }
-    owned_fds.push(raw_fd);
-
-    // SAFETY: Initializing OwnedFd for a RawFd provided in cmdline arguments.
-    // We checked that the integer value corresponds to a valid FD and that this
-    // is the first argument to claim its ownership.
-    Ok(unsafe { OwnedFd::from_raw_fd(raw_fd) })
-}
-
 fn check_vm_support() -> Result<()> {
     if hypervisor_props::is_any_vm_supported()? {
         Ok(())
@@ -122,11 +94,9 @@
 
     let args = Args::parse();
 
-    let mut owned_fds = vec![];
-    let rpc_server_fd = take_fd_ownership(args.rpc_server_fd, &mut owned_fds)
-        .expect("Failed to take ownership of rpc_server_fd");
-    let ready_fd = take_fd_ownership(args.ready_fd, &mut owned_fds)
-        .expect("Failed to take ownership of ready_fd");
+    let rpc_server_fd =
+        take_fd_ownership(args.rpc_server_fd).expect("Failed to take ownership of rpc_server_fd");
+    let ready_fd = take_fd_ownership(args.ready_fd).expect("Failed to take ownership of ready_fd");
 
     // Start thread pool for kernel Binder connection to VirtualizationServiceInternal.
     ProcessState::start_thread_pool();
diff --git a/android/virtualizationservice/Android.bp b/android/virtualizationservice/Android.bp
index f9034af..fb6e39a 100644
--- a/android/virtualizationservice/Android.bp
+++ b/android/virtualizationservice/Android.bp
@@ -38,7 +38,6 @@
         "libbinder_rs",
         "libhex",
         "libhypervisor_props",
-        "liblazy_static",
         "liblibc",
         "liblibsqlite3_sys",
         "liblog_rust",
diff --git a/android/virtualizationservice/aidl/Android.bp b/android/virtualizationservice/aidl/Android.bp
index bca4512..c1bff5e 100644
--- a/android/virtualizationservice/aidl/Android.bp
+++ b/android/virtualizationservice/aidl/Android.bp
@@ -31,6 +31,7 @@
             apex_available: [
                 "com.android.virt",
                 "com.android.compos",
+                "com.android.microfuchsia",
             ],
         },
     },
@@ -150,6 +151,7 @@
             apex_available: [
                 "com.android.virt",
                 "com.android.compos",
+                "com.android.microfuchsia",
             ],
         },
     },
diff --git a/libs/libvmbase/example/image.ld b/android/virtualizationservice/aidl/android/system/virtualizationservice/UsbConfig.aidl
similarity index 66%
copy from libs/libvmbase/example/image.ld
copy to android/virtualizationservice/aidl/android/system/virtualizationservice/UsbConfig.aidl
index 368acbb..1889d2c 100644
--- a/libs/libvmbase/example/image.ld
+++ b/android/virtualizationservice/aidl/android/system/virtualizationservice/UsbConfig.aidl
@@ -1,11 +1,11 @@
 /*
- * Copyright 2022 The Android Open Source Project
+ * Copyright 2024 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
  *
- *     https://www.apache.org/licenses/LICENSE-2.0
+ *      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,
@@ -14,9 +14,9 @@
  * limitations under the License.
  */
 
-MEMORY
-{
-	dtb_region	: ORIGIN = 0x80000000, LENGTH = 2M
-	image		: ORIGIN = 0x80200000, LENGTH = 2M
-	writable_data	: ORIGIN = 0x80400000, LENGTH = 2M
+package android.system.virtualizationservice;
+
+parcelable UsbConfig {
+    /** Enable the USB controller */
+    boolean controller;
 }
diff --git a/android/virtualizationservice/aidl/android/system/virtualizationservice/VirtualMachineAppConfig.aidl b/android/virtualizationservice/aidl/android/system/virtualizationservice/VirtualMachineAppConfig.aidl
index ee39d75..9123742 100644
--- a/android/virtualizationservice/aidl/android/system/virtualizationservice/VirtualMachineAppConfig.aidl
+++ b/android/virtualizationservice/aidl/android/system/virtualizationservice/VirtualMachineAppConfig.aidl
@@ -127,6 +127,9 @@
 
         /** Whether the VM should have network feature. */
         boolean networkSupported;
+
+        /** Additional parameters to pass to the VM's kernel cmdline. */
+        String[] extraKernelCmdlineParams;
     }
 
     /** Configuration parameters guarded by android.permission.USE_CUSTOM_VIRTUAL_MACHINE */
diff --git a/android/virtualizationservice/aidl/android/system/virtualizationservice/VirtualMachineRawConfig.aidl b/android/virtualizationservice/aidl/android/system/virtualizationservice/VirtualMachineRawConfig.aidl
index 07d52db..f559a71 100644
--- a/android/virtualizationservice/aidl/android/system/virtualizationservice/VirtualMachineRawConfig.aidl
+++ b/android/virtualizationservice/aidl/android/system/virtualizationservice/VirtualMachineRawConfig.aidl
@@ -21,6 +21,7 @@
 import android.system.virtualizationservice.DisplayConfig;
 import android.system.virtualizationservice.GpuConfig;
 import android.system.virtualizationservice.InputDevice;
+import android.system.virtualizationservice.UsbConfig;
 
 /** Raw configuration for running a VM. */
 parcelable VirtualMachineRawConfig {
@@ -100,4 +101,9 @@
     @nullable GpuConfig gpuConfig;
 
     @nullable AudioConfig audioConfig;
+
+    boolean noBalloon;
+
+    /** Enable or disable USB passthrough support */
+    @nullable UsbConfig usbConfig;
 }
diff --git a/android/virtualizationservice/src/aidl.rs b/android/virtualizationservice/src/aidl.rs
index acdb53a..0f16291 100644
--- a/android/virtualizationservice/src/aidl.rs
+++ b/android/virtualizationservice/src/aidl.rs
@@ -33,7 +33,6 @@
     self, wait_for_interface, BinderFeatures, ExceptionCode, Interface, IntoBinderResult,
     LazyServiceGuard, ParcelFileDescriptor, Status, Strong,
 };
-use lazy_static::lazy_static;
 use libc::{VMADDR_CID_HOST, VMADDR_CID_HYPERVISOR, VMADDR_CID_LOCAL};
 use log::{error, info, warn};
 use nix::unistd::{chown, Uid};
@@ -52,7 +51,7 @@
 use std::os::unix::fs::PermissionsExt;
 use std::os::unix::raw::{pid_t, uid_t};
 use std::path::{Path, PathBuf};
-use std::sync::{Arc, Condvar, Mutex, Weak};
+use std::sync::{Arc, Condvar, LazyLock, Mutex, Weak};
 use tombstoned_client::{DebuggerdDumpType, TombstonedConnection};
 use virtualizationcommon::Certificate::Certificate;
 use virtualizationmaintenance::{
@@ -157,18 +156,18 @@
     0xb9, 0x0f,
 ];
 
-lazy_static! {
-    static ref FAKE_PROVISIONED_KEY_BLOB_FOR_TESTING: Mutex<Option<Vec<u8>>> = Mutex::new(None);
-    static ref VFIO_SERVICE: Strong<dyn IVfioHandler> =
-        wait_for_interface(<BpVfioHandler as IVfioHandler>::get_descriptor())
-            .expect("Could not connect to VfioHandler");
-    static ref NETWORK_SERVICE: Strong<dyn IVmnic> =
-        wait_for_interface(<BpVmnic as IVmnic>::get_descriptor())
-            .expect("Could not connect to Vmnic");
-    static ref TETHERING_SERVICE: Strong<dyn IVmTethering> =
-        wait_for_interface(<BpVmTethering as IVmTethering>::get_descriptor())
-            .expect("Could not connect to VmTethering");
-}
+static FAKE_PROVISIONED_KEY_BLOB_FOR_TESTING: Mutex<Option<Vec<u8>>> = Mutex::new(None);
+static VFIO_SERVICE: LazyLock<Strong<dyn IVfioHandler>> = LazyLock::new(|| {
+    wait_for_interface(<BpVfioHandler as IVfioHandler>::get_descriptor())
+        .expect("Could not connect to VfioHandler")
+});
+static NETWORK_SERVICE: LazyLock<Strong<dyn IVmnic>> = LazyLock::new(|| {
+    wait_for_interface(<BpVmnic as IVmnic>::get_descriptor()).expect("Could not connect to Vmnic")
+});
+static TETHERING_SERVICE: LazyLock<Strong<dyn IVmTethering>> = LazyLock::new(|| {
+    wait_for_interface(<BpVmTethering as IVmTethering>::get_descriptor())
+        .expect("Could not connect to VmTethering")
+});
 
 fn is_valid_guest_cid(cid: Cid) -> bool {
     (GUEST_CID_MIN..=GUEST_CID_MAX).contains(&cid)
diff --git a/android/virtualizationservice/vfio_handler/Android.bp b/android/virtualizationservice/vfio_handler/Android.bp
index 66fc2ee..3635cf1 100644
--- a/android/virtualizationservice/vfio_handler/Android.bp
+++ b/android/virtualizationservice/vfio_handler/Android.bp
@@ -25,7 +25,6 @@
         "libandroid_logger",
         "libanyhow",
         "libbinder_rs",
-        "liblazy_static",
         "liblog_rust",
         "libnix",
         "librustutils",
diff --git a/android/virtualizationservice/vfio_handler/src/aidl.rs b/android/virtualizationservice/vfio_handler/src/aidl.rs
index b527260..3b4d0c5 100644
--- a/android/virtualizationservice/vfio_handler/src/aidl.rs
+++ b/android/virtualizationservice/vfio_handler/src/aidl.rs
@@ -20,11 +20,11 @@
 use android_system_virtualizationservice_internal::aidl::android::system::virtualizationservice_internal::IVfioHandler::VfioDev::VfioDev;
 use android_system_virtualizationservice_internal::binder::ParcelFileDescriptor;
 use binder::{self, BinderFeatures, ExceptionCode, Interface, IntoBinderResult, Strong};
-use lazy_static::lazy_static;
 use log::error;
 use std::fs::{read_link, write, File};
 use std::io::{Read, Seek, SeekFrom, Write};
 use std::mem::size_of;
+use std::sync::LazyLock;
 use std::path::{Path, PathBuf};
 use rustutils::system_properties;
 use zerocopy::{
@@ -169,10 +169,9 @@
     _custom: [U32<BigEndian>; 4],
 }
 
-lazy_static! {
-    static ref IS_VFIO_SUPPORTED: bool =
-        Path::new(DEV_VFIO_PATH).exists() && Path::new(VFIO_PLATFORM_DRIVER_PATH).exists();
-}
+static IS_VFIO_SUPPORTED: LazyLock<bool> = LazyLock::new(|| {
+    Path::new(DEV_VFIO_PATH).exists() && Path::new(VFIO_PLATFORM_DRIVER_PATH).exists()
+});
 
 fn check_platform_device(path: &Path) -> binder::Result<()> {
     if !path.exists() {
diff --git a/android/vm/src/main.rs b/android/vm/src/main.rs
index 3c0887c..f2c2fa4 100644
--- a/android/vm/src/main.rs
+++ b/android/vm/src/main.rs
@@ -109,6 +109,23 @@
     /// Note: this is only supported on Android kernels android14-5.15 and higher.
     #[arg(long)]
     gdb: Option<NonZeroU16>,
+
+    /// Whether to enable earlycon. Only supported for debuggable Linux-based VMs.
+    #[cfg(debuggable_vms_improvements)]
+    #[arg(long)]
+    enable_earlycon: bool,
+}
+
+impl DebugConfig {
+    #[cfg(debuggable_vms_improvements)]
+    fn enable_earlycon(&self) -> bool {
+        self.enable_earlycon
+    }
+
+    #[cfg(not(debuggable_vms_improvements))]
+    fn enable_earlycon(&self) -> bool {
+        false
+    }
 }
 
 #[derive(Args, Default)]
@@ -142,12 +159,12 @@
 
 impl MicrodroidConfig {
     #[cfg(vendor_modules)]
-    fn vendor(&self) -> &Option<PathBuf> {
-        &self.vendor
+    fn vendor(&self) -> Option<&PathBuf> {
+        self.vendor.as_ref()
     }
 
     #[cfg(not(vendor_modules))]
-    fn vendor(&self) -> Option<PathBuf> {
+    fn vendor(&self) -> Option<&PathBuf> {
         None
     }
 
@@ -162,13 +179,13 @@
     }
 
     #[cfg(device_assignment)]
-    fn devices(&self) -> &Vec<PathBuf> {
+    fn devices(&self) -> &[PathBuf] {
         &self.devices
     }
 
     #[cfg(not(device_assignment))]
-    fn devices(&self) -> Vec<PathBuf> {
-        Vec::new()
+    fn devices(&self) -> &[PathBuf] {
+        &[]
     }
 }
 
diff --git a/android/vm/src/run.rs b/android/vm/src/run.rs
index cb15802..823546f 100644
--- a/android/vm/src/run.rs
+++ b/android/vm/src/run.rs
@@ -36,7 +36,7 @@
 use std::fs::File;
 use std::io;
 use std::io::{Read, Write};
-use std::os::unix::io::{AsRawFd, FromRawFd};
+use std::os::fd::AsFd;
 use std::path::{Path, PathBuf};
 use vmclient::{ErrorCode, VmInstance};
 use vmconfig::{get_debug_level, open_parcel_file, VmConfig};
@@ -148,7 +148,7 @@
 
     let payload_config_str = format!("{:?}!{:?}", config.apk, payload);
 
-    let custom_config = CustomConfig {
+    let mut custom_config = CustomConfig {
         gdbPort: config.debug.gdb.map(u16::from).unwrap_or(0) as i32, // 0 means no gdb
         vendorImage: vendor,
         devices: config
@@ -163,6 +163,21 @@
         ..Default::default()
     };
 
+    if config.debug.enable_earlycon() {
+        if config.debug.debug != DebugLevel::FULL {
+            bail!("earlycon is only supported for debuggable VMs")
+        }
+        if cfg!(target_arch = "aarch64") {
+            custom_config
+                .extraKernelCmdlineParams
+                .push(String::from("earlycon=uart8250,mmio,0x3f8"));
+        } else if cfg!(target_arch = "x86_64") {
+            custom_config.extraKernelCmdlineParams.push(String::from("earlycon=uart8250,io,0x3f8"));
+        } else {
+            bail!("unexpected architecture!");
+        }
+    }
+
     let vm_config = VirtualMachineConfig::AppConfig(VirtualMachineAppConfig {
         name: config.common.name.unwrap_or_else(|| String::from("VmRunApp")),
         apk: apk_fd.into(),
@@ -365,16 +380,6 @@
 }
 
 /// Safely duplicate the file descriptor.
-fn duplicate_fd<T: AsRawFd>(file: T) -> io::Result<File> {
-    let fd = file.as_raw_fd();
-    // SAFETY: This just duplicates a file descriptor which we know to be valid, and we check for an
-    // an error.
-    let dup_fd = unsafe { libc::dup(fd) };
-    if dup_fd < 0 {
-        Err(io::Error::last_os_error())
-    } else {
-        // SAFETY: We have just duplicated the file descriptor so we own it, and `from_raw_fd` takes
-        // ownership of it.
-        Ok(unsafe { File::from_raw_fd(dup_fd) })
-    }
+fn duplicate_fd<T: AsFd>(file: T) -> io::Result<File> {
+    Ok(file.as_fd().try_clone_to_owned()?.into())
 }
diff --git a/build/Android.bp b/build/Android.bp
index 66cc626..6ab1d89 100644
--- a/build/Android.bp
+++ b/build/Android.bp
@@ -44,6 +44,9 @@
     }) + select(release_flag("RELEASE_AVF_ENABLE_VIRT_CPUFREQ"), {
         true: ["virt_cpufreq"],
         default: [],
+    }) + select(release_flag("RELEASE_AVF_IMPROVE_DEBUGGABLE_VMS"), {
+        true: ["debuggable_vms_improvements"],
+        default: [],
     }) + select(release_flag("RELEASE_AVF_SUPPORT_CUSTOM_VM_WITH_PARAVIRTUALIZED_DEVICES"), {
         true: ["paravirtualized_devices"],
         default: [],
diff --git a/build/apex/Android.bp b/build/apex/Android.bp
index 0a2b2de..f493202 100644
--- a/build/apex/Android.bp
+++ b/build/apex/Android.bp
@@ -7,12 +7,28 @@
     "microdroid_vbmeta",
 ]
 
-apex {
+soong_config_module_type {
+    name: "virt_apex",
+    module_type: "apex",
+    config_namespace: "ANDROID",
+    bool_variables: [
+        "avf_enabled",
+    ],
+    properties: [
+        "defaults",
+    ],
+}
+
+virt_apex {
     name: "com.android.virt",
-    defaults: select(soong_config_variable("ANDROID", "avf_enabled"), {
-        "true": ["com.android.virt_avf_enabled"],
-        default: ["com.android.virt_avf_disabled"],
-    }),
+    soong_config_variables: {
+        avf_enabled: {
+            defaults: ["com.android.virt_avf_enabled"],
+            conditions_default: {
+                defaults: ["com.android.virt_avf_disabled"],
+            },
+        },
+    },
 }
 
 apex_defaults {
@@ -30,7 +46,10 @@
     apps: [
         "android.system.virtualmachine.res",
     ] + select(release_flag("RELEASE_AVF_SUPPORT_CUSTOM_VM_WITH_PARAVIRTUALIZED_DEVICES"), {
-        true: ["VmLauncherApp"],
+        true: [
+            "VmLauncherApp",
+            "VmTerminalApp",
+        ],
         default: [],
     }),
 
diff --git a/build/apex/product_packages.mk b/build/apex/product_packages.mk
index e710021..a024192 100644
--- a/build/apex/product_packages.mk
+++ b/build/apex/product_packages.mk
@@ -62,3 +62,15 @@
     $(error RELEASE_AVF_ENABLE_LLPVM_CHANGES must also be enabled)
   endif
 endif
+
+ifdef RELEASE_AVF_ENABLE_EARLY_VM
+  # We can't query TARGET_RELEASE from here, so we use RELEASE_AIDL_USE_UNFROZEN as a proxy value of
+  # whether we are building -next release.
+  ifneq ($(RELEASE_AIDL_USE_UNFROZEN),true)
+    $(error RELEASE_AVF_ENABLE_EARLY_VM can only be enabled in trunk_staging until b/357025924 is fixed)
+  endif
+endif
+
+ifdef RELEASE_AVF_SUPPORT_CUSTOM_VM_WITH_PARAVIRTUALIZED_DEVICES
+  PRODUCT_PACKAGES += LinuxInstallerAppStub
+endif
diff --git a/docs/custom_vm.md b/docs/custom_vm.md
index cdeddf5..b02fbf7 100644
--- a/docs/custom_vm.md
+++ b/docs/custom_vm.md
@@ -25,19 +25,35 @@
 The `vm` command also has other subcommands for debugging; run
 `/apex/com.android.virt/bin/vm help` for details.
 
-### Running Debian with u-boot
-1. Prepare u-boot binary from `u-boot_crosvm_aarch64` in https://ci.android.com/builds/branches/aosp_u-boot-mainline/grid
-or build it by https://source.android.com/docs/devices/cuttlefish/bootloader-dev#develop-bootloader
-2. Prepare Debian image from https://cloud.debian.org/images/cloud/ (We tested nocloud image)
-3. Copy `u-boot.bin`, Debian image file(like `debian-12-nocloud-arm64.raw`) and `vm_config.json` to `/data/local/tmp`
+### Running Debian
+1. Download an ARM64 image from https://cloud.debian.org/images/cloud/ (We tested nocloud image)
+
+2. Resize the image
+```shell
+truncate -s 20G debian.img
+virt-resize --expand /dev/sda1 <download_image_file> debian.img
+```
+
+3. Copy the image file
+```shell
+tar cfS debian.img.tar debian.img
+adb push debian.img.tar /data/local/tmp/
+adb shell tar xf /data/local/tmp/debian.img.tar -C /data/local/tmp/
+adb shell rm /data/local/tmp/debian.img.tar
+adb shell chmod a+w /data/local/tmp/debian.img
+rm debian.img.tar
+```
+
+Note: we tar and untar to keep the image file sparse.
+
+4. Make the VM config file
 ```shell
 cat > vm_config.json <<EOF
 {
     "name": "debian",
-    "bootloader": "/data/local/tmp/u-boot.bin",
     "disks": [
         {
-            "image": "/data/local/tmp/debian-12-nocloud-arm64.raw",
+            "image": "/data/local/tmp/debian.img",
             "partitions": [],
             "writable": true
         }
@@ -45,24 +61,60 @@
     "protected": false,
     "cpu_topology": "match_host",
     "platform_version": "~1.0",
-    "memory_mib" : 8096
+    "memory_mib": 8096,
+    "debuggable": true,
+    "console_out": true,
+    "connect_console": true,
+    "console_input_device": "ttyS0",
+    "network": true,
+    "input": {
+        "touchscreen": true,
+        "keyboard": true,
+        "mouse": true,
+        "trackpad": true,
+        "switches": true
+    },
+    "audio": {
+        "speaker": true,
+         "microphone": true
+    },
+    "gpu": {
+        "backend": "virglrenderer",
+        "context_types": ["virgl2"]
+    },
+    "display": {
+        "refresh_rate": "30"
+    }
 }
 EOF
-adb push `u-boot.bin` /data/local/tmp
-adb push `debian-12-nocloud-arm64.raw` /data/local/tmp
-adb push vm_config.json /data/local/tmp/vm_config.json
+adb push vm_config.json /data/local/tmp/
 ```
-4. Launch VmLauncherApp(the detail will be explain below)
+
+5. Launch VmLauncherApp(the detail will be explain below)
+
+6. For console, we can refer to `Debugging` section below. (id: root)
+
+7. For graphical shell, you need to install xfce(for now, only xfce is tested)
+```
+apt install task-xfce-desktop
+dpkg --configure -a (if necessary)
+systemctl set-default graphical.target
+
+# need non-root user for graphical shell
+adduser linux
+# optional
+adduser linux sudo
+reboot
+```
 
 ## Graphical VMs
 
 To run OSes with graphics support, simply
-`packages/modules/Virtualization/tests/ferrochrome/ferrochrome.sh`. It prepares
-and launches the ChromiumOS, which is the only officially supported guest
-payload. We will be adding more OSes in the future.
+`packages/modules/Virtualization/tests/ferrochrome/ferrochrome.sh --forever`.
+It prepares and launches the ChromiumOS, which is the only officially supported
+guest payload. We will be adding more OSes in the future.
 
-If you want to do so by yourself (e.g. boot with your build), follow the
-instruction below.
+If you want to do so by yourself, follow the instruction below.
 
 ### Prepare a guest image
 
@@ -192,16 +244,34 @@
             "writable": true
         }
     ],
+    "protected": false,
+    "cpu_topology": "match_host",
+    "platform_version": "~1.0",
+    "memory_mib": 8096,
+    "debuggable": true,
+    "console_out": true,
+    "connect_console": true,
+    "console_input_device": "hvc0",
+    "network": true,
+    "input": {
+        "touchscreen": true,
+        "keyboard": true,
+        "mouse": true,
+        "trackpad": true,
+        "switches": true
+    },
+    "audio": {
+        "speaker": true,
+        "microphone": true
+    },
     "gpu": {
         "backend": "virglrenderer",
         "context_types": ["virgl2"]
     },
-    "params": "root=/dev/vda3 rootwait noinitrd ro enforcing=0 cros_debug cros_secure",
-    "protected": false,
-    "cpu_topology": "match_host",
-    "platform_version": "~1.0",
-    "memory_mib" : 8096,
-    "console_input_device": "hvc0"
+    "display": {
+        "scale": "0.77",
+        "refresh_rate": "30"
+    }
 }
 ```
 
@@ -235,15 +305,12 @@
 ```
 
 To see console logs only, check
-`/data/data/com{,.google}.android.virtualization.vmlauncher/files/console.log`
-
-For HSUM enabled devices,
-`/data/user/${current_user_id}/com{,.google}.android.virtualization.vmlauncher/files/console.log`
+`/data/user/${current_user_id}/com{,.google}.android.virtualization.vmlauncher/files/${vm_name}.log`
 
 You can monitor console out as follows
 
 ```shell
-$ adb shell 'su root tail +0 -F /data/user/$(am get-current-user)/com{,.google}.android.virtualization.vmlauncher/files/console.log'
+$ adb shell 'su root tail +0 -F /data/user/$(am get-current-user)/com{,.google}.android.virtualization.vmlauncher/files/${vm_name}.log'
 ```
 
 For ChromiumOS, you can enter to the console via SSH connection. Check your IP
diff --git a/docs/device_trees.md b/docs/device_trees.md
new file mode 100644
index 0000000..003e7be
--- /dev/null
+++ b/docs/device_trees.md
@@ -0,0 +1,211 @@
+# Device Trees in AVF
+
+This document aims to provide a centralized overview of the way the Android
+Virtualization Framework (AVF) composes and validates the device tree (DT)
+received by protected guest kernels, such as [Microdroid].
+
+[Microdroid]: ../guest/microdroid/README.md
+
+## Context
+
+As of Android 15, AVF only supports protected virtual machines (pVMs) on
+AArch64. On this architecture, the Linux kernel and many other embedded projects
+have adopted the [device tree format][dtspec] as the way to describe the
+platform to the software. This includes so-called "[platform devices]" (which are
+non-discoverable MMIO-based devices), CPUs (number, characteristics, ...),
+memory (address and size), and more.
+
+With virtualization, it is common for the virtual machine manager (VMM, e.g.
+crosvm or QEMU), typically a host userspace process, to generate the DT as it
+configures the virtual platform. In the case of AVF, the threat model prevents
+the guest from trusting the host and therefore the DT must be validated by a
+trusted entity. To avoid adding extra logic in the highly-privileged hypervisor,
+AVF relies on [pvmfw], a small piece of code that runs in the context of the
+guest (but before the guest kernel), loaded by the hypervisor, which validates
+the untrusted device tree. If any anomaly is detected, pvmfw aborts the boot of
+the guest. As a result, the guest kernel can trust the DT it receives.
+
+The DT sanitized by pvmfw is received by guests following the [Linux boot
+protocol][booting.txt] and includes both virtual and physical devices, which are
+hardly distinguishable from the guest's perspective (although the context could
+provide information helping to identify the nature of the device e.g. a
+virtio-blk device is likely to be virtual while a platform accelerator would be
+physical). The guest is not expected to treat physical devices differently from
+virtual devices and this distinction is therefore not relevant.
+
+```
+┌────────┐               ┌───────┐ valid              ┌───────┐
+│ crosvm ├──{input DT}──►│ pvmfw ├───────{guest DT}──►│ guest │
+└────────┘               └───┬───┘                    └───────┘
+                             │   invalid
+                             └───────────► SYSTEM RESET
+```
+
+[dtspec]: https://www.devicetree.org/specifications
+[platform devices]: https://docs.kernel.org/driver-api/driver-model/platform.html
+[pvmfw]: ../guest/pvmfw/README.md
+[booting.txt]: https://www.kernel.org/doc/Documentation/arm64/booting.txt
+
+## Device Tree Generation (Host-side)
+
+crosvm describes the virtual platform to the guest by generating a DT
+enumerating the memory region, virtual CPUs, virtual devices, and other
+properties (e.g. ramdisk, cmdline, ...). For physical devices (assigned using
+VFIO), it generates simple nodes describing the fundamental properties it
+configures for the devices i.e. `<reg>`, `<interrupts>`, `<iommus>`
+(respectively referring to IPA ranges, vIRQs, and pvIOMMUs).
+
+It is possible for the caller of crosvm to pass more DT properties or nodes to
+the guest by providing device tree overlays (DTBO) to crosvm. These overlays get
+applied after the DT describing the configured platform has been generated, the
+final result getting passed to the guest.
+
+For physical devices, crosvm supports applying a "filtered" subset of the DTBO
+received, where subnodes are only kept if they have a label corresponding to an
+assigned VFIO device. This allows the caller to always pass the same overlay,
+irrespective of which physical devices are being assigned, greatly simplifying
+the logic of the caller. This makes it possible for crosvm to support complex
+nodes for physical devices without including device-specific logic as any extra
+property (e.g. `<compatible>`) will be passed through the overlay and added to
+the final DT in a generic way. This _vm DTBO_ is read from an AVB-verified
+partition (see `ro.boot.hypervisor.vm_dtbo_idx`).
+
+Otherwise, if the `filter` option is not used, crosvm applies the overlay fully.
+This can be used to supplement the guest DT with nodes and properties which are
+not tied to particular assigned physical devices or emulated virtual devices. In
+particular, `virtualizationservice` currently makes use of it to pass
+AVF-specific properties.
+
+```
+            ┌─►{DTBO,filter}─┐
+┌─────────┐ │                │  ┌────────┐
+│ virtmgr ├─┼────►{DTBO}─────┼─►│ crosvm ├───►{guest DT}───► ...
+└─────────┘ │                │  └────────┘
+            └─►{VFIO sysfs}──┘
+```
+
+## Device Tree Sanitization
+
+pvmfw intercepts the boot sequence of the guest and locates the DT generated by
+the VMM through the VMM-guest ABI. A design goal of pvmfw is to have as little
+side-effect as possible on the guest so that the VMM can keep the illusion that
+it configured and booted the guest directly and the guest does not need to rely
+or expect pvmfw to have performed any noticeable work (a noteworthy exception
+being the memory region describing the [DICE chain]). As a result, both VMM and
+guest can mostly use the same logic between protected and non-protected VMs
+(where pvmfw does not run) and keep the simpler VMM-guest execution model they
+are used to. In the context of pvmfw and DT validation, the final DT passed by
+crosvm to the guest is typically referred to as the _input DT_.
+
+```
+┌────────┐                  ┌───────┐                  ┌───────┐
+│ crosvm ├───►{input DT}───►│ pvmfw │───►{guest DT}───►│ guest │
+└────────┘                  └───────┘                  └───────┘
+                              ▲   ▲
+   ┌─────┐  ┌─►{VM DTBO}──────┘   │
+   │ ABL ├──┤                     │
+   └─────┘  └─►{ref. DT}──────────┘
+```
+
+[DICE chain]: ../guest/pvmfw/README.md#virtual-platform-dice-chain-handover
+
+### Virtual Platform
+
+The DT sanitization policy in pvmfw matches the virtual platform defined by
+crosvm and its implementation is therefore tightly coupled with it (this is one
+reason why AVF expects pvmfw and the VMM to be updated in sync). It covers
+fundamental properties of the platform (e.g.  location of main memory,
+properties of CPUs, layout of the interrupt controller, ...) and the properties
+of (sometimes optional) virtual devices supported by crosvm and used by AVF
+guests.
+
+### Physical Devices
+
+To support device assignment, pvmfw needs to be able to validate physical
+platform-specific device properties. To achieve this in a platform-agnostic way,
+pvmfw receives a DT overlay (called the _VM DTBO_) from the Android Bootloader
+(ABL), containing a description of all the assignable devices. By detecting
+which devices have been assigned using platform-specific reserved DT labels, it
+can validate the properties of the physical devices through [generic logic].
+pvmfw also verifies with the hypervisor that the guest addresses from the DT
+have been properly mapped to the expected physical addresses of the devices; see
+[_Getting started with device assignment_][da.md].
+
+Note that, as pvmfw runs within the context of an individual pVM, it cannot
+detect abuses by the host of device assignment across guests (e.g.
+simultaneously assigning the same device to multiple guests), and it is the
+responsibility of the hypervisor to enforce this isolation. AVF also relies on
+the hypervisor to clear the state of the device on donation and (most
+importantly) on return to the host so that pvmfw does not need to access the
+assigned devices.
+
+[generic logic]: ../guest/pvmfw/src/device_assignment.rs
+[da.md]: ../docs/device_assignment.md
+
+### Extra Properties (Security-Sensitive)
+
+Some AVF use-cases require passing platform-specific inputs to protected guests.
+If these are security-sensitive, they must also be validated before being used
+by the guest. In most cases, the DT property is platform-agnostic (and supported
+by the generic guest) but its value is platform-specific. The _reference DT_ is
+an [input of pvmfw][pvmfw-config] (received from the loader) and used to
+validate DT entries which are:
+
+- security-sensitive: the host should not be able to tamper with these values
+- not confidential: the property is visible to the host (as it generates it)
+- Same across VMs: the property (if present) must be same across all instances
+- possibly optional: pvmfw does not abort the boot if the entry is missing
+
+[pvmfw-config]: ../guest/pvmfw/README.md#configuration-data-format
+
+### Extra Properties (Host-Generated)
+
+Finally, to allow the host to generate values that vary between guests (and
+which therefore can't be described using one the previous mechanisms), pvmfw
+treats the subtree of the input DT at path `/avf/untrusted` differently: it only
+performs minimal sanitization on it, allowing the host to pass arbitrary,
+unsanitized DT entries. Therefore, this subtree must be used with extra
+validation by guests e.g. only accessed by path (where the name, "`untrusted`",
+acts as a reminder), with no assumptions about the presence or correctness of
+nodes or properties, without expecting properties to be well-formed, ...
+
+In particular, pvmfw prevents other nodes from linking to this subtree
+(`<phandle>` is rejected) and limits the risk of guests unexpectedly parsing it
+other than by path (`<compatible>` is also rejected) but guests must not support
+non-standard ways of binding against nodes by property as they would then be
+vulnerable to attacks from a malicious host.
+
+### Implementation details
+
+DT sanitization is currently implemented in pvmfw by parsing the input DT into
+temporary data structures and pruning a built-in device tree (called the
+_platform DT_; see [platform.dts]) accordingly. For device assignment, it prunes
+the received VM DTBO to only keep the devices that have actually been assigned
+(as the overlay contains all assignable devices of the platform).
+
+[platform.dts]: ../guest/pvmfw/platform.dts
+
+## DT for guests
+
+### AVF-specific properties and nodes
+
+For Microdroid and other AVF guests, some special DT entries are defined:
+
+- the `/chosen/avf,new-instance` flag, set when pvmfw triggered the generation
+  of a new set of CDIs (see DICE) _i.e._ the pVM instance was booted for the
+  first time. This should be used by the next stages to synchronise the
+  generation of new CDIs and detect a malicious host attempting to force only
+  one stage to do so. This property becomes obsolete (and might not be set) when
+  [deferred rollback protection] is used by the guest kernel;
+
+- the `/chosen/avf,strict-boot` flag, always set for protected VMs and can be
+  used by guests to enable extra validation;
+
+- the `/avf/untrusted/defer-rollback-protection` flag controls [deferred
+  rollback protection] on devices and for guests which support it;
+
+- the host-allocated `/avf/untrusted/instance-id` is used to assign a unique
+  identifier to the VM instance & is used for differentiating VM secrets as well
+  as by guest OS to index external storage such as Secretkeeper.
+
+[deferred rollback protection]: ../docs/updatable_vm.md#deferring-rollback-protection
diff --git a/guest/authfs/Android.bp b/guest/authfs/Android.bp
index b11da3d..d7a8322 100644
--- a/guest/authfs/Android.bp
+++ b/guest/authfs/Android.bp
@@ -13,7 +13,6 @@
         "libanyhow",
         "libauthfs_fsverity_metadata",
         "libbinder_rs",
-        "libcfg_if",
         "libclap",
         "libfsverity_digests_proto_rust",
         "libfuse_rust",
diff --git a/guest/authfs/src/fusefs.rs b/guest/authfs/src/fusefs.rs
index 618b8ac..fa4076d 100644
--- a/guest/authfs/src/fusefs.rs
+++ b/guest/authfs/src/fusefs.rs
@@ -385,15 +385,6 @@
     }
 }
 
-cfg_if::cfg_if! {
-    if #[cfg(all(any(target_arch = "aarch64", target_arch = "riscv64"),
-                 target_pointer_width = "64"))] {
-        fn blk_size() -> libc::c_int { CHUNK_SIZE as libc::c_int }
-    } else {
-        fn blk_size() -> libc::c_long { CHUNK_SIZE as libc::c_long }
-    }
-}
-
 #[allow(clippy::enum_variant_names)]
 enum AccessMode {
     ReadOnly,
@@ -421,7 +412,7 @@
     st.st_gid = 0;
     st.st_size = libc::off64_t::try_from(file_size)
         .map_err(|_| io::Error::from_raw_os_error(libc::EFBIG))?;
-    st.st_blksize = blk_size();
+    st.st_blksize = CHUNK_SIZE.try_into().unwrap();
     // Per man stat(2), st_blocks is "Number of 512B blocks allocated".
     st.st_blocks = libc::c_longlong::try_from(divide_roundup(file_size, 512))
         .map_err(|_| io::Error::from_raw_os_error(libc::EFBIG))?;
diff --git a/guest/authfs_service/Android.bp b/guest/authfs_service/Android.bp
index 2101a36..e508c17 100644
--- a/guest/authfs_service/Android.bp
+++ b/guest/authfs_service/Android.bp
@@ -18,6 +18,7 @@
         "libnix",
         "librpcbinder_rs",
         "librustutils",
+        "libsafe_ownedfd",
         "libshared_child",
     ],
     prefer_rlib: true,
diff --git a/guest/authfs_service/src/main.rs b/guest/authfs_service/src/main.rs
index 97e684d..ff2f770 100644
--- a/guest/authfs_service/src/main.rs
+++ b/guest/authfs_service/src/main.rs
@@ -26,9 +26,10 @@
 use log::*;
 use rpcbinder::RpcServer;
 use rustutils::sockets::android_get_control_socket;
+use safe_ownedfd::take_fd_ownership;
 use std::ffi::OsString;
 use std::fs::{create_dir, read_dir, remove_dir_all, remove_file};
-use std::os::unix::io::{FromRawFd, OwnedFd};
+use std::os::unix::io::OwnedFd;
 use std::sync::atomic::{AtomicUsize, Ordering};
 
 use authfs_aidl_interface::aidl::com::android::virt::fs::AuthFsConfig::AuthFsConfig;
@@ -109,22 +110,9 @@
 }
 
 /// Prepares a socket file descriptor for the authfs service.
-///
-/// # Safety requirement
-///
-/// The caller must ensure that this function is the only place that claims ownership
-/// of the file descriptor and it is called only once.
-unsafe fn prepare_authfs_service_socket() -> Result<OwnedFd> {
+fn prepare_authfs_service_socket() -> Result<OwnedFd> {
     let raw_fd = android_get_control_socket(AUTHFS_SERVICE_SOCKET_NAME)?;
-
-    // Creating OwnedFd for stdio FDs is not safe.
-    if [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO].contains(&raw_fd) {
-        bail!("File descriptor {raw_fd} is standard I/O descriptor");
-    }
-    // SAFETY: Initializing OwnedFd for a RawFd created by the init.
-    // We checked that the integer value corresponds to a valid FD and that the caller
-    // ensures that this is the only place to claim its ownership.
-    Ok(unsafe { OwnedFd::from_raw_fd(raw_fd) })
+    Ok(take_fd_ownership(raw_fd)?)
 }
 
 #[allow(clippy::eq_op)]
@@ -137,8 +125,7 @@
 
     clean_up_working_directory()?;
 
-    // SAFETY: This is the only place we take the ownership of the fd of the authfs service.
-    let socket_fd = unsafe { prepare_authfs_service_socket()? };
+    let socket_fd = prepare_authfs_service_socket()?;
     let service = AuthFsService::new_binder(debuggable).as_binder();
     debug!("{} is starting as a rpc service.", AUTHFS_SERVICE_SOCKET_NAME);
     let server = RpcServer::new_bound_socket(service, socket_fd)?;
diff --git a/guest/microdroid_manager/Android.bp b/guest/microdroid_manager/Android.bp
index 9c9a3d0..82e26b7 100644
--- a/guest/microdroid_manager/Android.bp
+++ b/guest/microdroid_manager/Android.bp
@@ -48,6 +48,7 @@
         "libprotobuf",
         "librpcbinder_rs",
         "librustutils",
+        "libsafe_ownedfd",
         "libsecretkeeper_client",
         "libsecretkeeper_comm_nostd",
         "libscopeguard",
@@ -59,6 +60,7 @@
         "libvsock",
         "librand",
         "libzeroize",
+        "libsafe_ownedfd",
     ],
     init_rc: ["microdroid_manager.rc"],
     multilib: {
diff --git a/guest/microdroid_manager/src/main.rs b/guest/microdroid_manager/src/main.rs
index 990d27a..8b676b8 100644
--- a/guest/microdroid_manager/src/main.rs
+++ b/guest/microdroid_manager/src/main.rs
@@ -50,13 +50,14 @@
 use rustutils::sockets::android_get_control_socket;
 use rustutils::system_properties;
 use rustutils::system_properties::PropertyWatcher;
+use safe_ownedfd::take_fd_ownership;
 use secretkeeper_comm::data_types::ID_SIZE;
 use std::borrow::Cow::{Borrowed, Owned};
 use std::env;
 use std::ffi::CString;
 use std::fs::{self, create_dir, File, OpenOptions};
 use std::io::{Read, Write};
-use std::os::unix::io::{FromRawFd, OwnedFd};
+use std::os::unix::io::OwnedFd;
 use std::os::unix::process::CommandExt;
 use std::os::unix::process::ExitStatusExt;
 use std::path::Path;
@@ -199,13 +200,7 @@
     );
     info!("started.");
 
-    // SAFETY: This is the only place we take the ownership of the fd of the vm payload service.
-    //
-    // To ensure that the CLOEXEC flag is set on the file descriptor as early as possible,
-    // it is necessary to fetch the socket corresponding to vm_payload_service at the
-    // very beginning, as android_get_control_socket() sets the CLOEXEC flag on the file
-    // descriptor.
-    let vm_payload_service_fd = unsafe { prepare_vm_payload_service_socket()? };
+    let vm_payload_service_fd = prepare_vm_payload_service_socket()?;
 
     load_crashkernel_if_supported().context("Failed to load crashkernel")?;
 
@@ -487,22 +482,9 @@
 }
 
 /// Prepares a socket file descriptor for the vm payload service.
-///
-/// # Safety
-///
-/// The caller must ensure that this function is the only place that claims ownership
-/// of the file descriptor and it is called only once.
-unsafe fn prepare_vm_payload_service_socket() -> Result<OwnedFd> {
+fn prepare_vm_payload_service_socket() -> Result<OwnedFd> {
     let raw_fd = android_get_control_socket(VM_PAYLOAD_SERVICE_SOCKET_NAME)?;
-
-    // Creating OwnedFd for stdio FDs is not safe.
-    if [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO].contains(&raw_fd) {
-        bail!("File descriptor {raw_fd} is standard I/O descriptor");
-    }
-    // SAFETY: Initializing OwnedFd for a RawFd created by the init.
-    // We checked that the integer value corresponds to a valid FD and that the caller
-    // ensures that this is the only place to claim its ownership.
-    Ok(unsafe { OwnedFd::from_raw_fd(raw_fd) })
+    Ok(take_fd_ownership(raw_fd)?)
 }
 
 fn is_strict_boot() -> bool {
diff --git a/guest/microdroid_manager/src/verify.rs b/guest/microdroid_manager/src/verify.rs
index 84feb68..90671a6 100644
--- a/guest/microdroid_manager/src/verify.rs
+++ b/guest/microdroid_manager/src/verify.rs
@@ -272,7 +272,7 @@
     for argument in args {
         cmd.arg("--apk").arg(argument.apk).arg(argument.idsig).arg(argument.name);
         if let Some(root_hash) = argument.saved_root_hash {
-            cmd.arg(&hex::encode(root_hash));
+            cmd.arg(hex::encode(root_hash));
         } else {
             cmd.arg("none");
         }
diff --git a/guest/pvmfw/README.md b/guest/pvmfw/README.md
index cc5ae71..4712d77 100644
--- a/guest/pvmfw/README.md
+++ b/guest/pvmfw/README.md
@@ -405,32 +405,25 @@
 ### Handover ABI
 
 After verifying the guest kernel, pvmfw boots it using the Linux ABI described
-above. It uses the device tree to pass the following:
+above. It uses the device tree to pass [AVF-specific properties][dt.md] and the
+DICE chain:
 
-- a reserved memory node containing the produced DICE chain:
-
-    ```
-    / {
-        reserved-memory {
-            #address-cells = <0x02>;
-            #size-cells = <0x02>;
-            ranges;
-            dice {
-                compatible = "google,open-dice";
-                no-map;
-                reg = <0x0 0x7fe0000>, <0x0 0x1000>;
-            };
+```
+/ {
+    reserved-memory {
+        #address-cells = <0x02>;
+        #size-cells = <0x02>;
+        ranges;
+        dice {
+            compatible = "google,open-dice";
+            no-map;
+            reg = <0x0 0x7fe0000>, <0x0 0x1000>;
         };
     };
-    ```
+};
+```
 
-- the `/chosen/avf,new-instance` flag, set when pvmfw generated a new secret
-  (_i.e._ the pVM instance was booted for the first time). This should be used
-  by the next stages to ensure that an attacker isn't trying to force new
-  secrets to be generated by one stage, in isolation;
-
-- the `/chosen/avf,strict-boot` flag, always set and can be used by guests to
-  enable extra validation
+[dt.md]: ../docs/device_trees.md#avf_specific-properties-and-nodes
 
 ### Guest Image Signing
 
diff --git a/guest/pvmfw/image.ld b/guest/pvmfw/image.ld
index 18bb3ba..fb26806 100644
--- a/guest/pvmfw/image.ld
+++ b/guest/pvmfw/image.ld
@@ -18,5 +18,4 @@
 {
 	image		: ORIGIN = 0x7fc00000, LENGTH = 2M
 	writable_data	: ORIGIN = 0x7fe00000, LENGTH = 2M
-	dtb_region	: ORIGIN = 0x80000000, LENGTH = 2M
 }
diff --git a/guest/pvmfw/src/dice.rs b/guest/pvmfw/src/dice.rs
index 8be73a4..470711f 100644
--- a/guest/pvmfw/src/dice.rs
+++ b/guest/pvmfw/src/dice.rs
@@ -36,8 +36,10 @@
 #[derive(Debug)]
 pub enum Error {
     /// Error in CBOR operations
+    #[allow(dead_code)]
     CborError(ciborium::value::Error),
     /// Error in DICE operations
+    #[allow(dead_code)]
     DiceError(diced_open_dice::DiceError),
 }
 
diff --git a/guest/pvmfw/src/entry.rs b/guest/pvmfw/src/entry.rs
index ce04317..8f9340b 100644
--- a/guest/pvmfw/src/entry.rs
+++ b/guest/pvmfw/src/entry.rs
@@ -276,8 +276,9 @@
     MEMORY.lock().as_mut().unwrap().unshare_all_memory();
 
     if let Some(mmio_guard) = get_mmio_guard() {
-        // Keep UART MMIO_GUARD-ed for debuggable payloads, to enable earlycon.
-        if !debuggable_payload {
+        if cfg!(debuggable_vms_improvements) && debuggable_payload {
+            // Keep UART MMIO_GUARD-ed for debuggable payloads, to enable earlycon.
+        } else {
             mmio_guard.unmap(UART_PAGE_ADDR).map_err(|e| {
                 error!("Failed to unshare the UART: {e}");
                 RebootReason::InternalError
diff --git a/guest/rialto/idmap.S b/guest/rialto/idmap.S
index 7281d9b..eb4d823 100644
--- a/guest/rialto/idmap.S
+++ b/guest/rialto/idmap.S
@@ -28,9 +28,8 @@
 .set .PAGE_SIZE, .SZ_4K
 
 .set .ORIGIN_ADDR, 2 * .SZ_1G
-.set .DTB_ADDR, .ORIGIN_ADDR + (0 * .SZ_2M)
-.set .TEXT_ADDR, .ORIGIN_ADDR + (1 * .SZ_2M)
-.set .DATA_ADDR, .ORIGIN_ADDR + (2 * .SZ_2M)
+.set .TEXT_ADDR, .ORIGIN_ADDR + (0 * .SZ_2M)
+.set .DATA_ADDR, .ORIGIN_ADDR + (1 * .SZ_2M)
 
 .set .L_TT_TYPE_BLOCK, 0x1
 .set .L_TT_TYPE_PAGE,  0x3
@@ -60,7 +59,7 @@
 	.balign .PAGE_SIZE, 0				// unmapped
 
 	/* level 2 */
-0:	.quad		.L_BLOCK_RO  | .DTB_ADDR	// DT provided by VMM
+0:
 	.quad		.L_BLOCK_MEM_XIP | .TEXT_ADDR	// 2 MiB of DRAM containing image
 	.quad		.L_BLOCK_MEM | .DATA_ADDR	// 2 MiB of writable DRAM
 	.balign .PAGE_SIZE, 0				// unmapped
diff --git a/guest/rialto/image.ld b/guest/rialto/image.ld
index 368acbb..3bf910c 100644
--- a/guest/rialto/image.ld
+++ b/guest/rialto/image.ld
@@ -16,7 +16,6 @@
 
 MEMORY
 {
-	dtb_region	: ORIGIN = 0x80000000, LENGTH = 2M
-	image		: ORIGIN = 0x80200000, LENGTH = 2M
-	writable_data	: ORIGIN = 0x80400000, LENGTH = 2M
+	image		: ORIGIN = 0x80000000, LENGTH = 2M
+	writable_data	: ORIGIN = 0x80200000, LENGTH = 2M
 }
diff --git a/guest/rialto/src/main.rs b/guest/rialto/src/main.rs
index 930f4e8..a98ec25 100644
--- a/guest/rialto/src/main.rs
+++ b/guest/rialto/src/main.rs
@@ -47,6 +47,7 @@
 use vmbase::{
     configure_heap,
     fdt::SwiotlbInfo,
+    generate_image_header,
     hyp::{get_mem_sharer, get_mmio_guard},
     layout::{self, crosvm, UART_PAGE_ADDR},
     main,
@@ -232,5 +233,6 @@
     }
 }
 
+generate_image_header!();
 main!(main);
 configure_heap!(SIZE_128KB * 2);
diff --git a/guest/rialto/tests/test.rs b/guest/rialto/tests/test.rs
index cf5630f..582b69e 100644
--- a/guest/rialto/tests/test.rs
+++ b/guest/rialto/tests/test.rs
@@ -34,7 +34,7 @@
 use service_vm_fake_chain::client_vm::{
     fake_client_vm_dice_artifacts, fake_sub_components, SubComponent,
 };
-use service_vm_manager::ServiceVm;
+use service_vm_manager::{ServiceVm, VM_MEMORY_MB};
 use std::fs;
 use std::fs::File;
 use std::panic;
@@ -59,7 +59,7 @@
         // The test is skipped if the feature flag |dice_changes| is not enabled, because when
         // the flag is off, the DICE chain is truncated in the pvmfw, and the service VM cannot
         // verify the chain due to the missing entries in the chain.
-        check_processing_requests(VmType::ProtectedVm)
+        check_processing_requests(VmType::ProtectedVm, None)
     } else {
         warn!("pVMs are not supported on device, skipping test");
         Ok(())
@@ -68,11 +68,13 @@
 
 #[test]
 fn process_requests_in_non_protected_vm() -> Result<()> {
-    check_processing_requests(VmType::NonProtectedVm)
+    const MEMORY_MB: i32 = 300;
+    check_processing_requests(VmType::NonProtectedVm, Some(MEMORY_MB))?;
+    check_processing_requests(VmType::NonProtectedVm, None)
 }
 
-fn check_processing_requests(vm_type: VmType) -> Result<()> {
-    let mut vm = start_service_vm(vm_type)?;
+fn check_processing_requests(vm_type: VmType, vm_memory_mb: Option<i32>) -> Result<()> {
+    let mut vm = start_service_vm(vm_type, vm_memory_mb)?;
 
     check_processing_reverse_request(&mut vm)?;
     let key_pair = check_processing_generating_key_pair_request(&mut vm)?;
@@ -281,11 +283,13 @@
 }
 
 fn check_csr(csr: Vec<u8>) -> Result<()> {
-    let _csr = rkp::Csr::from_cbor(&Session::default(), &csr[..]).context("Failed to parse CSR")?;
+    let mut session = Session::default();
+    session.set_allow_any_mode(true);
+    let _csr = rkp::Csr::from_cbor(&session, &csr[..]).context("Failed to parse CSR")?;
     Ok(())
 }
 
-fn start_service_vm(vm_type: VmType) -> Result<ServiceVm> {
+fn start_service_vm(vm_type: VmType, vm_memory_mb: Option<i32>) -> Result<ServiceVm> {
     android_logger::init_once(
         android_logger::Config::default()
             .with_tag("rialto")
@@ -297,19 +301,20 @@
     }));
     // We need to start the thread pool for Binder to work properly, especially link_to_death.
     ProcessState::start_thread_pool();
-    ServiceVm::start_vm(vm_instance(vm_type)?, vm_type)
+    ServiceVm::start_vm(vm_instance(vm_type, vm_memory_mb)?, vm_type)
 }
 
-fn vm_instance(vm_type: VmType) -> Result<VmInstance> {
+fn vm_instance(vm_type: VmType, vm_memory_mb: Option<i32>) -> Result<VmInstance> {
     match vm_type {
         VmType::ProtectedVm => {
+            assert!(vm_memory_mb.is_none());
             service_vm_manager::protected_vm_instance(PathBuf::from(INSTANCE_IMG_PATH))
         }
-        VmType::NonProtectedVm => nonprotected_vm_instance(),
+        VmType::NonProtectedVm => nonprotected_vm_instance(vm_memory_mb.unwrap_or(VM_MEMORY_MB)),
     }
 }
 
-fn nonprotected_vm_instance() -> Result<VmInstance> {
+fn nonprotected_vm_instance(memory_mib: i32) -> Result<VmInstance> {
     let rialto = File::open(UNSIGNED_RIALTO_PATH).context("Failed to open Rialto kernel binary")?;
     // Do not use `#allocateInstanceId` to generate the instance ID because the method
     // also adds an instance ID to the database it manages.
@@ -317,10 +322,10 @@
     let mut instance_id = [0u8; 64];
     rand_bytes(&mut instance_id).unwrap();
     let config = VirtualMachineConfig::RawConfig(VirtualMachineRawConfig {
-        name: String::from("Non protected rialto"),
-        bootloader: Some(ParcelFileDescriptor::new(rialto)),
+        name: format!("Non protected rialto ({memory_mib}MiB)"),
+        kernel: Some(ParcelFileDescriptor::new(rialto)),
         protectedVm: false,
-        memoryMib: 300,
+        memoryMib: memory_mib,
         platformVersion: "~1.0".to_string(),
         instanceId: instance_id,
         ..Default::default()
diff --git a/guest/vmbase_example/Android.bp b/guest/vmbase_example/Android.bp
new file mode 100644
index 0000000..ff7bd83
--- /dev/null
+++ b/guest/vmbase_example/Android.bp
@@ -0,0 +1,114 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+rust_ffi_static {
+    name: "libvmbase_example",
+    defaults: ["vmbase_ffi_defaults"],
+    crate_name: "vmbase_example",
+    srcs: ["src/main.rs"],
+    rustlibs: [
+        "libaarch64_paging",
+        "libcstr",
+        "libdiced_open_dice_nostd",
+        "libfdtpci",
+        "liblibfdt",
+        "liblog_rust_nostd",
+        "libvirtio_drivers",
+        "libvmbase",
+    ],
+}
+
+genrule {
+    name: "vmbase_image.ld.S.mm",
+    // Soong won't let us use cc_object to preprocess *.ld.S files because it
+    // can't resist feeding any and all *.S files to the assembler, which fails
+    // because linker scripts typically aren't valid assembly. Also, cc_object
+    // rejects inputs that don't end in one of .{s,S,c,cpp,cc,cxx,mm}. So keep
+    // the proper extension (.ld.S) for the file in VCS and use this convoluted
+    // extra step to please Soong by pretending that our linker script is in
+    // fact some Object C++ code, which fortunately it doesn't try to compile.
+    srcs: ["image.ld.S"],
+    out: ["image.ld.S.mm"],
+    cmd: "cp $(in) $(out)",
+    visibility: ["//visibility:private"],
+}
+
+cc_defaults {
+    name: "vmbase_example_ld_defaults",
+    defaults: ["vmbase_cc_defaults"],
+    cflags: [
+        "-E",
+        "-P",
+        "-xassembler-with-cpp", // allow C preprocessor directives
+    ],
+    srcs: [":vmbase_image.ld.S.mm"],
+    visibility: ["//visibility:private"],
+}
+
+cc_object {
+    name: "vmbase_example_bios.ld",
+    defaults: ["vmbase_example_ld_defaults"],
+    cflags: ["-DVMBASE_EXAMPLE_IS_BIOS"],
+}
+
+cc_object {
+    name: "vmbase_example_kernel.ld",
+    defaults: ["vmbase_example_ld_defaults"],
+    cflags: ["-DVMBASE_EXAMPLE_IS_KERNEL"],
+}
+
+cc_defaults {
+    name: "vmbase_example_elf_defaults",
+    defaults: ["vmbase_elf_defaults"],
+    srcs: [
+        "idmap.S",
+    ],
+    static_libs: [
+        "libvmbase_example",
+    ],
+}
+
+cc_binary {
+    name: "vmbase_example_bios",
+    defaults: ["vmbase_example_elf_defaults"],
+    asflags: ["-DVMBASE_EXAMPLE_IS_BIOS"],
+    linker_scripts: [
+        ":vmbase_example_bios.ld",
+        ":vmbase_sections",
+    ],
+}
+
+cc_binary {
+    name: "vmbase_example_kernel",
+    defaults: ["vmbase_example_elf_defaults"],
+    asflags: ["-DVMBASE_EXAMPLE_IS_KERNEL"],
+    linker_scripts: [
+        ":vmbase_example_kernel.ld",
+        ":vmbase_sections",
+    ],
+}
+
+raw_binary {
+    name: "vmbase_example_bios_bin",
+    stem: "vmbase_example_bios.bin",
+    src: ":vmbase_example_bios",
+    enabled: false,
+    target: {
+        android_arm64: {
+            enabled: true,
+        },
+    },
+}
+
+raw_binary {
+    name: "vmbase_example_kernel_bin",
+    stem: "vmbase_example_kernel.bin",
+    src: ":vmbase_example_kernel",
+    enabled: false,
+    target: {
+        android_arm64: {
+            enabled: true,
+        },
+    },
+}
diff --git a/libs/libvmbase/example/idmap.S b/guest/vmbase_example/idmap.S
similarity index 82%
rename from libs/libvmbase/example/idmap.S
rename to guest/vmbase_example/idmap.S
index 71a6ade..881850c 100644
--- a/libs/libvmbase/example/idmap.S
+++ b/guest/vmbase_example/idmap.S
@@ -43,8 +43,16 @@
 	.quad		.L_TT_TYPE_TABLE + 0f		// up to 1 GiB of DRAM
 	.fill		509, 8, 0x0			// 509 GiB of remaining VA space
 
-	/* level 2 */
-0:	.quad		.L_BLOCK_MEM | 0x80000000	// DT provided by VMM
+0:	/* level 2 */
+#if defined(VMBASE_EXAMPLE_IS_BIOS)
+	.quad		0				// 2 MiB not mapped (DT)
 	.quad		.L_BLOCK_MEM_XIP | 0x80200000	// 2 MiB of DRAM containing image
 	.quad		.L_BLOCK_MEM | 0x80400000	// 2 MiB of writable DRAM
 	.fill		509, 8, 0x0
+#elif defined(VMBASE_EXAMPLE_IS_KERNEL)
+	.quad		.L_BLOCK_MEM_XIP | 0x80000000	// 2 MiB of DRAM containing image
+	.quad		.L_BLOCK_MEM | 0x80200000	// 2 MiB of writable DRAM
+	.fill		510, 8, 0x0
+#else
+#error "Unexpected vmbase_example mode: failed to generate idmap"
+#endif
diff --git a/libs/libvmbase/example/image.ld b/guest/vmbase_example/image.ld.S
similarity index 73%
rename from libs/libvmbase/example/image.ld
rename to guest/vmbase_example/image.ld.S
index 368acbb..a5cd965 100644
--- a/libs/libvmbase/example/image.ld
+++ b/guest/vmbase_example/image.ld.S
@@ -16,7 +16,13 @@
 
 MEMORY
 {
-	dtb_region	: ORIGIN = 0x80000000, LENGTH = 2M
+#if defined(VMBASE_EXAMPLE_IS_BIOS)
 	image		: ORIGIN = 0x80200000, LENGTH = 2M
 	writable_data	: ORIGIN = 0x80400000, LENGTH = 2M
+#elif defined(VMBASE_EXAMPLE_IS_KERNEL)
+	image		: ORIGIN = 0x80000000, LENGTH = 2M
+	writable_data	: ORIGIN = 0x80200000, LENGTH = 2M
+#else
+#error "Unexpected vmbase_example mode: failed to generate image layout"
+#endif
 }
diff --git a/libs/libvmbase/example/src/exceptions.rs b/guest/vmbase_example/src/exceptions.rs
similarity index 100%
rename from libs/libvmbase/example/src/exceptions.rs
rename to guest/vmbase_example/src/exceptions.rs
diff --git a/libs/libvmbase/example/src/layout.rs b/guest/vmbase_example/src/layout.rs
similarity index 90%
rename from libs/libvmbase/example/src/layout.rs
rename to guest/vmbase_example/src/layout.rs
index fc578bc..50ecb7e 100644
--- a/libs/libvmbase/example/src/layout.rs
+++ b/guest/vmbase_example/src/layout.rs
@@ -17,20 +17,17 @@
 use aarch64_paging::paging::{MemoryRegion, VirtualAddress};
 use core::ops::Range;
 use log::info;
-use vmbase::layout;
+use vmbase::{layout, memory::PAGE_SIZE};
 
 /// The first 1 GiB of memory are used for MMIO.
 pub const DEVICE_REGION: MemoryRegion = MemoryRegion::new(0, 0x40000000);
 
 /// Writable data region for the stack.
 pub fn boot_stack_range() -> Range<VirtualAddress> {
-    const PAGE_SIZE: usize = 4 << 10;
     layout::stack_range(40 * PAGE_SIZE)
 }
 
 pub fn print_addresses() {
-    let dtb = layout::dtb_range();
-    info!("dtb:        {}..{} ({} bytes)", dtb.start, dtb.end, dtb.end - dtb.start);
     let text = layout::text_range();
     info!("text:       {}..{} ({} bytes)", text.start, text.end, text.end - text.start);
     let rodata = layout::rodata_range();
diff --git a/libs/libvmbase/example/src/main.rs b/guest/vmbase_example/src/main.rs
similarity index 87%
rename from libs/libvmbase/example/src/main.rs
rename to guest/vmbase_example/src/main.rs
index da82b17..7a3f427 100644
--- a/libs/libvmbase/example/src/main.rs
+++ b/guest/vmbase_example/src/main.rs
@@ -25,38 +25,37 @@
 
 use crate::layout::{boot_stack_range, print_addresses, DEVICE_REGION};
 use crate::pci::{check_pci, get_bar_region};
-use aarch64_paging::paging::MemoryRegion;
+use aarch64_paging::paging::VirtualAddress;
 use aarch64_paging::MapError;
 use alloc::{vec, vec::Vec};
+use core::mem;
 use core::ptr::addr_of_mut;
 use cstr::cstr;
 use fdtpci::PciInfo;
 use libfdt::Fdt;
 use log::{debug, error, info, trace, warn, LevelFilter};
 use vmbase::{
-    bionic, configure_heap,
-    layout::{dtb_range, rodata_range, scratch_range, text_range},
+    bionic, configure_heap, generate_image_header,
+    layout::{crosvm::FDT_MAX_SIZE, rodata_range, scratch_range, text_range},
     linker, logger, main,
     memory::{PageTable, SIZE_64KB},
+    util::RangeExt as _,
 };
 
 static INITIALISED_DATA: [u32; 4] = [1, 2, 3, 4];
 static mut ZEROED_DATA: [u32; 10] = [0; 10];
 static mut MUTABLE_DATA: [u32; 4] = [1, 2, 3, 4];
 
+generate_image_header!();
 main!(main);
 configure_heap!(SIZE_64KB);
 
-fn init_page_table(pci_bar_range: &MemoryRegion) -> Result<(), MapError> {
-    let mut page_table = PageTable::default();
-
+fn init_page_table(page_table: &mut PageTable) -> Result<(), MapError> {
     page_table.map_device(&DEVICE_REGION)?;
     page_table.map_code(&text_range().into())?;
     page_table.map_rodata(&rodata_range().into())?;
     page_table.map_data(&scratch_range().into())?;
     page_table.map_data(&boot_stack_range().into())?;
-    page_table.map_rodata(&dtb_range().into())?;
-    page_table.map_device(pci_bar_range)?;
 
     info!("Activating IdMap...");
     // SAFETY: page_table duplicates the static mappings for everything that the Rust code is
@@ -76,15 +75,18 @@
     info!("Hello world");
     info!("x0={:#018x}, x1={:#018x}, x2={:#018x}, x3={:#018x}", arg0, arg1, arg2, arg3);
     print_addresses();
-    assert_eq!(arg0, dtb_range().start.0 as u64);
     check_data();
     check_stack_guard();
 
+    let mut page_table = PageTable::default();
+    init_page_table(&mut page_table).unwrap();
+
     info!("Checking FDT...");
-    let fdt = dtb_range();
-    let fdt_size = fdt.end.0 - fdt.start.0;
+    let fdt_addr = usize::try_from(arg0).unwrap();
     // SAFETY: The DTB range is valid, writable memory, and we don't construct any aliases to it.
-    let fdt = unsafe { core::slice::from_raw_parts_mut(fdt.start.0 as *mut u8, fdt_size) };
+    let fdt = unsafe { core::slice::from_raw_parts_mut(fdt_addr as *mut u8, FDT_MAX_SIZE) };
+    let fdt_region = (VirtualAddress(fdt_addr)..VirtualAddress(fdt_addr + fdt.len())).into();
+    page_table.map_data(&fdt_region).unwrap();
     let fdt = Fdt::from_mut_slice(fdt).unwrap();
     info!("FDT passed verification.");
     check_fdt(fdt);
@@ -96,7 +98,13 @@
 
     check_alloc();
 
-    init_page_table(&get_bar_region(&pci_info)).unwrap();
+    let bar_region = get_bar_region(&pci_info);
+    if bar_region.is_within(&DEVICE_REGION) {
+        // Avoid a MapError::BreakBeforeMakeViolation.
+        info!("BAR region is within already mapped device region: skipping page table ops.");
+    } else {
+        page_table.map_device(&bar_region).unwrap();
+    }
 
     check_data();
     check_dice();
@@ -106,6 +114,10 @@
     check_pci(&mut pci_root);
 
     emit_suppressed_log();
+
+    info!("De-activating IdMap...");
+    mem::drop(page_table); // Release PageTable and switch back to idmap.S
+    info!("De-activated.");
 }
 
 fn check_stack_guard() {
diff --git a/libs/libvmbase/example/src/pci.rs b/guest/vmbase_example/src/pci.rs
similarity index 100%
rename from libs/libvmbase/example/src/pci.rs
rename to guest/vmbase_example/src/pci.rs
diff --git a/libs/apkverify/src/sigutil.rs b/libs/apkverify/src/sigutil.rs
index 7d03bb2..a47b4c5 100644
--- a/libs/apkverify/src/sigutil.rs
+++ b/libs/apkverify/src/sigutil.rs
@@ -79,6 +79,7 @@
     /// 2. The top-level digest is computed over the concatenation of byte 0x5a, the number of
     ///    chunks (little-endian uint32), and the concatenation of digests of the chunks in the
     ///    order the chunks appear in the APK.
+    ///
     /// (see https://source.android.com/security/apksigning/v2#integrity-protected-contents)
     pub(crate) fn compute_digest(
         &mut self,
diff --git a/libs/dice/open_dice/Android.bp b/libs/dice/open_dice/Android.bp
index 62504d1..efe350f 100644
--- a/libs/dice/open_dice/Android.bp
+++ b/libs/dice/open_dice/Android.bp
@@ -92,6 +92,9 @@
         "--ctypes-prefix=core::ffi",
         "--raw-line=#![no_std]",
     ],
+    dylib: {
+        enabled: false,
+    },
     no_stdlibs: true,
     prefer_rlib: true,
     stdlibs: [
diff --git a/libs/framework-virtualization/Android.bp b/libs/framework-virtualization/Android.bp
index d3a2b54..d02eec6 100644
--- a/libs/framework-virtualization/Android.bp
+++ b/libs/framework-virtualization/Android.bp
@@ -9,7 +9,10 @@
 
     jarjar_rules: "jarjar-rules.txt",
 
-    srcs: ["src/**/*.java"],
+    srcs: [
+        "src/**/*.java",
+        ":avf-build-flags-java-gen",
+    ],
     static_libs: [
         "android.system.virtualizationservice-java",
         "avf_aconfig_flags_java",
@@ -53,3 +56,15 @@
         ],
     },
 }
+
+gensrcs {
+    name: "avf-build-flags-java-gen",
+    srcs: ["src/**/BuildFlags.java_template"],
+    output_extension: "java",
+    cmd: "cp $(in) $(genDir)/tmp.java && " +
+        select(release_flag("RELEASE_AVF_ENABLE_VENDOR_MODULES"), {
+            true: "sed -ie 's/@vendor_modules_enabled_placeholder/true/g'",
+            default: "sed -ie 's/@vendor_modules_enabled_placeholder/false/g'",
+        }) + " $(genDir)/tmp.java && " +
+        " cp $(genDir)/tmp.java $(out)",
+}
diff --git a/libs/framework-virtualization/src/android/system/virtualmachine/BuildFlags.java_template b/libs/framework-virtualization/src/android/system/virtualmachine/BuildFlags.java_template
new file mode 100644
index 0000000..12b249c
--- /dev/null
+++ b/libs/framework-virtualization/src/android/system/virtualmachine/BuildFlags.java_template
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2024 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;
+
+/**
+ * Exposes AVF build flags (RELEASE_AVF_*) to java.
+ *
+ * @hide
+ */
+public final class BuildFlags {
+
+    /**
+     * Value of the {@code RELEASE_AVF_ENABLE_VENDOR_MODULES} build flag.
+     */
+    public static boolean VENDOR_MODULES_ENABLED = @vendor_modules_enabled_placeholder;
+
+    private BuildFlags() {};
+}
+
diff --git a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineConfig.java b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineConfig.java
index 7ae4a55..cb21ccf 100644
--- a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineConfig.java
+++ b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineConfig.java
@@ -40,6 +40,7 @@
 import android.sysprop.HypervisorProperties;
 import android.system.virtualizationservice.DiskImage;
 import android.system.virtualizationservice.Partition;
+import android.system.virtualizationservice.UsbConfig;
 import android.system.virtualizationservice.VirtualMachineAppConfig;
 import android.system.virtualizationservice.VirtualMachinePayloadConfig;
 import android.system.virtualizationservice.VirtualMachineRawConfig;
@@ -724,6 +725,16 @@
                 Optional.ofNullable(customImageConfig.getAudioConfig())
                         .map(ac -> ac.toParcelable())
                         .orElse(null);
+        config.noBalloon = !customImageConfig.useAutoMemoryBalloon();
+        config.usbConfig =
+                Optional.ofNullable(customImageConfig.getUsbConfig())
+                        .map(
+                                uc -> {
+                                    UsbConfig usbConfig = new UsbConfig();
+                                    usbConfig.controller = uc.getUsbController();
+                                    return usbConfig;
+                                })
+                        .orElse(null);
         return config;
     }
 
@@ -777,6 +788,7 @@
             VirtualMachineAppConfig.CustomConfig customConfig =
                     new VirtualMachineAppConfig.CustomConfig();
             customConfig.devices = EMPTY_STRING_ARRAY;
+            customConfig.extraKernelCmdlineParams = EMPTY_STRING_ARRAY;
             try {
                 customConfig.vendorImage =
                         ParcelFileDescriptor.open(mVendorDiskImage, MODE_READ_ONLY);
diff --git a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineCustomImageConfig.java b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineCustomImageConfig.java
index 37dc8fa..9774585 100644
--- a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineCustomImageConfig.java
+++ b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineCustomImageConfig.java
@@ -46,6 +46,7 @@
     private static final String KEY_AUDIO_CONFIG = "audio_config";
     private static final String KEY_TRACKPAD = "trackpad";
     private static final String KEY_AUTO_MEMORY_BALLOON = "auto_memory_balloon";
+    private static final String KEY_USB_CONFIG = "usb_config";
 
     @Nullable private final String name;
     @Nullable private final String kernelPath;
@@ -63,6 +64,7 @@
     @Nullable private final GpuConfig gpuConfig;
     private final boolean trackpad;
     private final boolean autoMemoryBalloon;
+    @Nullable private final UsbConfig usbConfig;
 
     @Nullable
     public Disk[] getDisks() {
@@ -139,7 +141,8 @@
             GpuConfig gpuConfig,
             AudioConfig audioConfig,
             boolean trackpad,
-            boolean autoMemoryBalloon) {
+            boolean autoMemoryBalloon,
+            UsbConfig usbConfig) {
         this.name = name;
         this.kernelPath = kernelPath;
         this.initrdPath = initrdPath;
@@ -156,6 +159,7 @@
         this.audioConfig = audioConfig;
         this.trackpad = trackpad;
         this.autoMemoryBalloon = autoMemoryBalloon;
+        this.usbConfig = usbConfig;
     }
 
     static VirtualMachineCustomImageConfig from(PersistableBundle customImageConfigBundle) {
@@ -208,6 +212,9 @@
         builder.setAudioConfig(AudioConfig.from(audioConfigPb));
         builder.useTrackpad(customImageConfigBundle.getBoolean(KEY_TRACKPAD));
         builder.useAutoMemoryBalloon(customImageConfigBundle.getBoolean(KEY_AUTO_MEMORY_BALLOON));
+        PersistableBundle usbConfigPb =
+                customImageConfigBundle.getPersistableBundle(KEY_USB_CONFIG);
+        builder.setUsbConfig(UsbConfig.from(usbConfigPb));
         return builder.build();
     }
 
@@ -266,6 +273,9 @@
                 Optional.ofNullable(audioConfig).map(ac -> ac.toPersistableBundle()).orElse(null));
         pb.putBoolean(KEY_TRACKPAD, trackpad);
         pb.putBoolean(KEY_AUTO_MEMORY_BALLOON, autoMemoryBalloon);
+        pb.putPersistableBundle(
+                KEY_USB_CONFIG,
+                Optional.ofNullable(usbConfig).map(uc -> uc.toPersistableBundle()).orElse(null));
         return pb;
     }
 
@@ -284,6 +294,11 @@
         return gpuConfig;
     }
 
+    @Nullable
+    public UsbConfig getUsbConfig() {
+        return usbConfig;
+    }
+
     /** @hide */
     public static final class Disk {
         private final boolean writable;
@@ -360,7 +375,9 @@
         private boolean network;
         private GpuConfig gpuConfig;
         private boolean trackpad;
-        private boolean autoMemoryBalloon = true;
+        // TODO(b/363985291): balloon breaks Linux VM behavior
+        private boolean autoMemoryBalloon = false;
+        private UsbConfig usbConfig;
 
         /** @hide */
         public Builder() {}
@@ -462,6 +479,12 @@
         }
 
         /** @hide */
+        public Builder setUsbConfig(UsbConfig usbConfig) {
+            this.usbConfig = usbConfig;
+            return this;
+        }
+
+        /** @hide */
         public VirtualMachineCustomImageConfig build() {
             return new VirtualMachineCustomImageConfig(
                     this.name,
@@ -479,7 +502,63 @@
                     gpuConfig,
                     audioConfig,
                     trackpad,
-                    autoMemoryBalloon);
+                    autoMemoryBalloon,
+                    usbConfig);
+        }
+    }
+
+    /** @hide */
+    public static final class UsbConfig {
+        private static final String KEY_USE_CONTROLLER = "use_controller";
+        public final boolean controller;
+
+        public UsbConfig(boolean controller) {
+            this.controller = controller;
+        }
+
+        public boolean getUsbController() {
+            return this.controller;
+        }
+
+        android.system.virtualizationservice.UsbConfig toParceclable() {
+            android.system.virtualizationservice.UsbConfig parcelable =
+                    new android.system.virtualizationservice.UsbConfig();
+            parcelable.controller = this.controller;
+            return parcelable;
+        }
+
+        private static UsbConfig from(PersistableBundle pb) {
+            if (pb == null) {
+                return null;
+            }
+            Builder builder = new Builder();
+            builder.setController(pb.getBoolean(KEY_USE_CONTROLLER));
+            return builder.build();
+        }
+
+        private PersistableBundle toPersistableBundle() {
+            PersistableBundle pb = new PersistableBundle();
+            pb.putBoolean(KEY_USE_CONTROLLER, this.controller);
+            return pb;
+        }
+
+        /** @hide */
+        public static class Builder {
+            private boolean useController = false;
+
+            /** @hide */
+            public Builder() {}
+
+            /** @hide */
+            public Builder setController(boolean useController) {
+                this.useController = useController;
+                return this;
+            }
+
+            /** @hide */
+            public UsbConfig build() {
+                return new UsbConfig(useController);
+            }
         }
     }
 
diff --git a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineManager.java b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineManager.java
index 242dc91..9295c6c 100644
--- a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineManager.java
+++ b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachineManager.java
@@ -371,10 +371,6 @@
     private static final List<String> SUPPORTED_OS_LIST_FROM_CFG =
             extractSupportedOSListFromConfig();
 
-    private boolean isVendorModuleEnabled() {
-        return VirtualizationService.nativeIsVendorModulesFlagEnabled();
-    }
-
     private static List<String> extractSupportedOSListFromConfig() {
         List<String> supportedOsList = new ArrayList<>();
         File directory = new File("/apex/com.android.virt/etc");
@@ -400,7 +396,7 @@
     @FlaggedApi(Flags.FLAG_AVF_V_TEST_APIS)
     @NonNull
     public List<String> getSupportedOSList() throws VirtualMachineException {
-        if (isVendorModuleEnabled()) {
+        if (BuildFlags.VENDOR_MODULES_ENABLED) {
             return SUPPORTED_OS_LIST_FROM_CFG;
         } else {
             return Arrays.asList("microdroid");
diff --git a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualizationService.java b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualizationService.java
index 83b64ee..57990a9 100644
--- a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualizationService.java
+++ b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualizationService.java
@@ -51,12 +51,6 @@
     private native boolean nativeIsOk(int clientFd);
 
     /*
-     * Retrieve boolean value whether RELEASE_AVF_ENABLE_VENDOR_MODULES build flag is enabled or
-     * not.
-     */
-    static native boolean nativeIsVendorModulesFlagEnabled();
-
-    /*
      * Spawns a new virtmgr subprocess that will host a VirtualizationService
      * AIDL service.
      */
diff --git a/libs/libcompos_common/Android.bp b/libs/libcompos_common/Android.bp
index 72cb5e1..01836ae 100644
--- a/libs/libcompos_common/Android.bp
+++ b/libs/libcompos_common/Android.bp
@@ -14,7 +14,6 @@
         "libanyhow",
         "libbinder_rs",
         "libglob",
-        "liblazy_static",
         "liblog_rust",
         "libnested_virt",
         "libnum_traits",
diff --git a/libs/libcompos_common/timeouts.rs b/libs/libcompos_common/timeouts.rs
index 7bd7679..d22f7f7 100644
--- a/libs/libcompos_common/timeouts.rs
+++ b/libs/libcompos_common/timeouts.rs
@@ -17,7 +17,7 @@
 //! Timeouts for common situations, with support for longer timeouts when using nested
 //! virtualization.
 
-use lazy_static::lazy_static;
+use std::sync::LazyLock;
 use std::time::Duration;
 
 /// Holder for the various timeouts we use.
@@ -31,15 +31,15 @@
     pub vm_max_time_to_exit: Duration,
 }
 
-lazy_static! {
 /// The timeouts that are appropriate on the current platform.
-pub static ref TIMEOUTS: Timeouts = if nested_virt::is_nested_virtualization().unwrap() {
-    // Nested virtualization is slow.
-    EXTENDED_TIMEOUTS
-} else {
-    NORMAL_TIMEOUTS
-};
-}
+pub static TIMEOUTS: LazyLock<Timeouts> = LazyLock::new(|| {
+    if nested_virt::is_nested_virtualization().unwrap() {
+        // Nested virtualization is slow.
+        EXTENDED_TIMEOUTS
+    } else {
+        NORMAL_TIMEOUTS
+    }
+});
 
 /// The timeouts that we use normally.
 const NORMAL_TIMEOUTS: Timeouts = Timeouts {
diff --git a/libs/libfdt/Android.bp b/libs/libfdt/Android.bp
index 7dc9e64..b2e7b2b 100644
--- a/libs/libfdt/Android.bp
+++ b/libs/libfdt/Android.bp
@@ -16,6 +16,9 @@
         "--raw-line=#![no_std]",
         "--ctypes-prefix=core::ffi",
     ],
+    dylib: {
+        enabled: false,
+    },
     static_libs: [
         "libfdt",
     ],
diff --git a/libs/libsafe_ownedfd/Android.bp b/libs/libsafe_ownedfd/Android.bp
new file mode 100644
index 0000000..53e14dc
--- /dev/null
+++ b/libs/libsafe_ownedfd/Android.bp
@@ -0,0 +1,38 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+rust_defaults {
+    name: "libsafe_ownedfd.defaults",
+    crate_name: "safe_ownedfd",
+    srcs: ["src/lib.rs"],
+    edition: "2021",
+    rustlibs: [
+        "libnix",
+        "libthiserror",
+    ],
+}
+
+rust_library {
+    name: "libsafe_ownedfd",
+    defaults: ["libsafe_ownedfd.defaults"],
+    apex_available: [
+        "com.android.compos",
+        "com.android.microfuchsia",
+        "com.android.virt",
+    ],
+}
+
+rust_test {
+    name: "libsafe_ownedfd.test",
+    defaults: ["libsafe_ownedfd.defaults"],
+    rustlibs: [
+        "libanyhow",
+        "libtempfile",
+    ],
+    host_supported: true,
+    test_suites: ["general-tests"],
+    test_options: {
+        unit_test: true,
+    },
+}
diff --git a/libs/libsafe_ownedfd/src/lib.rs b/libs/libsafe_ownedfd/src/lib.rs
new file mode 100644
index 0000000..52ae180
--- /dev/null
+++ b/libs/libsafe_ownedfd/src/lib.rs
@@ -0,0 +1,127 @@
+// Copyright 2024, 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.
+
+//! Library for a safer conversion from `RawFd` to `OwnedFd`
+
+use nix::fcntl::{fcntl, FdFlag, F_DUPFD, F_GETFD, F_SETFD};
+use nix::libc;
+use nix::unistd::close;
+use std::os::fd::FromRawFd;
+use std::os::fd::OwnedFd;
+use std::os::fd::RawFd;
+use std::sync::Mutex;
+use thiserror::Error;
+
+/// Errors that can occur while taking an ownership of `RawFd`
+#[derive(Debug, PartialEq, Error)]
+pub enum Error {
+    /// RawFd is not a valid file descriptor
+    #[error("{0} is not a file descriptor")]
+    Invalid(RawFd),
+
+    /// RawFd is either stdio, stdout, or stderr
+    #[error("standard IO descriptors cannot be owned")]
+    StdioNotAllowed,
+
+    /// Generic UNIX error
+    #[error("UNIX error")]
+    Errno(#[from] nix::errno::Errno),
+}
+
+static LOCK: Mutex<()> = Mutex::new(());
+
+/// Takes the ownership of `RawFd` and converts it to `OwnedFd`. It is important to know that
+/// `RawFd` is closed when this function successfully returns. The raw file descriptor of the
+/// returned `OwnedFd` is different from `RawFd`. The returned file descriptor is CLOEXEC set.
+pub fn take_fd_ownership(raw_fd: RawFd) -> Result<OwnedFd, Error> {
+    fcntl(raw_fd, F_GETFD).map_err(|_| Error::Invalid(raw_fd))?;
+
+    if [libc::STDIN_FILENO, libc::STDOUT_FILENO, libc::STDERR_FILENO].contains(&raw_fd) {
+        return Err(Error::StdioNotAllowed);
+    }
+
+    // sync is needed otherwise we can create multiple OwnedFds out of the same RawFd
+    let lock = LOCK.lock().unwrap();
+    let new_fd = fcntl(raw_fd, F_DUPFD(raw_fd))?;
+    close(raw_fd)?;
+    drop(lock);
+
+    // This is not essential, but let's follow the common practice in the Rust ecosystem
+    fcntl(new_fd, F_SETFD(FdFlag::FD_CLOEXEC)).map_err(Error::Errno)?;
+
+    // SAFETY: In this function, we have checked that RawFd is actually an open file descriptor and
+    // this is the first time to claim its ownership because we just created it by duping.
+    Ok(unsafe { OwnedFd::from_raw_fd(new_fd) })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use anyhow::Result;
+    use nix::fcntl::{fcntl, FdFlag, F_GETFD, F_SETFD};
+    use std::os::fd::AsRawFd;
+    use std::os::fd::IntoRawFd;
+    use tempfile::tempfile;
+
+    #[test]
+    fn good_fd() -> Result<()> {
+        let raw_fd = tempfile()?.into_raw_fd();
+        assert!(take_fd_ownership(raw_fd).is_ok());
+        Ok(())
+    }
+
+    #[test]
+    fn invalid_fd() -> Result<()> {
+        let raw_fd = 12345; // randomly chosen
+        assert_eq!(take_fd_ownership(raw_fd).unwrap_err(), Error::Invalid(raw_fd));
+        Ok(())
+    }
+
+    #[test]
+    fn original_fd_closed() -> Result<()> {
+        let raw_fd = tempfile()?.into_raw_fd();
+        let owned_fd = take_fd_ownership(raw_fd)?;
+        assert_ne!(raw_fd, owned_fd.as_raw_fd());
+        assert!(fcntl(raw_fd, F_GETFD).is_err());
+        Ok(())
+    }
+
+    #[test]
+    fn cannot_use_same_rawfd_multiple_times() -> Result<()> {
+        let raw_fd = tempfile()?.into_raw_fd();
+
+        let owned_fd = take_fd_ownership(raw_fd); // once
+        let owned_fd2 = take_fd_ownership(raw_fd); // twice
+
+        assert!(owned_fd.is_ok());
+        assert!(owned_fd2.is_err());
+        Ok(())
+    }
+
+    #[test]
+    fn cloexec() -> Result<()> {
+        let raw_fd = tempfile()?.into_raw_fd();
+
+        // intentionally clear cloexec to see if it is set by take_fd_ownership
+        fcntl(raw_fd, F_SETFD(FdFlag::empty()))?;
+        let flags = fcntl(raw_fd, F_GETFD)?;
+        assert_eq!(flags, FdFlag::empty().bits());
+
+        let owned_fd = take_fd_ownership(raw_fd)?;
+        let flags = fcntl(owned_fd.as_raw_fd(), F_GETFD)?;
+        assert_eq!(flags, FdFlag::FD_CLOEXEC.bits());
+        drop(owned_fd);
+        Ok(())
+    }
+}
diff --git a/libs/libservice_vm_manager/Android.bp b/libs/libservice_vm_manager/Android.bp
index 6469212..b3618a6 100644
--- a/libs/libservice_vm_manager/Android.bp
+++ b/libs/libservice_vm_manager/Android.bp
@@ -12,7 +12,6 @@
         "android.system.virtualizationservice-rust",
         "libanyhow",
         "libciborium",
-        "liblazy_static",
         "liblog_rust",
         "libnix",
         "libservice_vm_comm",
diff --git a/libs/libservice_vm_manager/src/lib.rs b/libs/libservice_vm_manager/src/lib.rs
index 78ed85b..d7b4dd6 100644
--- a/libs/libservice_vm_manager/src/lib.rs
+++ b/libs/libservice_vm_manager/src/lib.rs
@@ -25,7 +25,6 @@
     binder::ParcelFileDescriptor,
 };
 use anyhow::{anyhow, ensure, Context, Result};
-use lazy_static::lazy_static;
 use log::{info, warn};
 use service_vm_comm::{Request, Response, ServiceVmRequest, VmType};
 use std::fs::{self, File, OpenOptions};
@@ -37,20 +36,21 @@
 use vmclient::{DeathReason, VmInstance};
 use vsock::{VsockListener, VsockStream, VMADDR_CID_HOST};
 
+/// Size of virtual memory allocated to the Service VM.
+pub const VM_MEMORY_MB: i32 = 6;
+
 const VIRT_DATA_DIR: &str = "/data/misc/apexdata/com.android.virt";
 const RIALTO_PATH: &str = "/apex/com.android.virt/etc/rialto.bin";
 const INSTANCE_IMG_NAME: &str = "service_vm_instance.img";
 const INSTANCE_ID_FILENAME: &str = "service_vm_instance_id";
 const INSTANCE_IMG_SIZE_BYTES: i64 = 1 << 20; // 1MB
-const MEMORY_MB: i32 = 300;
 const WRITE_BUFFER_CAPACITY: usize = 512;
 const READ_TIMEOUT: Duration = Duration::from_secs(10);
 const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
-lazy_static! {
-    static ref PENDING_REQUESTS: AtomicCounter = AtomicCounter::default();
-    static ref SERVICE_VM: Mutex<Option<ServiceVm>> = Mutex::new(None);
-    static ref SERVICE_VM_SHUTDOWN: Condvar = Condvar::new();
-}
+
+static PENDING_REQUESTS: AtomicCounter = AtomicCounter::new();
+static SERVICE_VM: Mutex<Option<ServiceVm>> = Mutex::new(None);
+static SERVICE_VM_SHUTDOWN: Condvar = Condvar::new();
 
 /// Atomic counter with a condition variable that is used to wait for the counter
 /// to become positive within a timeout.
@@ -61,6 +61,10 @@
 }
 
 impl AtomicCounter {
+    const fn new() -> Self {
+        Self { num: Mutex::new(0), num_increased: Condvar::new() }
+    }
+
     /// Checks if the counter becomes positive within the given timeout.
     fn is_positive_within_timeout(&self, timeout: Duration) -> bool {
         let (guard, _wait_result) = self
@@ -227,11 +231,11 @@
     let instance_id = get_or_allocate_instance_id(service.as_ref(), instance_id_file)?;
     let config = VirtualMachineConfig::RawConfig(VirtualMachineRawConfig {
         name: String::from("Service VM"),
-        bootloader: Some(ParcelFileDescriptor::new(rialto)),
+        kernel: Some(ParcelFileDescriptor::new(rialto)),
         disks: vec![DiskImage { image: None, partitions: writable_partitions, writable: true }],
         instanceId: instance_id,
         protectedVm: true,
-        memoryMib: MEMORY_MB,
+        memoryMib: VM_MEMORY_MB,
         cpuTopology: CpuTopology::ONE_CPU,
         platformVersion: "~1.0".to_string(),
         gdbPort: 0, // No gdb
diff --git a/libs/libservice_vm_requests/src/dice.rs b/libs/libservice_vm_requests/src/dice.rs
index 247c34e..ef9d894 100644
--- a/libs/libservice_vm_requests/src/dice.rs
+++ b/libs/libservice_vm_requests/src/dice.rs
@@ -76,7 +76,7 @@
     ///
     /// - The first entry of the `client_vm_dice_chain` must be signed with the root public key.
     /// - After the first entry, each entry of the `client_vm_dice_chain` must be signed with the
-    ///  subject public key of the previous entry.
+    ///   subject public key of the previous entry.
     ///
     /// Returns a partially decoded client VM's DICE chain if the verification succeeds.
     pub(crate) fn validate_signatures_and_parse_dice_chain(
diff --git a/libs/libservice_vm_requests/src/rkp.rs b/libs/libservice_vm_requests/src/rkp.rs
index c62a36b..e2be11b 100644
--- a/libs/libservice_vm_requests/src/rkp.rs
+++ b/libs/libservice_vm_requests/src/rkp.rs
@@ -180,8 +180,8 @@
     /// order as per RFC8949.
     /// The CBOR ordering rules are:
     /// 1. If two keys have different lengths, the shorter one sorts earlier;
-    /// 2. If two keys have the same length, the one with the lower value in
-    ///  (bytewise) lexical order sorts earlier.
+    /// 2. If two keys have the same length, the one with the lower value in (bytewise) lexical
+    ///    order sorts earlier.
     #[test]
     fn device_info_is_in_length_first_deterministic_order() {
         let device_info = cbor!(device_info()).unwrap();
diff --git a/libs/libvirtualization_jni/Android.bp b/libs/libvirtualization_jni/Android.bp
index 4a569d4..9dc86b0 100644
--- a/libs/libvirtualization_jni/Android.bp
+++ b/libs/libvirtualization_jni/Android.bp
@@ -16,7 +16,10 @@
         "liblog",
         "libnativehelper",
     ],
-    static_libs: ["libavf_cc_flags"],
+    static_libs: [
+        "libavf_cc_flags",
+        "libvmclient.ffi",
+    ],
 }
 
 cc_library_shared {
diff --git a/libs/libvirtualization_jni/android_system_virtualmachine_VirtualizationService.cpp b/libs/libvirtualization_jni/android_system_virtualmachine_VirtualizationService.cpp
index ced2079..f0c9b4f 100644
--- a/libs/libvirtualization_jni/android_system_virtualmachine_VirtualizationService.cpp
+++ b/libs/libvirtualization_jni/android_system_virtualmachine_VirtualizationService.cpp
@@ -19,6 +19,7 @@
 #include <android-base/unique_fd.h>
 #include <android/avf_cc_flags.h>
 #include <android/binder_ibinder_jni.h>
+#include <errno.h>
 #include <jni.h>
 #include <log/log.h>
 #include <poll.h>
@@ -29,57 +30,25 @@
 
 using namespace android::base;
 
-static constexpr const char VIRTMGR_PATH[] = "/apex/com.android.virt/bin/virtmgr";
 static constexpr size_t VIRTMGR_THREADS = 2;
 
+void error_callback(int code, const char* msg, void* ctx) {
+    JNIEnv* env = reinterpret_cast<JNIEnv*>(ctx);
+    if (code == EPERM || code == EACCES) {
+        env->ThrowNew(env->FindClass("java/lang/SecurityException"),
+                      "Virtmgr didn't send any data through pipe. Please consider checking if "
+                      "android.permission.MANAGE_VIRTUAL_MACHINE permission is granted");
+        return;
+    }
+    env->ThrowNew(env->FindClass("android/system/virtualmachine/VirtualMachineException"), msg);
+}
+
+extern "C" int get_virtualization_service(decltype(error_callback)*, void*);
+
 extern "C" JNIEXPORT jint JNICALL
 Java_android_system_virtualmachine_VirtualizationService_nativeSpawn(
         JNIEnv* env, [[maybe_unused]] jclass clazz) {
-    unique_fd serverFd, clientFd;
-    if (!Socketpair(SOCK_STREAM, &serverFd, &clientFd)) {
-        env->ThrowNew(env->FindClass("android/system/virtualmachine/VirtualMachineException"),
-                      ("Failed to create socketpair: " + std::string(strerror(errno))).c_str());
-        return -1;
-    }
-
-    unique_fd waitFd, readyFd;
-    if (!Pipe(&waitFd, &readyFd, 0)) {
-        env->ThrowNew(env->FindClass("android/system/virtualmachine/VirtualMachineException"),
-                      ("Failed to create pipe: " + std::string(strerror(errno))).c_str());
-        return -1;
-    }
-
-    if (fork() == 0) {
-        // Close client's FDs.
-        clientFd.reset();
-        waitFd.reset();
-
-        auto strServerFd = std::to_string(serverFd.get());
-        auto strReadyFd = std::to_string(readyFd.get());
-
-        execl(VIRTMGR_PATH, VIRTMGR_PATH, "--rpc-server-fd", strServerFd.c_str(), "--ready-fd",
-              strReadyFd.c_str(), NULL);
-    }
-
-    // Close virtmgr's FDs.
-    serverFd.reset();
-    readyFd.reset();
-
-    // Wait for the server to signal its readiness by closing its end of the pipe.
-    char buf;
-    int ret = read(waitFd.get(), &buf, sizeof(buf));
-    if (ret < 0) {
-        env->ThrowNew(env->FindClass("android/system/virtualmachine/VirtualMachineException"),
-                      "Failed to wait for VirtualizationService to be ready");
-        return -1;
-    } else if (ret < 1) {
-        env->ThrowNew(env->FindClass("java/lang/SecurityException"),
-                      "Virtmgr didn't send any data through pipe. Please consider checking if "
-                      "android.permission.MANAGE_VIRTUAL_MACHINE permission is granted");
-        return -1;
-    }
-
-    return clientFd.release();
+    return get_virtualization_service(error_callback, env);
 }
 
 extern "C" JNIEXPORT jobject JNICALL
@@ -108,9 +77,3 @@
     }
     return pfds[0].revents == 0;
 }
-
-extern "C" JNIEXPORT jboolean JNICALL
-Java_android_system_virtualmachine_VirtualizationService_nativeIsVendorModulesFlagEnabled(
-        [[maybe_unused]] JNIEnv* env, [[maybe_unused]] jobject obj) {
-    return android::virtualization::IsVendorModulesFlagEnabled();
-}
diff --git a/libs/libvm_payload/Android.bp b/libs/libvm_payload/Android.bp
index cf2a002..bb91737 100644
--- a/libs/libvm_payload/Android.bp
+++ b/libs/libvm_payload/Android.bp
@@ -16,7 +16,6 @@
         "libandroid_logger",
         "libanyhow",
         "libbinder_rs",
-        "liblazy_static",
         "liblibc",
         "liblog_rust",
         "libopenssl",
diff --git a/libs/libvm_payload/src/lib.rs b/libs/libvm_payload/src/lib.rs
index 5cc4431..40f7b79 100644
--- a/libs/libvm_payload/src/lib.rs
+++ b/libs/libvm_payload/src/lib.rs
@@ -23,7 +23,6 @@
     unstable_api::{new_spibinder, AIBinder},
     Strong, ExceptionCode,
 };
-use lazy_static::lazy_static;
 use log::{error, info, LevelFilter};
 use rpcbinder::{RpcServer, RpcSession};
 use openssl::{ec::EcKey, sha::sha256, ecdsa::EcdsaSig};
@@ -35,6 +34,7 @@
 use std::ptr::{self, NonNull};
 use std::sync::{
     atomic::{AtomicBool, Ordering},
+    LazyLock,
     Mutex,
 };
 use vm_payload_status_bindgen::AVmAttestationStatus;
@@ -42,13 +42,11 @@
 /// Maximum size of an ECDSA signature for EC P-256 key is 72 bytes.
 const MAX_ECDSA_P256_SIGNATURE_SIZE: usize = 72;
 
-lazy_static! {
-    static ref VM_APK_CONTENTS_PATH_C: CString =
-        CString::new(VM_APK_CONTENTS_PATH).expect("CString::new failed");
-    static ref PAYLOAD_CONNECTION: Mutex<Option<Strong<dyn IVmPayloadService>>> = Mutex::default();
-    static ref VM_ENCRYPTED_STORAGE_PATH_C: CString =
-        CString::new(ENCRYPTEDSTORE_MOUNTPOINT).expect("CString::new failed");
-}
+static VM_APK_CONTENTS_PATH_C: LazyLock<CString> =
+    LazyLock::new(|| CString::new(VM_APK_CONTENTS_PATH).expect("CString::new failed"));
+static PAYLOAD_CONNECTION: Mutex<Option<Strong<dyn IVmPayloadService>>> = Mutex::new(None);
+static VM_ENCRYPTED_STORAGE_PATH_C: LazyLock<CString> =
+    LazyLock::new(|| CString::new(ENCRYPTEDSTORE_MOUNTPOINT).expect("CString::new failed"));
 
 static ALREADY_NOTIFIED: AtomicBool = AtomicBool::new(false);
 
@@ -401,8 +399,8 @@
 /// Behavior is undefined if any of the following conditions are violated:
 ///
 /// * `data` must be [valid] for writes of `size` bytes, if size > 0.
-/// * The region of memory beginning at `data` with `size` bytes must not overlap with the
-///  region of memory `res` points to.
+/// * The region of memory beginning at `data` with `size` bytes must not overlap with the region of
+///   memory `res` points to.
 ///
 /// [valid]: ptr#safety
 /// [RFC 5915 s3]: https://datatracker.ietf.org/doc/html/rfc5915#section-3
@@ -439,8 +437,8 @@
 ///
 /// * `message` must be [valid] for reads of `message_size` bytes.
 /// * `data` must be [valid] for writes of `size` bytes, if size > 0.
-/// * The region of memory beginning at `data` with `size` bytes must not overlap with the
-///  region of memory `res` or `message` point to.
+/// * The region of memory beginning at `data` with `size` bytes must not overlap with the region of
+///   memory `res` or `message` point to.
 ///
 ///
 /// [valid]: ptr#safety
@@ -507,8 +505,8 @@
 /// * `data` must be [valid] for writes of `size` bytes, if size > 0.
 /// * `index` must be within the range of [0, number of certificates). The number of certificates
 ///   can be obtained with `AVmAttestationResult_getCertificateCount`.
-/// * The region of memory beginning at `data` with `size` bytes must not overlap with the
-///  region of memory `res` points to.
+/// * The region of memory beginning at `data` with `size` bytes must not overlap with the region of
+///   memory `res` points to.
 ///
 /// [valid]: ptr#safety
 #[no_mangle]
diff --git a/libs/libvmbase/example/Android.bp b/libs/libvmbase/example/Android.bp
deleted file mode 100644
index fe9de44..0000000
--- a/libs/libvmbase/example/Android.bp
+++ /dev/null
@@ -1,74 +0,0 @@
-package {
-    default_applicable_licenses: ["Android-Apache-2.0"],
-}
-
-rust_ffi_static {
-    name: "libvmbase_example",
-    defaults: ["vmbase_ffi_defaults"],
-    crate_name: "vmbase_example",
-    srcs: ["src/main.rs"],
-    rustlibs: [
-        "libaarch64_paging",
-        "libcstr",
-        "libdiced_open_dice_nostd",
-        "libfdtpci",
-        "liblibfdt",
-        "liblog_rust_nostd",
-        "libvirtio_drivers",
-        "libvmbase",
-    ],
-}
-
-cc_binary {
-    name: "vmbase_example",
-    defaults: ["vmbase_elf_defaults"],
-    srcs: [
-        "idmap.S",
-    ],
-    static_libs: [
-        "libvmbase_example",
-    ],
-    linker_scripts: [
-        "image.ld",
-        ":vmbase_sections",
-    ],
-}
-
-raw_binary {
-    name: "vmbase_example_bin",
-    stem: "vmbase_example.bin",
-    src: ":vmbase_example",
-    enabled: false,
-    target: {
-        android_arm64: {
-            enabled: true,
-        },
-    },
-}
-
-rust_test {
-    name: "vmbase_example.integration_test",
-    crate_name: "vmbase_example_test",
-    srcs: ["tests/test.rs"],
-    prefer_rlib: true,
-    edition: "2021",
-    rustlibs: [
-        "android.system.virtualizationservice-rust",
-        "libandroid_logger",
-        "libanyhow",
-        "liblibc",
-        "liblog_rust",
-        "libnix",
-        "libvmclient",
-    ],
-    data: [
-        ":vmbase_example_bin",
-    ],
-    test_suites: ["general-tests"],
-    enabled: false,
-    target: {
-        android_arm64: {
-            enabled: true,
-        },
-    },
-}
diff --git a/libs/libvmbase/sections.ld b/libs/libvmbase/sections.ld
index c7ef0ec..7d464bc 100644
--- a/libs/libvmbase/sections.ld
+++ b/libs/libvmbase/sections.ld
@@ -29,17 +29,13 @@
 
 SECTIONS
 {
-	.dtb (NOLOAD) : {
-		dtb_begin = .;
-		. += LENGTH(dtb_region);
-		dtb_end = .;
-	} >dtb_region
-
 	/*
 	 * Collect together the code. This is page aligned so it can be mapped
 	 * as executable-only.
 	 */
 	.text : ALIGN(4096) {
+		KEEP(*(.init.head));
+		*(.init.head)
 		text_begin = .;
 		*(.init.entry)
 		*(.init.*)
diff --git a/libs/libvmbase/src/entry.rs b/libs/libvmbase/src/entry.rs
index ad633ed..99f28fc 100644
--- a/libs/libvmbase/src/entry.rs
+++ b/libs/libvmbase/src/entry.rs
@@ -18,7 +18,7 @@
     bionic, console, heap, hyp,
     layout::{UART_ADDRESSES, UART_PAGE_ADDR},
     logger,
-    memory::{SIZE_16KB, SIZE_4KB},
+    memory::{PAGE_SIZE, SIZE_16KB, SIZE_4KB},
     power::{reboot, shutdown},
     rand,
 };
@@ -129,3 +129,37 @@
         }
     };
 }
+
+/// Prepends a Linux kernel header to the generated binary image.
+///
+/// See https://docs.kernel.org/arch/arm64/booting.html
+/// ```
+#[macro_export]
+macro_rules! generate_image_header {
+    () => {
+        #[cfg(not(target_endian = "little"))]
+        compile_error!("Image header uses wrong endianness: bootloaders expect LE!");
+
+        core::arch::global_asm!(
+            // This section gets linked at the start of the image.
+            ".section .init.head, \"ax\"",
+            // This prevents the macro from being called more than once.
+            ".global image_header",
+            "image_header:",
+            // Linux uses a special NOP to be ELF-compatible; we're not.
+            "nop",                          // code0
+            "b entry",                      // code1
+            ".quad 0",                      // text_offset
+            ".quad bin_end - image_header", // image_size
+            ".quad (1 << 1)",               // flags (PAGE_SIZE=4KiB)
+            ".quad 0",                      // res2
+            ".quad 0",                      // res3
+            ".quad 0",                      // res4
+            ".ascii \"ARM\x64\"",           // magic
+            ".long 0",                      // res5
+        );
+    };
+}
+
+// If this fails, the image header flags are out-of-sync with PAGE_SIZE!
+static_assertions::const_assert_eq!(PAGE_SIZE, SIZE_4KB);
diff --git a/libs/libvmbase/src/layout.rs b/libs/libvmbase/src/layout.rs
index 5ac435f..adcb2fa 100644
--- a/libs/libvmbase/src/layout.rs
+++ b/libs/libvmbase/src/layout.rs
@@ -60,11 +60,6 @@
     }};
 }
 
-/// Memory reserved for the DTB.
-pub fn dtb_range() -> Range<VirtualAddress> {
-    linker_region!(dtb_begin, dtb_end)
-}
-
 /// Executable code.
 pub fn text_range() -> Range<VirtualAddress> {
     linker_region!(text_begin, text_end)
diff --git a/libs/libvmbase/src/util.rs b/libs/libvmbase/src/util.rs
index 8c230a1..e52ac8e 100644
--- a/libs/libvmbase/src/util.rs
+++ b/libs/libvmbase/src/util.rs
@@ -14,6 +14,7 @@
 
 //! Utility functions.
 
+use aarch64_paging::paging::MemoryRegion;
 use core::ops::Range;
 
 /// Flatten [[T; N]] into &[T]
@@ -91,3 +92,13 @@
         self.start < other.end && other.start < self.end
     }
 }
+
+impl RangeExt for MemoryRegion {
+    fn is_within(&self, other: &Self) -> bool {
+        self.start() >= other.start() && self.end() <= other.end()
+    }
+
+    fn overlaps(&self, other: &Self) -> bool {
+        self.start() < other.end() && other.start() < self.end()
+    }
+}
diff --git a/libs/libvmclient/Android.bp b/libs/libvmclient/Android.bp
index 96fe667..5bd59da 100644
--- a/libs/libvmclient/Android.bp
+++ b/libs/libvmclient/Android.bp
@@ -2,8 +2,8 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
-rust_library {
-    name: "libvmclient",
+rust_defaults {
+    name: "libvmclient.default",
     crate_name: "vmclient",
     defaults: ["avf_build_flags_rust"],
     srcs: ["src/lib.rs"],
@@ -21,6 +21,17 @@
     ],
     apex_available: [
         "com.android.compos",
+        "com.android.microfuchsia",
         "com.android.virt",
     ],
 }
+
+rust_library {
+    name: "libvmclient",
+    defaults: ["libvmclient.default"],
+}
+
+rust_ffi_static {
+    name: "libvmclient.ffi",
+    defaults: ["libvmclient.default"],
+}
diff --git a/libs/libvmclient/src/lib.rs b/libs/libvmclient/src/lib.rs
index 88072a7..bc9d683 100644
--- a/libs/libvmclient/src/lib.rs
+++ b/libs/libvmclient/src/lib.rs
@@ -43,7 +43,9 @@
 use log::warn;
 use rpcbinder::{FileDescriptorTransportMode, RpcSession};
 use shared_child::SharedChild;
+use std::ffi::{c_char, c_int, c_void, CString};
 use std::io::{self, Read};
+use std::os::fd::RawFd;
 use std::process::Command;
 use std::{
     fmt::{self, Debug, Formatter},
@@ -74,6 +76,40 @@
     Ok(socketpair(AddressFamily::Unix, SockType::Stream, None, SockFlag::SOCK_CLOEXEC)?)
 }
 
+/// Error handling function for `get_virtualization_service`.
+///
+/// # Safety
+/// `message` shouldn't be used outside of the lifetime of the function. Management of `ctx` is
+/// entirely up to the function.
+pub type ErrorCallback =
+    unsafe extern "C" fn(code: c_int, message: *const c_char, ctx: *mut c_void);
+
+/// Spawns a new instance of virtmgr and rerturns a file descriptor for the socket connection to
+/// the service. When error occurs, it is reported via the ErrorCallback function along with the
+/// error message and any context that is set by the client.
+///
+/// # Safety
+/// `cb` should be null or a valid function pointer of type `ErrorCallback`
+#[no_mangle]
+pub unsafe extern "C" fn get_virtualization_service(
+    cb: Option<ErrorCallback>,
+    ctx: *mut c_void,
+) -> RawFd {
+    match VirtualizationService::new() {
+        Ok(vs) => vs.client_fd.into_raw_fd(),
+        Err(e) => {
+            if let Some(cb) = cb {
+                let code = e.raw_os_error().unwrap_or(-1);
+                let msg = CString::new(e.to_string()).unwrap();
+                // SAFETY: `cb` doesn't use `msg` outside of the lifetime of the function.
+                // msg's lifetime is longer than `cb` as it is bound to a local variable.
+                unsafe { cb(code, msg.as_ptr(), ctx) };
+            }
+            -1
+        }
+    }
+}
+
 /// A running instance of virtmgr which is hosting a VirtualizationService
 /// RpcBinder server.
 pub struct VirtualizationService {
@@ -90,20 +126,18 @@
         let (client_fd, server_fd) = posix_socketpair()?;
 
         let mut command = Command::new(VIRTMGR_PATH);
+        // Can't use BorrowedFd as it doesn't implement Display
         command.arg("--rpc-server-fd").arg(format!("{}", server_fd.as_raw_fd()));
         command.arg("--ready-fd").arg(format!("{}", ready_fd.as_raw_fd()));
-        command.preserved_fds(vec![server_fd.as_raw_fd(), ready_fd.as_raw_fd()]);
+        command.preserved_fds(vec![server_fd, ready_fd]);
 
         SharedChild::spawn(&mut command)?;
 
-        // Drop FDs that belong to virtmgr.
-        drop(server_fd);
-        drop(ready_fd);
-
-        // Wait for the child to signal that the RpcBinder server is ready
-        // by closing its end of the pipe.
-        let _ignored = File::from(wait_fd).read(&mut [0]);
-
+        // Wait for the child to signal that the RpcBinder server is read by closing its end of the
+        // pipe. Failing to read (especially EACCESS or EPERM) can happen if the client lacks the
+        // MANAGE_VIRTUAL_MACHINE permission. Therefore, such errors are propagated instead of
+        // being ignored.
+        let _ = File::from(wait_fd).read(&mut [0])?;
         Ok(VirtualizationService { client_fd })
     }
 
diff --git a/libs/vm_launcher_lib/Android.bp b/libs/vm_launcher_lib/Android.bp
new file mode 100644
index 0000000..8591c8d
--- /dev/null
+++ b/libs/vm_launcher_lib/Android.bp
@@ -0,0 +1,13 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+java_library {
+    name: "vm_launcher_lib",
+    srcs: ["java/**/*.java"],
+    apex_available: [
+        "//apex_available:platform",
+        "com.android.virt",
+    ],
+    sdk_version: "system_current",
+}
diff --git a/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/VmLauncherServices.java b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/VmLauncherServices.java
new file mode 100644
index 0000000..565b793
--- /dev/null
+++ b/libs/vm_launcher_lib/java/com/android/virtualization/vmlauncher/VmLauncherServices.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2024 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.virtualization.vmlauncher;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.content.pm.ResolveInfo;
+import android.os.Bundle;
+import android.os.Handler;
+import android.os.Looper;
+import android.os.Parcel;
+import android.os.ResultReceiver;
+import android.util.Log;
+
+import java.util.List;
+
+public class VmLauncherServices {
+    private static final String TAG = "VmLauncherServices";
+
+    private static final String ACTION_START_VM_LAUNCHER_SERVICE =
+            "android.virtualization.START_VM_LAUNCHER_SERVICE";
+
+    private static final int RESULT_START = 0;
+    private static final int RESULT_STOP = 1;
+    private static final int RESULT_ERROR = 2;
+    private static final int RESULT_IPADDR = 3;
+    private static final String KEY_VM_IP_ADDR = "ip_addr";
+
+    private static Intent buildVmLauncherServiceIntent(Context context) {
+        Intent i = new Intent();
+        i.setAction(ACTION_START_VM_LAUNCHER_SERVICE);
+
+        Intent intent = new Intent(ACTION_START_VM_LAUNCHER_SERVICE);
+        PackageManager pm = context.getPackageManager();
+        List<ResolveInfo> resolveInfos =
+                pm.queryIntentServices(intent, PackageManager.MATCH_DEFAULT_ONLY);
+        if (resolveInfos == null || resolveInfos.size() != 1) {
+            Log.e(TAG, "cannot find a service to handle ACTION_START_VM_LAUNCHER_SERVICE");
+            return null;
+        }
+        String packageName = resolveInfos.get(0).serviceInfo.packageName;
+
+        i.setPackage(packageName);
+        return i;
+    }
+
+    public static void stopVmLauncherService(Context context) {
+        Intent i = buildVmLauncherServiceIntent(context);
+        context.stopService(i);
+    }
+
+    public static void startVmLauncherService(Context context, VmLauncherServiceCallback callback) {
+        Intent i = buildVmLauncherServiceIntent(context);
+        if (i == null) {
+            return;
+        }
+        ResultReceiver resultReceiver =
+                new ResultReceiver(new Handler(Looper.myLooper())) {
+                    @Override
+                    protected void onReceiveResult(int resultCode, Bundle resultData) {
+                        if (callback == null) {
+                            return;
+                        }
+                        switch (resultCode) {
+                            case RESULT_START:
+                                callback.onVmStart();
+                                return;
+                            case RESULT_STOP:
+                                callback.onVmStop();
+                                return;
+                            case RESULT_ERROR:
+                                callback.onVmError();
+                                return;
+                            case RESULT_IPADDR:
+                                callback.onIpAddrAvailable(resultData.getString(KEY_VM_IP_ADDR));
+                                return;
+                        }
+                    }
+                };
+        i.putExtra(Intent.EXTRA_RESULT_RECEIVER, getResultReceiverForIntent(resultReceiver));
+        context.startForegroundService(i);
+    }
+
+    public interface VmLauncherServiceCallback {
+        void onVmStart();
+
+        void onVmStop();
+
+        void onVmError();
+
+        void onIpAddrAvailable(String ipAddr);
+    }
+
+    private static ResultReceiver getResultReceiverForIntent(ResultReceiver r) {
+        Parcel parcel = Parcel.obtain();
+        r.writeToParcel(parcel, 0);
+        parcel.setDataPosition(0);
+        r = ResultReceiver.CREATOR.createFromParcel(parcel);
+        parcel.recycle();
+        return r;
+    }
+}
diff --git a/libs/vmconfig/src/lib.rs b/libs/vmconfig/src/lib.rs
index ff115f3..ef932c2 100644
--- a/libs/vmconfig/src/lib.rs
+++ b/libs/vmconfig/src/lib.rs
@@ -18,6 +18,7 @@
     aidl::android::system::virtualizationservice::CpuTopology::CpuTopology,
     aidl::android::system::virtualizationservice::DiskImage::DiskImage as AidlDiskImage,
     aidl::android::system::virtualizationservice::Partition::Partition as AidlPartition,
+    aidl::android::system::virtualizationservice::UsbConfig::UsbConfig as AidlUsbConfig,
     aidl::android::system::virtualizationservice::VirtualMachineAppConfig::DebugLevel::DebugLevel,
     aidl::android::system::virtualizationservice::VirtualMachineConfig::VirtualMachineConfig,
     aidl::android::system::virtualizationservice::VirtualMachineRawConfig::VirtualMachineRawConfig,
@@ -68,6 +69,8 @@
     pub devices: Vec<PathBuf>,
     /// The serial device for VM console input.
     pub console_input_device: Option<String>,
+    /// The USB config of the VM.
+    pub usb_config: Option<UsbConfig>,
 }
 
 impl VmConfig {
@@ -110,6 +113,7 @@
             Some("match_host") => CpuTopology::MATCH_HOST,
             Some(cpu_topology) => bail!("Invalid cpu topology {}", cpu_topology),
         };
+        let usb_config = self.usb_config.clone().map(|x| x.to_parcelable()).transpose()?;
         Ok(VirtualMachineRawConfig {
             kernel: maybe_open_parcel_file(&self.kernel, false)?,
             initrd: maybe_open_parcel_file(&self.initrd, false)?,
@@ -128,6 +132,7 @@
                 })
                 .collect::<Result<_>>()?,
             consoleInputDevice: self.console_input_device.clone(),
+            usbConfig: usb_config,
             ..Default::default()
         })
     }
@@ -193,6 +198,19 @@
     }
 }
 
+/// USB controller and available USB devices
+#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
+pub struct UsbConfig {
+    /// Enable USB controller
+    pub controller: bool,
+}
+
+impl UsbConfig {
+    fn to_parcelable(&self) -> Result<AidlUsbConfig> {
+        Ok(AidlUsbConfig { controller: self.controller })
+    }
+}
+
 /// Try to open the given file and wrap it in a [`ParcelFileDescriptor`].
 pub fn open_parcel_file(filename: &Path, writable: bool) -> Result<ParcelFileDescriptor> {
     Ok(ParcelFileDescriptor::new(
diff --git a/microfuchsia/OWNERS b/microfuchsia/OWNERS
new file mode 100644
index 0000000..8092be7
--- /dev/null
+++ b/microfuchsia/OWNERS
@@ -0,0 +1,2 @@
+awolter@google.com
+jamesr@google.com
diff --git a/microfuchsia/README.md b/microfuchsia/README.md
new file mode 100644
index 0000000..82de725
--- /dev/null
+++ b/microfuchsia/README.md
@@ -0,0 +1,30 @@
+# Microfuchsia
+
+Microfuchsia is an experimental solution for running trusted applications on
+pkvm using the Android Virtualization Framework (AVF).
+
+# How to use
+
+Add the `com.android.microfuchsia` apex to your product.
+
+```
+PRODUCT_PACKAGES += com.android.microfuchsia
+```
+
+Define and add a `com.android.microfuchsia.images` apex to hold the images.
+
+```
+PRODUCT_PACKAGES += com.android.microfuchsia.images
+```
+
+This apex must have a prebuilt `fuchsia.zbi` in `/etc/fuchsia.zbi` and a boot
+shim in `/etc/linux-arm64-boot-shim.bin`.
+
+# Using the console
+
+This command will open the console for the first VM running in AVF, and can be
+used to connect to the microfuchsia console.
+
+```
+adb shell -t /apex/com.android.virt/bin/vm console
+```
diff --git a/microfuchsia/apex/Android.bp b/microfuchsia/apex/Android.bp
new file mode 100644
index 0000000..eddda9f
--- /dev/null
+++ b/microfuchsia/apex/Android.bp
@@ -0,0 +1,55 @@
+// Copyright (C) 2024 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 {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+apex {
+    name: "com.android.microfuchsia",
+    manifest: "manifest.json",
+    key: "com.android.microfuchsia.key",
+
+    // Allows us to specify a file_contexts in our own repository.
+    system_ext_specific: true,
+    file_contexts: "com.android.microfuchsia-file_contexts",
+
+    updatable: false,
+    future_updatable: false,
+    platform_apis: true,
+
+    binaries: [
+        // A daemon that starts on bootup that launches microfuchsia in AVF.
+        "microfuchsiad",
+    ],
+
+    prebuilts: [
+        // An init script to launch the microfuchsiad daemon on bootup which
+        // launches the microfuchsia VM in AVF.
+        "com.android.microfuchsia.init.rc",
+    ],
+}
+
+apex_key {
+    name: "com.android.microfuchsia.key",
+    public_key: "com.android.microfuchsia.avbpubkey",
+    private_key: "com.android.microfuchsia.pem",
+}
+
+prebuilt_etc {
+    name: "com.android.microfuchsia.init.rc",
+    src: "microfuchsia.rc",
+    filename: "init.rc",
+    installable: false,
+}
diff --git a/microfuchsia/apex/com.android.microfuchsia-file_contexts b/microfuchsia/apex/com.android.microfuchsia-file_contexts
new file mode 100644
index 0000000..13d7286
--- /dev/null
+++ b/microfuchsia/apex/com.android.microfuchsia-file_contexts
@@ -0,0 +1,2 @@
+(/.*)?                   u:object_r:system_file:s0
+/bin/microfuchsiad       u:object_r:microfuchsiad_exec:s0
diff --git a/microfuchsia/apex/com.android.microfuchsia.avbpubkey b/microfuchsia/apex/com.android.microfuchsia.avbpubkey
new file mode 100644
index 0000000..10d4b88
--- /dev/null
+++ b/microfuchsia/apex/com.android.microfuchsia.avbpubkey
Binary files differ
diff --git a/microfuchsia/apex/com.android.microfuchsia.pem b/microfuchsia/apex/com.android.microfuchsia.pem
new file mode 100644
index 0000000..541fa80
--- /dev/null
+++ b/microfuchsia/apex/com.android.microfuchsia.pem
@@ -0,0 +1,52 @@
+-----BEGIN PRIVATE KEY-----
+MIIJQAIBADANBgkqhkiG9w0BAQEFAASCCSowggkmAgEAAoICAQCr8qQ+s57kXmB6
+m51lEcX7edl3l5jc1gQxmgopb4ddY0fXm4f0xj9El/Ye4J9lNpf9e1sTJuaytQZR
+lz/I/Kyla6erb37zw18kI1OyTY7PWoeNyNUehLEHqIoeDaj1S3xvb3BiRcncBpLt
+KT/Lunxu4C9sL8kAg9egH/zhOPvm37dqWxJq7CJC/TxSC4sizH6pxjx8AigVCDP3
+i4rwtgUxEdO4CKKm0bK+izUIGRXta3AToL6PKeki0r8E3HhpjNbcpTMpC57TtIgt
+39VSsk5azqSFeEUuBqZdI2Sqgsdxyh3CC4n7MzRduKtrlYAM94Mf2VNQINQ2dG/i
+AhH6Azd8WizGv5MHUeBqn/wHXQ699q19rQj5fFy1vFpw2ptSmkDP3xDsKZsfpYQl
+2FzYoEKIPli7uKOXu5Wa8N+a32SVF8nKbbvPCojklVmOC2IWOxolxI5BlvuMy8aJ
++Ly743dRHu6hEKIHZLRcVCHiixwjlZ8Wqweq5WaxMAKAlvQ4FY0xMoRMeij9WpJ/
+rBYE7qQE2GRm7h9D16nqoJvTeoucsQ50sg5U5aR00aH4xQacK4v6UnkQ5yU8ssPV
+oCIcLbAZ+i0ZRULSom7Lmeu+Lb4kb0+GhP31M3UjGMmyTZYtxbaHwkMK+W4ja6/X
+M4O5+cruvEAxkNQhRUTVBNDKo7YKewIDAQABAoIB/2taktvoSXagy0ZsN1i4QA6X
+hQRQd0q+/t9OeAm8GEe2NKSTS88HTM5cEiOKb/pBRk58izWUlB9UkR1f0UiAeUoj
+wgtxu/wgKXE78oWK5smPPBLJ0PBnkspf79vTq37QImDGCDn9rd+G5d+BttL7xl9z
+Q33IV+ElGlBe/a5LEFCVB27fwsqpo2Uvtk7YkNtT0cEt2OrpGHKz1xOMNrMS8dWG
+dn6a5ZzsT9enZ598CgoG33K3FEKjaBYrKMK1jnhX9njMAPp0xt+8AfSiS2MrmsAX
+REtl9nXwsO3LAI7KGBEd9SEHE0mYLpmqiAbOJaSdjsB+b1sXzrww9lRP9pP3GNcC
+dLF+MOZMFiT+mltSNOmVgPM5nV8njFruqcGOssyq8UJVl/aoIc5CNTsRgiudxOjy
+1kS2VPw4zeoQqyt3lFoZQR/PfrJEXsOJJqJngS8cUmuAAKEWZb0ZjtMFcUrXfFH1
+IXyOl1eQysvQQQynnVc4Xsg67FkqO4OEfxO2Ia9WzGmBV1DfCAK52iLbh2dNxPxg
+5SwkOuzmsztDNHAXMZZZJgwQJ7j4mc1ftfilaNUJn6PDguakclpMKVzP72Hg62TY
+ieQzSo1aKmd4fGMmVe0vCcAur2VnbmKjrblxigg4Gf7S794WJccVsZyGEcasEryA
+OP6M+jHA8EaZQT7DGxUCggEBAO/gqOobZV1b5WyX2WLi+v+Hyd8ZaCpCEeW+NPHd
+Bhh+LffoSrQ4LT4qLfHOaplarA8qcf/Tws4PUgB0yAd/OkwjCBsKSnaa/5368elv
+MOVFhZlg+jn7NXfNh3KvyZ7c/Usg/Hh6w6IleY8mvCj25A8aqb4xqEEHIh6AgYu7
+1bcqmKvEh3zVgkVCNFqDMQvA2F86qo4kW4QCeH4uCH749ynbwO6xungHJdEvEYLv
+hr9r7KXYD6m+redF8UQZE2y35o+MHgzmX1u7ak427D11Uq7OkP3U1xxyPgZ5hURX
+nHKJStGQ1xKZvBQ7aZGKPFTE+7GZJBuwO7NGhFAtOGWWOwcCggEBALeBLjMVTVo+
+8OqnJ2zbCYHTbcP0fBFdXFQLg+XhOxpVCQjDP59pJZC0vyH4BkCpnrSGTJRYuZz6
+MA4uptjU07P9bRBM3mK0c6pb71S2bMIzV5PxiwXvRKVzIAcXY4f2KgIQM6STRaT6
+r50gNTYak+CsdqQqPTqIpii3O9ddp9JEB1sZNys36GKuNm2a86dZO7gV5n5NBPJJ
+AHnSYIhPF3JD9EqlSeAmWOtW2vDc7Kogkf4SdaYFIX2FYIffFEOOUjlaIL5Xgf7P
+iFF8/Tu9WiExyA+sD8yLG2pNdS66eBXVEdCBC44uDDVU4awYgpi34ZJTgay1yj0o
+tloYeexpM+0CggEAHA8Zcxj1SHBha8xvX0PRvGYz1Obx6k+ELG2NX+VMuzy3P9Jq
+Op5/nE/uw+QzT/DtQ3DhmN06YkQkgW0noMjfFtzaK9+OSkVjNSWPepDJFWiGciSH
+4JRj8rmV6HJrkSukbU9UePtTOvpLN9V+GQSYNLQXuumwFrsw4ISDosa7/wr6hM0e
+VBndfSB7Y0MJT6ilJq6EGNBj7BMl6QyVbdTNhJXyAXnEqBmd8NQipkBCcM29BsE5
+Q8/MI8top2CPhx4T2CK5uSSRbveDPdbq112L6Gq9RxPIfclXPAam8hGVeUhZ+h2J
+KuHUwEEa3i1fVUMdde7F7H823IeZHo/LkwZ5rQKCAQA4qfYnJgPNwzPHcbg13+ku
+oqf5Y2xQPGD/PtMK0CLc/bcdcpUZ13EXHwkKJzlfDEGKgxHwmPkv5P2j03oH6Kg6
+ox3jc6kUF57D00GzCeXJjesULvj76ydqY4NXTTyZxkSwgGpB/ov55sMFpOVpgIl7
+TiYQiU6A3aNZXUNoPG5O+ly/H6kuekQS/LKn47orSd2r+W9EPuoxGqO/+lt+m9Wk
+niE4T5PhWFYKzbYrvDyESCxspSyZCGqQBPiK3DK4raDsPs1vmTv2AAWbDBpyMQU8
+zM93L21tfuMHT0XJGSFttG6c0MxNqiBw83YAG01wdQ99jLW1LCl3+zNb3MUBYHb9
+AoIBAGWTZQOQLMVDH5ljzty/HnW3J9ZPPhF+x3B5L98eiYD96tJ6UVsU9Cok6WKu
+V7q7SdwI4pI3mdiuD7ljHMHXiSmF8zPmpG1TpZ1yFNKBQyhIkA/Pffe2wc3ua6Kj
+baXi9jWfLDCQoa8fZ/dzlaUuqN23YuCSwUrLpJ/3o/xgTG085vD3ycbcYvw715PK
+B/9YspIMDQkf2yvOuDwXCjI3IFIGwBGLHoHt+Giqz3z68z54z5qaFi092yNeAewQ
+hhUl1mh6VVanYiERqAgvYUxHuEyD211UYGwMxRHUdiqbtALexZjOB1hLxLnWRtdS
+wa28hvmts5NyMy819GfPGqdRa14=
+-----END PRIVATE KEY-----
diff --git a/microfuchsia/apex/manifest.json b/microfuchsia/apex/manifest.json
new file mode 100644
index 0000000..b7ea23b
--- /dev/null
+++ b/microfuchsia/apex/manifest.json
@@ -0,0 +1,4 @@
+{
+  "name": "com.android.microfuchsia",
+  "version": 1
+}
diff --git a/microfuchsia/apex/microfuchsia.rc b/microfuchsia/apex/microfuchsia.rc
new file mode 100644
index 0000000..2b19ed3
--- /dev/null
+++ b/microfuchsia/apex/microfuchsia.rc
@@ -0,0 +1,22 @@
+# Copyright (C) 2024 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.
+
+service microfuchsiad /apex/com.android.microfuchsia/bin/microfuchsiad
+    class main
+    user root
+    group system
+    # We need SYS_NICE in order to allow the crosvm child process to use it.
+    # (b/322197421). composd itself never uses it (and isn't allowed to by
+    # SELinux).
+    capabilities SYS_NICE
diff --git a/microfuchsia/microfuchsiad/Android.bp b/microfuchsia/microfuchsiad/Android.bp
new file mode 100644
index 0000000..ab3f865
--- /dev/null
+++ b/microfuchsia/microfuchsiad/Android.bp
@@ -0,0 +1,26 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+// A daemon that launches microfuchsia in AVF.
+rust_binary {
+    name: "microfuchsiad",
+    srcs: ["src/main.rs"],
+    edition: "2021",
+    prefer_rlib: true,
+    defaults: ["avf_build_flags_rust"],
+    rustlibs: [
+        "android.system.microfuchsiad-rust",
+        "android.system.virtualizationservice-rust",
+        "libandroid_logger",
+        "libanyhow",
+        "libbinder_rs",
+        "liblibc",
+        "liblog_rust",
+        "libsafe_ownedfd",
+        "libvmclient",
+    ],
+    apex_available: [
+        "com.android.microfuchsia",
+    ],
+}
diff --git a/microfuchsia/microfuchsiad/aidl/Android.bp b/microfuchsia/microfuchsiad/aidl/Android.bp
new file mode 100644
index 0000000..02bb7c6
--- /dev/null
+++ b/microfuchsia/microfuchsiad/aidl/Android.bp
@@ -0,0 +1,24 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+aidl_interface {
+    name: "android.system.microfuchsiad",
+    srcs: ["android/system/microfuchsiad/*.aidl"],
+    // TODO: Make this stable when the APEX becomes updatable.
+    unstable: true,
+    backend: {
+        java: {
+            enabled: false,
+        },
+        ndk: {
+            enabled: false,
+        },
+        rust: {
+            enabled: true,
+            apex_available: [
+                "com.android.microfuchsia",
+            ],
+        },
+    },
+}
diff --git a/libs/libvmbase/example/image.ld b/microfuchsia/microfuchsiad/aidl/android/system/microfuchsiad/IMicrofuchsiaService.aidl
similarity index 64%
copy from libs/libvmbase/example/image.ld
copy to microfuchsia/microfuchsiad/aidl/android/system/microfuchsiad/IMicrofuchsiaService.aidl
index 368acbb..a04ae2b 100644
--- a/libs/libvmbase/example/image.ld
+++ b/microfuchsia/microfuchsiad/aidl/android/system/microfuchsiad/IMicrofuchsiaService.aidl
@@ -1,11 +1,11 @@
 /*
- * Copyright 2022 The Android Open Source Project
+ * Copyright 2024 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
  *
- *     https://www.apache.org/licenses/LICENSE-2.0
+ *      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,
@@ -13,10 +13,9 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
+package android.system.microfuchsiad;
 
-MEMORY
-{
-	dtb_region	: ORIGIN = 0x80000000, LENGTH = 2M
-	image		: ORIGIN = 0x80200000, LENGTH = 2M
-	writable_data	: ORIGIN = 0x80400000, LENGTH = 2M
+// This service exists as a placeholder in case we want to communicate with the
+// daemon in the future.
+interface IMicrofuchsiaService {
 }
diff --git a/microfuchsia/microfuchsiad/src/instance_manager.rs b/microfuchsia/microfuchsiad/src/instance_manager.rs
new file mode 100644
index 0000000..5082e50
--- /dev/null
+++ b/microfuchsia/microfuchsiad/src/instance_manager.rs
@@ -0,0 +1,49 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+//! Manages running instances of the Microfuchsia VM.
+//! At most one instance should be running at a time.
+
+use crate::instance_starter::{InstanceStarter, MicrofuchsiaInstance};
+use android_system_virtualizationservice::aidl::android::system::virtualizationservice;
+use anyhow::{bail, Result};
+use binder::Strong;
+use virtualizationservice::IVirtualizationService::IVirtualizationService;
+
+pub struct InstanceManager {
+    service: Strong<dyn IVirtualizationService>,
+    started: bool,
+}
+
+impl InstanceManager {
+    pub fn new(service: Strong<dyn IVirtualizationService>) -> Self {
+        Self { service, started: false }
+    }
+
+    pub fn start_instance(&mut self) -> Result<MicrofuchsiaInstance> {
+        if self.started {
+            bail!("Cannot start multiple microfuchsia instances");
+        }
+
+        let instance_starter = InstanceStarter::new("Microfuchsia", 0);
+        let instance = instance_starter.start_new_instance(&*self.service);
+
+        if instance.is_ok() {
+            self.started = true;
+        }
+        instance
+    }
+}
diff --git a/microfuchsia/microfuchsiad/src/instance_starter.rs b/microfuchsia/microfuchsiad/src/instance_starter.rs
new file mode 100644
index 0000000..6688447
--- /dev/null
+++ b/microfuchsia/microfuchsiad/src/instance_starter.rs
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+//! Responsible for starting an instance of the Microfuchsia VM.
+
+use android_system_virtualizationservice::aidl::android::system::virtualizationservice::{
+    CpuTopology::CpuTopology, IVirtualizationService::IVirtualizationService,
+    VirtualMachineConfig::VirtualMachineConfig, VirtualMachineRawConfig::VirtualMachineRawConfig,
+};
+use anyhow::{ensure, Context, Result};
+use binder::{LazyServiceGuard, ParcelFileDescriptor};
+use log::info;
+use safe_ownedfd::take_fd_ownership;
+use std::ffi::CStr;
+use std::fs::File;
+use std::os::fd::AsRawFd;
+use vmclient::VmInstance;
+
+pub struct MicrofuchsiaInstance {
+    _vm_instance: VmInstance,
+    _lazy_service_guard: LazyServiceGuard,
+    _pty: Pty,
+}
+
+pub struct InstanceStarter {
+    instance_name: String,
+    instance_id: u8,
+}
+
+impl InstanceStarter {
+    pub fn new(instance_name: &str, instance_id: u8) -> Self {
+        Self { instance_name: instance_name.to_owned(), instance_id }
+    }
+
+    pub fn start_new_instance(
+        &self,
+        virtualization_service: &dyn IVirtualizationService,
+    ) -> Result<MicrofuchsiaInstance> {
+        info!("Creating {} instance", self.instance_name);
+
+        // Always use instance id 0, because we will only ever have one instance.
+        let mut instance_id = [0u8; 64];
+        instance_id[0] = self.instance_id;
+
+        // Open the kernel and initrd files from the microfuchsia.images apex.
+        let kernel_fd =
+            File::open("/apex/com.android.microfuchsia.images/etc/linux-arm64-boot-shim.bin")
+                .context("Failed to open the boot-shim")?;
+        let initrd_fd = File::open("/apex/com.android.microfuchsia.images/etc/fuchsia.zbi")
+            .context("Failed to open the fuchsia ZBI")?;
+        let kernel = Some(ParcelFileDescriptor::new(kernel_fd));
+        let initrd = Some(ParcelFileDescriptor::new(initrd_fd));
+
+        // Prepare a pty for console input/output.
+        let pty = openpty()?;
+        let console_in = Some(pty.leader.try_clone().context("cloning pty")?);
+        let console_out = Some(pty.leader.try_clone().context("cloning pty")?);
+
+        let config = VirtualMachineConfig::RawConfig(VirtualMachineRawConfig {
+            name: "Microfuchsia".into(),
+            instanceId: instance_id,
+            kernel,
+            initrd,
+            params: None,
+            bootloader: None,
+            disks: vec![],
+            protectedVm: false,
+            memoryMib: 256,
+            cpuTopology: CpuTopology::ONE_CPU,
+            platformVersion: "1.0.0".into(),
+            // Fuchsia uses serial for console by default.
+            consoleInputDevice: Some("ttyS0".into()),
+            ..Default::default()
+        });
+        let vm_instance = VmInstance::create(
+            virtualization_service,
+            &config,
+            console_out,
+            console_in,
+            /* log= */ None,
+            None,
+        )
+        .context("Failed to create VM")?;
+        vm_instance
+            .vm
+            .setHostConsoleName(&pty.follower_name)
+            .context("Setting host console name")?;
+        vm_instance.start().context("Starting VM")?;
+
+        Ok(MicrofuchsiaInstance {
+            _vm_instance: vm_instance,
+            _lazy_service_guard: Default::default(),
+            _pty: pty,
+        })
+    }
+}
+
+struct Pty {
+    leader: File,
+    follower_name: String,
+}
+
+/// Opens a pseudoterminal (pty), configures it to be a raw terminal, and returns the file pair.
+fn openpty() -> Result<Pty> {
+    // Create a pty pair.
+    let mut leader: libc::c_int = -1;
+    let mut _follower: libc::c_int = -1;
+    let mut follower_name: Vec<libc::c_char> = vec![0; 32];
+
+    // SAFETY: calling openpty with valid+initialized variables is safe.
+    // The two null pointers are valid inputs for openpty.
+    unsafe {
+        ensure!(
+            libc::openpty(
+                &mut leader,
+                &mut _follower,
+                follower_name.as_mut_ptr(),
+                std::ptr::null_mut(),
+                std::ptr::null_mut(),
+            ) == 0,
+            "failed to openpty"
+        );
+    }
+    let leader = take_fd_ownership(leader)?;
+
+    // SAFETY: calling these libc functions with valid+initialized variables is safe.
+    unsafe {
+        // Fetch the termios attributes.
+        let mut attr = libc::termios {
+            c_iflag: 0,
+            c_oflag: 0,
+            c_cflag: 0,
+            c_lflag: 0,
+            c_line: 0,
+            c_cc: [0u8; 19],
+        };
+        ensure!(
+            libc::tcgetattr(leader.as_raw_fd(), &mut attr) == 0,
+            "failed to get termios attributes"
+        );
+
+        // Force it to be a raw pty and re-set it.
+        libc::cfmakeraw(&mut attr);
+        ensure!(
+            libc::tcsetattr(leader.as_raw_fd(), libc::TCSANOW, &attr) == 0,
+            "failed to set termios attributes"
+        );
+    }
+
+    // Construct the return value.
+    let follower_name: Vec<u8> = follower_name.iter_mut().map(|x| *x as _).collect();
+    let follower_name = CStr::from_bytes_until_nul(&follower_name)
+        .context("pty filename missing NUL")?
+        .to_str()
+        .context("pty filename invalid utf8")?
+        .to_string();
+    Ok(Pty { leader: File::from(leader), follower_name })
+}
diff --git a/microfuchsia/microfuchsiad/src/main.rs b/microfuchsia/microfuchsiad/src/main.rs
new file mode 100644
index 0000000..ec290cc
--- /dev/null
+++ b/microfuchsia/microfuchsiad/src/main.rs
@@ -0,0 +1,62 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+//! A daemon that can be launched on bootup that runs microfuchsia in AVF.
+//! An on-demand binder service is also prepared in case we want to communicate with the daemon in
+//! the future.
+
+mod instance_manager;
+mod instance_starter;
+mod service;
+
+use crate::instance_manager::InstanceManager;
+use anyhow::{Context, Result};
+use binder::{register_lazy_service, ProcessState};
+use log::{error, info};
+
+#[allow(clippy::eq_op)]
+fn try_main() -> Result<()> {
+    let debuggable = env!("TARGET_BUILD_VARIANT") != "user";
+    let log_level = if debuggable { log::LevelFilter::Debug } else { log::LevelFilter::Info };
+    android_logger::init_once(
+        android_logger::Config::default().with_tag("microfuchsiad").with_max_level(log_level),
+    );
+
+    ProcessState::start_thread_pool();
+
+    let virtmgr =
+        vmclient::VirtualizationService::new().context("Failed to spawn VirtualizationService")?;
+    let virtualization_service =
+        virtmgr.connect().context("Failed to connect to VirtualizationService")?;
+
+    let instance_manager = InstanceManager::new(virtualization_service);
+    let service = service::new_binder(instance_manager);
+    register_lazy_service("android.system.microfuchsiad", service.as_binder())
+        .context("Registering microfuchsiad service")?;
+
+    info!("Registered services, joining threadpool");
+    ProcessState::join_thread_pool();
+
+    info!("Exiting");
+    Ok(())
+}
+
+fn main() {
+    if let Err(e) = try_main() {
+        error!("{:?}", e);
+        std::process::exit(1)
+    }
+}
diff --git a/microfuchsia/microfuchsiad/src/service.rs b/microfuchsia/microfuchsiad/src/service.rs
new file mode 100644
index 0000000..a2112b1
--- /dev/null
+++ b/microfuchsia/microfuchsiad/src/service.rs
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+
+//! Implementation of IMicrofuchsiaService that runs microfuchsia in AVF when
+//! created.
+
+use crate::instance_manager::InstanceManager;
+use crate::instance_starter::MicrofuchsiaInstance;
+use android_system_microfuchsiad::aidl::android::system::microfuchsiad::IMicrofuchsiaService::{
+    BnMicrofuchsiaService, IMicrofuchsiaService,
+};
+use anyhow::Context;
+use binder::{self, BinderFeatures, Interface, Strong};
+
+#[allow(unused)]
+pub struct MicrofuchsiaService {
+    instance_manager: InstanceManager,
+    microfuchsia: MicrofuchsiaInstance,
+}
+
+pub fn new_binder(mut instance_manager: InstanceManager) -> Strong<dyn IMicrofuchsiaService> {
+    let microfuchsia = instance_manager.start_instance().context("Starting Microfuchsia").unwrap();
+    let service = MicrofuchsiaService { instance_manager, microfuchsia };
+    BnMicrofuchsiaService::new_binder(service, BinderFeatures::default())
+}
+
+impl Interface for MicrofuchsiaService {}
+
+impl IMicrofuchsiaService for MicrofuchsiaService {}
diff --git a/tests/ComposHostTestCases/java/android/compos/test/ComposTestCase.java b/tests/ComposHostTestCases/java/android/compos/test/ComposTestCase.java
index 7a35829..6e583c0 100644
--- a/tests/ComposHostTestCases/java/android/compos/test/ComposTestCase.java
+++ b/tests/ComposHostTestCases/java/android/compos/test/ComposTestCase.java
@@ -200,6 +200,7 @@
                                 10000,
                                 validator.getAbsolutePath(),
                                 "dice-chain",
+                                "--allow-any-mode",
                                 bcc_file.getAbsolutePath());
         assertWithMessage("hwtrust failed").about(command_results()).that(result).isSuccess();
     }
diff --git a/tests/authfs/common/src/open_then_run.rs b/tests/authfs/common/src/open_then_run.rs
index e5e33eb..a9004b0 100644
--- a/tests/authfs/common/src/open_then_run.rs
+++ b/tests/authfs/common/src/open_then_run.rs
@@ -24,7 +24,7 @@
 use log::{debug, error};
 use std::fs::OpenOptions;
 use std::os::unix::fs::OpenOptionsExt;
-use std::os::unix::io::{AsRawFd, OwnedFd, RawFd};
+use std::os::unix::io::{OwnedFd, RawFd};
 use std::process::Command;
 
 // `PseudoRawFd` is just an integer and not necessarily backed by a real FD. It is used to denote
@@ -38,8 +38,8 @@
 }
 
 impl OwnedFdMapping {
-    fn as_fd_mapping(&self) -> FdMapping {
-        FdMapping { parent_fd: self.owned_fd.as_raw_fd(), child_fd: self.target_fd }
+    fn into_fd_mapping(self) -> FdMapping {
+        FdMapping { parent_fd: self.owned_fd, child_fd: self.target_fd }
     }
 }
 
@@ -148,9 +148,9 @@
 
     // Set up FD mappings in the child process.
     let mut fd_mappings = Vec::new();
-    fd_mappings.extend(args.ro_file_fds.iter().map(OwnedFdMapping::as_fd_mapping));
-    fd_mappings.extend(args.rw_file_fds.iter().map(OwnedFdMapping::as_fd_mapping));
-    fd_mappings.extend(args.dir_fds.iter().map(OwnedFdMapping::as_fd_mapping));
+    fd_mappings.extend(args.ro_file_fds.into_iter().map(OwnedFdMapping::into_fd_mapping));
+    fd_mappings.extend(args.rw_file_fds.into_iter().map(OwnedFdMapping::into_fd_mapping));
+    fd_mappings.extend(args.dir_fds.into_iter().map(OwnedFdMapping::into_fd_mapping));
     command.fd_mappings(fd_mappings)?;
 
     debug!("Spawning {:?}", command);
diff --git a/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java b/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
index ec1a553..0e59a01 100644
--- a/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
+++ b/tests/benchmark/src/java/com/android/microdroid/benchmark/MicrodroidBenchmarks.java
@@ -268,6 +268,7 @@
                 /* fullDebug */ false,
                 (builder) -> builder.setCpuTopology(CPU_TOPOLOGY_ONE_CPU));
     }
+
     @Test
     public void testMicrodroidHostCpuTopologyBootTime()
             throws VirtualMachineException, InterruptedException, IOException {
@@ -280,10 +281,7 @@
     @Test
     public void testMicrodroidDebugBootTime()
             throws VirtualMachineException, InterruptedException, IOException {
-        runBootTimeTest(
-                "test_vm_boot_time_debug",
-                /* fullDebug */ true,
-                (builder) -> builder);
+        runBootTimeTest("test_vm_boot_time_debug", /* fullDebug */ true, (builder) -> builder);
     }
 
     private void testMicrodroidDebugBootTime_withVendorBase(File vendorDiskImage) throws Exception {
@@ -366,12 +364,12 @@
 
     @Test
     public void testVirtioBlkSeqReadRate() throws Exception {
-        testVirtioBlkReadRate(/*isRand=*/ false);
+        testVirtioBlkReadRate(/* isRand= */ false);
     }
 
     @Test
     public void testVirtioBlkRandReadRate() throws Exception {
-        testVirtioBlkReadRate(/*isRand=*/ true);
+        testVirtioBlkReadRate(/* isRand= */ true);
     }
 
     private void testVirtioBlkReadRate(boolean isRand) throws Exception {
diff --git a/tests/benchmark_hostside/java/android/avf/test/AVFHostTestCase.java b/tests/benchmark_hostside/java/android/avf/test/AVFHostTestCase.java
index 4a61016..e2956f2 100644
--- a/tests/benchmark_hostside/java/android/avf/test/AVFHostTestCase.java
+++ b/tests/benchmark_hostside/java/android/avf/test/AVFHostTestCase.java
@@ -69,6 +69,7 @@
 
     /** Boot time test related variables */
     private static final int REINSTALL_APEX_RETRY_INTERVAL_MS = 5 * 1000;
+
     private static final int REINSTALL_APEX_TIMEOUT_SEC = 15;
     private static final int COMPILE_STAGED_APEX_RETRY_INTERVAL_MS = 10 * 1000;
     private static final int COMPILE_STAGED_APEX_TIMEOUT_SEC = 540;
@@ -122,17 +123,18 @@
 
     @Test
     public void testNoLongHypSections() throws Exception {
-        String[] hypEvents = {
-            "hyp_enter", "hyp_exit"
-        };
+        String[] hypEvents = {"hyp_enter", "hyp_exit"};
 
-        assumeTrue("Skip without hypervisor tracing",
-            KvmHypTracer.isSupported(getDevice(), hypEvents));
+        assumeTrue(
+                "Skip without hypervisor tracing",
+                KvmHypTracer.isSupported(getDevice(), hypEvents));
 
         KvmHypTracer tracer = new KvmHypTracer(getDevice(), hypEvents);
         String result = tracer.run(COMPOSD_CMD_BIN + " test-compile");
         assertWithMessage("Failed to test compilation VM.")
-                .that(result).ignoringCase().contains("all ok");
+                .that(result)
+                .ignoringCase()
+                .contains("all ok");
 
         SimpleStats stats = tracer.getDurationStats();
         reportMetric(stats.getData(), "hyp_sections", "s");
@@ -141,32 +143,37 @@
 
     @Test
     public void testPsciMemProtect() throws Exception {
-        String[] hypEvents = {
-            "psci_mem_protect"
-        };
+        String[] hypEvents = {"psci_mem_protect"};
 
-        assumeTrue("Skip without hypervisor tracing",
-            KvmHypTracer.isSupported(getDevice(), hypEvents));
+        assumeTrue(
+                "Skip without hypervisor tracing",
+                KvmHypTracer.isSupported(getDevice(), hypEvents));
         KvmHypTracer tracer = new KvmHypTracer(getDevice(), hypEvents);
 
         /* We need to wait for crosvm to die so all the VM pages are reclaimed */
         String result = tracer.run(COMPOSD_CMD_BIN + " test-compile && killall -w crosvm || true");
         assertWithMessage("Failed to test compilation VM.")
-                .that(result).ignoringCase().contains("all ok");
+                .that(result)
+                .ignoringCase()
+                .contains("all ok");
 
         List<Integer> values = tracer.getPsciMemProtect();
 
         assertWithMessage("PSCI MEM_PROTECT events not recorded")
-            .that(values.size()).isGreaterThan(2);
+                .that(values.size())
+                .isGreaterThan(2);
 
         assertWithMessage("PSCI MEM_PROTECT counter not starting from 0")
-            .that(values.get(0)).isEqualTo(0);
+                .that(values.get(0))
+                .isEqualTo(0);
 
         assertWithMessage("PSCI MEM_PROTECT counter not ending with 0")
-            .that(values.get(values.size() - 1)).isEqualTo(0);
+                .that(values.get(values.size() - 1))
+                .isEqualTo(0);
 
         assertWithMessage("PSCI MEM_PROTECT counter didn't increment")
-            .that(Collections.max(values)).isGreaterThan(0);
+                .that(Collections.max(values))
+                .isGreaterThan(0);
     }
 
     @Test
@@ -182,9 +189,7 @@
 
     @Test
     public void testSettingsAppStartupTime() throws Exception {
-        String[] launchIntentPackages = {
-            "com.android.settings"
-        };
+        String[] launchIntentPackages = {"com.android.settings"};
         String launchIntentPackage = findSupportedPackage(launchIntentPackages);
         assume().withMessage("No supported settings package").that(launchIntentPackage).isNotNull();
         appStartupHelper(launchIntentPackage);
@@ -193,28 +198,34 @@
     private void appStartupHelper(String launchIntentPackage) throws Exception {
         assumeTrue(
                 "Skip on non-protected VMs",
-                ((TestDevice) getDevice()).supportsMicrodroid(/*protectedVm=*/ true));
+                ((TestDevice) getDevice()).supportsMicrodroid(/* protectedVm= */ true));
 
         StartupTimeMetricCollection mCollection =
                 new StartupTimeMetricCollection(getPackageName(launchIntentPackage), ROUND_COUNT);
         getAppStartupTime(launchIntentPackage, mCollection);
 
-        reportMetric(mCollection.mAppBeforeVmRunTotalTime,
+        reportMetric(
+                mCollection.mAppBeforeVmRunTotalTime,
                 "app_startup/" + mCollection.getPkgName() + "/total_time/before_vm",
                 "ms");
-        reportMetric(mCollection.mAppBeforeVmRunWaitTime,
+        reportMetric(
+                mCollection.mAppBeforeVmRunWaitTime,
                 "app_startup/" + mCollection.getPkgName() + "/wait_time/before_vm",
                 "ms");
-        reportMetric(mCollection.mAppDuringVmRunTotalTime,
+        reportMetric(
+                mCollection.mAppDuringVmRunTotalTime,
                 "app_startup/" + mCollection.getPkgName() + "/total_time/during_vm",
                 "ms");
-        reportMetric(mCollection.mAppDuringVmRunWaitTime,
+        reportMetric(
+                mCollection.mAppDuringVmRunWaitTime,
                 "app_startup/" + mCollection.getPkgName() + "/wait_time/during_vm",
                 "ms");
-        reportMetric(mCollection.mAppAfterVmRunTotalTime,
+        reportMetric(
+                mCollection.mAppAfterVmRunTotalTime,
                 "app_startup/" + mCollection.getPkgName() + "/total_time/after_vm",
                 "ms");
-        reportMetric(mCollection.mAppAfterVmRunWaitTime,
+        reportMetric(
+                mCollection.mAppAfterVmRunWaitTime,
                 "app_startup/" + mCollection.getPkgName() + "/wait_time/after_vm",
                 "ms");
     }
@@ -234,8 +245,9 @@
 
         for (String pkgName : pkgNameList) {
             String appPkg = getPackageName(pkgName);
-            String hasPackage = android.run("pm list package | grep -w " + appPkg + " 1> /dev/null"
-                    + "; echo $?");
+            String hasPackage =
+                    android.run(
+                            "pm list package | grep -w " + appPkg + " 1> /dev/null" + "; echo $?");
             assertNotNull(hasPackage);
 
             if (hasPackage.equals("0")) {
@@ -390,8 +402,8 @@
         }
     }
 
-    private int getFreeMemoryInfoMb(CommandRunner android) throws DeviceNotAvailableException,
-            IllegalArgumentException {
+    private int getFreeMemoryInfoMb(CommandRunner android)
+            throws DeviceNotAvailableException, IllegalArgumentException {
         int freeMemory = 0;
         String content = android.runForResult("cat /proc/meminfo").getStdout().trim();
         String[] lines = content.split("[\r\n]+");
@@ -410,8 +422,8 @@
             throws DeviceNotAvailableException, InterruptedException {
         android.run("input keyevent", "KEYCODE_WAKEUP");
         Thread.sleep(500);
-        final String ret = android.runForResult("dumpsys nfc | grep 'mScreenState='")
-                .getStdout().trim();
+        final String ret =
+                android.runForResult("dumpsys nfc | grep 'mScreenState='").getStdout().trim();
         if (ret != null && ret.contains("ON_LOCKED")) {
             android.run("input keyevent", "KEYCODE_MENU");
         }
@@ -429,8 +441,9 @@
                 String[] bootKeyVal = bootLoaderPhase.split(":");
                 String key = String.format("%s%s", BOOTLOADER_PREFIX, bootKeyVal[0]);
 
-                bootloaderTime.computeIfAbsent(key,
-                        k -> new ArrayList<>()).add(Double.parseDouble(bootKeyVal[1]));
+                bootloaderTime
+                        .computeIfAbsent(key, k -> new ArrayList<>())
+                        .add(Double.parseDouble(bootKeyVal[1]));
                 // SW is the time spent on the warning screen. So ignore it in
                 // final boot time calculation.
                 if (BOOTLOADER_PHASE_SW.equalsIgnoreCase(bootKeyVal[0])) {
@@ -438,8 +451,9 @@
                 }
                 bootLoaderTotalTime += Double.parseDouble(bootKeyVal[1]);
             }
-            bootloaderTime.computeIfAbsent(BOOTLOADER_TIME,
-                    k -> new ArrayList<>()).add(bootLoaderTotalTime);
+            bootloaderTime
+                    .computeIfAbsent(BOOTLOADER_TIME, k -> new ArrayList<>())
+                    .add(bootLoaderTotalTime);
         }
     }
 
@@ -518,7 +532,9 @@
                         android.runWithTimeout(
                                 3 * 60 * 1000, COMPOSD_CMD_BIN + " staged-apex-compile");
                 assertWithMessage("Failed to compile staged APEX. Reason: " + result)
-                    .that(result).ignoringCase().contains("all ok");
+                        .that(result)
+                        .ignoringCase()
+                        .contains("all ok");
 
                 CLog.i("Success to compile staged APEX. Result: " + result);
 
@@ -546,22 +562,23 @@
             try {
                 CommandRunner android = new CommandRunner(getDevice());
 
-                String packagesOutput =
-                        android.run("pm list packages -f --apex-only");
+                String packagesOutput = android.run("pm list packages -f --apex-only");
 
-                Pattern p = Pattern.compile(
-                        "package:(.*)=(com(?:\\.google)?\\.android\\.art)$", Pattern.MULTILINE);
+                Pattern p =
+                        Pattern.compile(
+                                "package:(.*)=(com(?:\\.google)?\\.android\\.art)$",
+                                Pattern.MULTILINE);
                 Matcher m = p.matcher(packagesOutput);
                 assertWithMessage("ART module not found. Packages are:\n" + packagesOutput)
-                    .that(m.find())
-                    .isTrue();
+                        .that(m.find())
+                        .isTrue();
 
                 String artApexPath = m.group(1);
 
-                CommandResult result = android.runForResult(
-                        "pm install --apex " + artApexPath);
+                CommandResult result = android.runForResult("pm install --apex " + artApexPath);
                 assertWithMessage("Failed to install APEX. Reason: " + result)
-                    .that(result.getExitCode()).isEqualTo(0);
+                        .that(result.getExitCode())
+                        .isEqualTo(0);
 
                 CLog.i("Success to install APEX. Result: " + result);
 
diff --git a/tests/ferrochrome/Android.bp b/tests/ferrochrome/Android.bp
index f165b8f..f1b7f27 100644
--- a/tests/ferrochrome/Android.bp
+++ b/tests/ferrochrome/Android.bp
@@ -19,8 +19,7 @@
     name: "ferrochrome-tests.sh",
     srcs: ["ferrochrome.sh"],
     out: ["ferrochrome-tests"],
-    // This breaks shebang, but test will execute the script with bash
-    cmd: "echo \"set -x\" > $(out); cat $(in) >> $(out)",
+    cmd: "sed '2 i set -x' $(in) > $(out)",
 }
 
 sh_binary_host {
diff --git a/tests/ferrochrome/AndroidTest.xml b/tests/ferrochrome/AndroidTest.xml
index 9eaaed3..6c975be 100644
--- a/tests/ferrochrome/AndroidTest.xml
+++ b/tests/ferrochrome/AndroidTest.xml
@@ -35,13 +35,19 @@
         <option name="run-command" value="mkdir /data/local/tmp" />
         <option name="teardown-command" value="pkill vmlauncher" />
         <option name="teardown-command" value="rm /data/local/tmp/chromiumos_base_image.bin" />
+        <option name="teardown-command" value="rm -rf /data/local/tmp/ferrochrome_screenshots" />
     </target_preparer>
 
-    <test class="com.android.tradefed.testtype.binary.ExecutableHostTest" >
+    <test class="com.android.tradefed.testtype.binary.ExecutableHostTest">
         <option name="binary" value="ferrochrome-tests" />
         <option name="relative-path-execution" value="true" />
         <option name="runtime-hint" value="10m" />
         <option name="per-binary-timeout" value="20m" />
     </test>
+
+    <metrics_collector class="com.android.tradefed.device.metric.FilePullerLogCollector">
+        <option name="directory-keys" value="/data/local/tmp/ferrochrome_screenshots" />
+        <option name="collect-on-run-ended-only" value="true" />
+    </metrics_collector>
 </configuration>
 
diff --git a/tests/ferrochrome/assets/vm_config.json b/tests/ferrochrome/assets/vm_config.json
index 3053626..53e3b72 100644
--- a/tests/ferrochrome/assets/vm_config.json
+++ b/tests/ferrochrome/assets/vm_config.json
@@ -7,14 +7,32 @@
             "writable": true
         }
     ],
+    "protected": false,
+    "cpu_topology": "match_host",
+    "platform_version": "~1.0",
+    "memory_mib": 8096,
+    "debuggable": true,
+    "console_out": true,
+    "connect_console": true,
+    "console_input_device": "hvc0",
+    "network": true,
+    "input": {
+        "touchscreen": true,
+        "keyboard": true,
+        "mouse": true,
+        "trackpad": true,
+        "switches": true
+    },
+    "audio": {
+        "speaker": true,
+        "microphone": true
+    },
     "gpu": {
         "backend": "virglrenderer",
         "context_types": ["virgl2"]
     },
-    "params": "root=/dev/vda3 rootwait noinitrd ro enforcing=0 cros_debug cros_secure",
-    "protected": false,
-    "cpu_topology": "match_host",
-    "platform_version": "~1.0",
-    "memory_mib" : 8096,
-    "console_input_device": "hvc0"
+    "display": {
+        "scale": "0.77",
+        "refresh_rate": "30"
+    }
 }
diff --git a/tests/ferrochrome/ferrochrome.sh b/tests/ferrochrome/ferrochrome.sh
index 683b82e..03630dd 100755
--- a/tests/ferrochrome/ferrochrome.sh
+++ b/tests/ferrochrome/ferrochrome.sh
@@ -21,12 +21,13 @@
 
 FECR_GS_URL="https://storage.googleapis.com/chromiumos-image-archive/ferrochrome-public"
 FECR_DEFAULT_VERSION="R128-15958.0.0"
+FECR_DEFAULT_SCREENSHOT_DIR="/data/local/tmp/ferrochrome_screenshots"  # Hardcoded at AndroidTest.xml
 FECR_TEST_IMAGE="chromiumos_test_image"
 FECR_BASE_IMAGE="chromiumos_base_image"
 FECR_DEVICE_DIR="/data/local/tmp"
 FECR_IMAGE_VM_CONFIG_JSON="chromiumos_base_image.bin"  # hardcoded at vm_config.json
 FECR_CONFIG_PATH="/data/local/tmp/vm_config.json"  # hardcoded at VmLauncherApp
-FECR_CONSOLE_LOG_PATH="/data/data/\${pkg_name}/files/console.log"
+FECR_CONSOLE_LOG_PATH="files/cros.log" # log file name is ${vm_name}.log
 FECR_TEST_IMAGE_BOOT_COMPLETED_LOG="Have fun and send patches!"
 FECR_BASE_IMAGE_BOOT_COMPLETED_LOG="Chrome started, our work is done, exiting"
 FECR_BOOT_TIMEOUT="300" # 5 minutes (300 seconds)
@@ -64,6 +65,7 @@
   echo "  --version \${version}: ferrochrome version to be downloaded"
   echo "  --keep: Keep downloaded ferrochrome image"
   echo "  --test: Download test image instead"
+  echo "  --forever: Keep ferrochrome running forever. Used for manual test"
 }
 
 fecr_version="${FECR_DEFAULT_VERSION}"
@@ -74,6 +76,8 @@
 fecr_verbose=""
 fecr_image="${FECR_DEFAULT_IMAGE}"
 fecr_boot_completed_log="${FECR_DEFAULT_BOOT_COMPLETED_LOG}"
+fecr_screenshot_dir="${FECR_DEFAULT_SCREENSHOT_DIR}"
+fecr_forever=""
 
 # Parse parameters
 while (( "${#}" )); do
@@ -100,6 +104,9 @@
       fecr_image="${FECR_TEST_IMAGE}"
       fecr_boot_completed_log="${FECR_TEST_IMAGE_BOOT_COMPLETED_LOG}"
       ;;
+    --forever)
+      fecr_forever="true"
+      ;;
     -h|--help)
       print_usage
       exit 0
@@ -129,9 +136,12 @@
 fi
 
 pkg_name=$(dirname ${resolved_activities})
+current_user=$(adb shell am get-current-user)
 
-adb shell pm grant ${pkg_name} android.permission.USE_CUSTOM_VIRTUAL_MACHINE > /dev/null
-adb shell pm clear ${pkg_name} > /dev/null
+echo "Reset app & granting permission"
+adb shell pm clear --user ${current_user} ${pkg_name} > /dev/null
+adb shell pm grant --user ${current_user} ${pkg_name} android.permission.RECORD_AUDIO
+adb shell pm grant --user ${current_user} ${pkg_name} android.permission.USE_CUSTOM_VIRTUAL_MACHINE > /dev/null
 
 if [[ -z "${fecr_skip}" ]]; then
   if [[ -z "${fecr_dir}" ]]; then
@@ -156,21 +166,27 @@
 echo "Starting ferrochrome"
 adb shell am start-activity -a ${ACTION_NAME} > /dev/null
 
-if [[ $(adb shell getprop ro.fw.mu.headless_system_user) == "true" ]]; then
-  current_user=$(adb shell am get-current-user)
-  log_path="/data/user/${current_user}/${pkg_name}/files/console.log"
-else
-  log_path="/data/data/${pkg_name}/files/console.log"
-fi
+# HSUM aware log path
+log_path="/data/user/${current_user}/${pkg_name}/${FECR_CONSOLE_LOG_PATH}"
 fecr_start_time=${EPOCHSECONDS}
 
-while [[ $((EPOCHSECONDS - fecr_start_time)) -lt ${FECR_BOOT_TIMEOUT} ]]; do
-  adb shell grep -soF \""${fecr_boot_completed_log}"\" "${log_path}" && exit 0 || true
-  sleep 10
-done
+echo "Check ${log_path} on device for console log"
 
->&2 echo "Ferrochrome failed to boot. Dumping console log"
->&2 adb shell cat ${log_path}
+if [[ "${fecr_forever}" == "true" ]]; then
+  echo "Ctrl+C to stop running"
+  echo "To open interactive serial console, use following command:"
+  echo "adb shell -t /apex/com.android.virt/bin/vm console"
+else
+  adb shell mkdir -p "${fecr_screenshot_dir}"
+  while [[ $((EPOCHSECONDS - fecr_start_time)) -lt ${FECR_BOOT_TIMEOUT} ]]; do
+    adb shell screencap -p "${fecr_screenshot_dir}/screenshot-${EPOCHSECONDS}.png"
+    adb shell grep -soF \""${fecr_boot_completed_log}"\" "${log_path}" && exit 0 || true
+    sleep 10
+  done
 
-exit 1
+  >&2 echo "Ferrochrome failed to boot. Dumping console log"
+  >&2 adb shell cat ${log_path}
+
+  exit 1
+fi
 
diff --git a/tests/helper/src/java/com/android/microdroid/test/common/MetricsProcessor.java b/tests/helper/src/java/com/android/microdroid/test/common/MetricsProcessor.java
index dd68d6a..8f93d1e 100644
--- a/tests/helper/src/java/com/android/microdroid/test/common/MetricsProcessor.java
+++ b/tests/helper/src/java/com/android/microdroid/test/common/MetricsProcessor.java
@@ -28,8 +28,8 @@
 
     public static String getMetricPrefix(String debugTag) {
         return "avf_perf"
-            + ((debugTag != null && !debugTag.isEmpty()) ? "[" + debugTag + "]" : "")
-            + "/";
+                + ((debugTag != null && !debugTag.isEmpty()) ? "[" + debugTag + "]" : "")
+                + "/";
     }
 
     public MetricsProcessor(String prefix) {
@@ -41,8 +41,8 @@
      * a {@link Map} with the corresponding keys equal to [mPrefix + name +
      * _[min|max|average|stdev]_ + unit].
      */
-    public Map<String, Double> computeStats(List<? extends Number> metrics, String name,
-            String unit) {
+    public Map<String, Double> computeStats(
+            List<? extends Number> metrics, String name, String unit) {
         List<Double> values = new ArrayList<>(metrics.size());
         for (Number metric : metrics) {
             values.add(metric.doubleValue());
diff --git a/tests/helper/src/java/com/android/microdroid/test/common/ProcessUtil.java b/tests/helper/src/java/com/android/microdroid/test/common/ProcessUtil.java
index e058674..c4aba81 100644
--- a/tests/helper/src/java/com/android/microdroid/test/common/ProcessUtil.java
+++ b/tests/helper/src/java/com/android/microdroid/test/common/ProcessUtil.java
@@ -69,13 +69,13 @@
     }
 
     /** Gets global memory metrics key and values mapping */
-    public static Map<String, Long> getProcessMemoryMap(
-            Function<String, String> shellExecutor) throws IOException {
+    public static Map<String, Long> getProcessMemoryMap(Function<String, String> shellExecutor)
+            throws IOException {
         // The input file of parseMemoryInfo need a header string as the key of output entries.
         // /proc/meminfo doesn't have this line so add one as the key.
         String header = "device memory info\n";
-        List<SMapEntry> entries = parseMemoryInfo(header
-                + shellExecutor.apply("cat /proc/meminfo"));
+        List<SMapEntry> entries =
+                parseMemoryInfo(header + shellExecutor.apply("cat /proc/meminfo"));
         if (entries.size() != 1) {
             throw new RuntimeException(
                     "expected one entry in /proc/meminfo, got " + entries.size());
diff --git a/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java b/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
index 8169376..135d947 100644
--- a/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
+++ b/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
@@ -108,15 +108,15 @@
     protected final void grantPermission(String permission) {
         Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
         UiAutomation uiAutomation = instrumentation.getUiAutomation();
-        uiAutomation.grantRuntimePermission(instrumentation.getContext().getPackageName(),
-                permission);
+        uiAutomation.grantRuntimePermission(
+                instrumentation.getContext().getPackageName(), permission);
     }
 
     protected final void revokePermission(String permission) {
         Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation();
         UiAutomation uiAutomation = instrumentation.getUiAutomation();
-        uiAutomation.revokeRuntimePermission(instrumentation.getContext().getPackageName(),
-                permission);
+        uiAutomation.revokeRuntimePermission(
+                instrumentation.getContext().getPackageName(), permission);
     }
 
     protected final void setMaxPerformanceTaskProfile() throws IOException {
@@ -233,12 +233,11 @@
     }
 
     protected void assumeVsrCompliant() {
-        boolean featureCheck = mCtx.getPackageManager().hasSystemFeature(FEATURE_WATCH) ||
-                               mCtx.getPackageManager().hasSystemFeature(FEATURE_AUTOMOTIVE) ||
-                               mCtx.getPackageManager().hasSystemFeature(FEATURE_LEANBACK);
-        assume().withMessage("This device is not VSR compliant")
-                .that(featureCheck)
-                .isFalse();
+        boolean featureCheck =
+                mCtx.getPackageManager().hasSystemFeature(FEATURE_WATCH)
+                        || mCtx.getPackageManager().hasSystemFeature(FEATURE_AUTOMOTIVE)
+                        || mCtx.getPackageManager().hasSystemFeature(FEATURE_LEANBACK);
+        assume().withMessage("This device is not VSR compliant").that(featureCheck).isFalse();
     }
 
     protected boolean isGsi() {
@@ -256,8 +255,9 @@
 
         // Cuttlefish/Goldfish on Arm 64 doesn't and cannot support any form of virtualization,
         // so there's no point running any of these tests.
-        assume().withMessage("Virtualization not supported on Arm64 Cuttlefish/Goldfish."
-                + " b/341889915")
+        assume().withMessage(
+                        "Virtualization not supported on Arm64 Cuttlefish/Goldfish."
+                                + " b/341889915")
                 .that(isCuttlefishArm64() || isGoldfishArm64())
                 .isFalse();
     }
@@ -288,7 +288,8 @@
             if (log.contains("Run /init as init process") && !mInitStartedNanoTime.isPresent()) {
                 mInitStartedNanoTime = OptionalLong.of(System.nanoTime());
             }
-            if (log.contains("microdroid_manager") && log.contains("executing main task")
+            if (log.contains("microdroid_manager")
+                    && log.contains("executing main task")
                     && !mPayloadStartedNanoTime.isPresent()) {
                 mPayloadStartedNanoTime = OptionalLong.of(System.nanoTime());
             }
diff --git a/tests/hostside/helper/java/com/android/microdroid/test/host/CommandResultSubject.java b/tests/hostside/helper/java/com/android/microdroid/test/host/CommandResultSubject.java
index 2e9d078..1d292eb 100644
--- a/tests/hostside/helper/java/com/android/microdroid/test/host/CommandResultSubject.java
+++ b/tests/hostside/helper/java/com/android/microdroid/test/host/CommandResultSubject.java
@@ -25,9 +25,7 @@
 import com.google.common.truth.StringSubject;
 import com.google.common.truth.Subject;
 
-/**
- * A <a href="https://github.com/google/truth">Truth</a> subject for {@link CommandResult}.
- */
+/** A <a href="https://github.com/google/truth">Truth</a> subject for {@link CommandResult}. */
 public class CommandResultSubject extends Subject {
     private final CommandResult mActual;
 
diff --git a/tests/hostside/helper/java/com/android/microdroid/test/host/KvmHypTracer.java b/tests/hostside/helper/java/com/android/microdroid/test/host/KvmHypTracer.java
index 5c72358..3814cdd 100644
--- a/tests/hostside/helper/java/com/android/microdroid/test/host/KvmHypTracer.java
+++ b/tests/hostside/helper/java/com/android/microdroid/test/host/KvmHypTracer.java
@@ -17,22 +17,23 @@
 package com.android.microdroid.test.host;
 
 import static com.google.common.truth.Truth.assertWithMessage;
+
 import static org.junit.Assert.assertNotNull;
 
-import com.android.microdroid.test.host.CommandRunner;
 import com.android.tradefed.device.ITestDevice;
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.util.SimpleStats;
 
+import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileReader;
-import java.io.BufferedReader;
 import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.List;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
+
 import javax.annotation.Nonnull;
 
 class KvmHypEvent {
@@ -42,16 +43,14 @@
     public final String args;
     public final boolean valid;
 
-    private static final Pattern LOST_EVENT_PATTERN = Pattern.compile(
-            "^CPU:[0-9]* \\[LOST ([0-9]*) EVENTS\\]");
+    private static final Pattern LOST_EVENT_PATTERN =
+            Pattern.compile("^CPU:[0-9]* \\[LOST ([0-9]*) EVENTS\\]");
 
     public KvmHypEvent(String str) {
         Matcher matcher = LOST_EVENT_PATTERN.matcher(str);
-        if (matcher.find())
-            throw new OutOfMemoryError("Lost " + matcher.group(1) + " events");
+        if (matcher.find()) throw new OutOfMemoryError("Lost " + matcher.group(1) + " events");
 
-        Pattern pattern = Pattern.compile(
-                "^\\[([0-9]*)\\][ \t]*([0-9]*\\.[0-9]*): (\\S+) (.*)");
+        Pattern pattern = Pattern.compile("^\\[([0-9]*)\\][ \t]*([0-9]*\\.[0-9]*): (\\S+) (.*)");
 
         matcher = pattern.matcher(str);
         if (!matcher.find()) {
@@ -72,8 +71,7 @@
     }
 
     public String toString() {
-        return String.format(
-                "[%03d]\t%f: %s %s", cpu, timestamp, name, args);
+        return String.format("[%03d]\t%f: %s %s", cpu, timestamp, name, args);
     }
 }
 
@@ -99,16 +97,16 @@
     }
 
     public static boolean isSupported(ITestDevice device, String[] events) throws Exception {
-        for (String event: events) {
-            if (!device.doesFileExist(HYP_TRACING_ROOT + eventDir(event) + "/enable"))
-                return false;
+        for (String event : events) {
+            if (!device.doesFileExist(HYP_TRACING_ROOT + eventDir(event) + "/enable")) return false;
         }
         return true;
     }
 
     public KvmHypTracer(@Nonnull ITestDevice device, String[] events) throws Exception {
         assertWithMessage("Hypervisor events " + String.join(",", events) + " not supported")
-            .that(isSupported(device, events)).isTrue();
+                .that(isSupported(device, events))
+                .isTrue();
 
         mDevice = device;
         mRunner = new CommandRunner(mDevice);
@@ -123,8 +121,7 @@
         setNode("tracing_on", 0);
         mRunner.run("echo 0 | tee " + HYP_TRACING_ROOT + "events/*/*/enable");
         setNode("buffer_size_kb", DEFAULT_BUF_SIZE_KB);
-        for (String event: mHypEvents)
-            setNode(eventDir(event) + "/enable", 1);
+        for (String event : mHypEvents) setNode(eventDir(event) + "/enable", 1);
         setNode("trace", 0);
 
         /* Cat each per-cpu trace_pipe in its own tmp file in the background */
@@ -147,8 +144,10 @@
 
         /* Wait for cat to finish reading the pipe interface before killing it */
         for (int i = 0; i < mNrCpus; i++) {
-            cmd += "while $(test '$(ps -o S -p $CPU" + i
-                + "_TRACE_PIPE_PID | tail -n 1)' = 'R'); do sleep 1; done;";
+            cmd +=
+                    "while $(test '$(ps -o S -p $CPU"
+                            + i
+                            + "_TRACE_PIPE_PID | tail -n 1)' = 'R'); do sleep 1; done;";
             cmd += "kill -9 $CPU" + i + "_TRACE_PIPE_PID;";
         }
         cmd += "wait";
@@ -164,7 +163,7 @@
 
         mRunner.run("rm -f " + cmd_script);
 
-        for (String t: trace_pipes) {
+        for (String t : trace_pipes) {
             File trace = mDevice.pullFile(t);
             assertNotNull(trace);
             mTraces.add(trace);
@@ -190,12 +189,10 @@
         KvmHypEvent event;
         String l;
 
-        if ((l = br.readLine()) == null)
-            return null;
+        if ((l = br.readLine()) == null) return null;
 
         event = new KvmHypEvent(l);
-        if (!event.valid)
-            return null;
+        if (!event.valid) return null;
 
         return event;
     }
@@ -205,9 +202,10 @@
         SimpleStats stats = new SimpleStats();
 
         assertWithMessage("KvmHypTracer() is missing events " + String.join(",", reqEvents))
-            .that(hasEvents(reqEvents)).isTrue();
+                .that(hasEvents(reqEvents))
+                .isTrue();
 
-        for (File trace: mTraces) {
+        for (File trace : mTraces) {
             BufferedReader br = new BufferedReader(new FileReader(trace));
             double last = 0.0, hyp_enter = 0.0;
             String prev_event = "";
@@ -219,20 +217,18 @@
                     throw new ParseException("Incorrect CPU number: " + cpu, 0);
 
                 double cur = hypEvent.timestamp;
-                if (cur < last)
-                    throw new ParseException("Time must not go backward: " + cur, 0);
+                if (cur < last) throw new ParseException("Time must not go backward: " + cur, 0);
                 last = cur;
 
                 String event = hypEvent.name;
                 if (event.equals(prev_event)) {
-                    throw new ParseException("Hyp event found twice in a row: " +
-                                             trace + " - " + hypEvent, 0);
+                    throw new ParseException(
+                            "Hyp event found twice in a row: " + trace + " - " + hypEvent, 0);
                 }
 
                 switch (event) {
                     case "hyp_exit":
-                        if (prev_event.equals("hyp_enter"))
-                            stats.add(cur - hyp_enter);
+                        if (prev_event.equals("hyp_enter")) stats.add(cur - hyp_enter);
                         break;
                     case "hyp_enter":
                         hyp_enter = cur;
@@ -252,7 +248,8 @@
         List<Integer> psciMemProtect = new ArrayList<>();
 
         assertWithMessage("KvmHypTracer() is missing events " + String.join(",", reqEvents))
-            .that(hasEvents(reqEvents)).isTrue();
+                .that(hasEvents(reqEvents))
+                .isTrue();
 
         BufferedReader[] brs = new BufferedReader[mTraces.size()];
         KvmHypEvent[] next = new KvmHypEvent[mTraces.size()];
@@ -266,22 +263,20 @@
             double oldest = Double.MAX_VALUE;
             int oldestIdx = -1;
 
-            for (int i = 0; i < mTraces.size(); i ++) {
+            for (int i = 0; i < mTraces.size(); i++) {
                 if ((next[i] != null) && (next[i].timestamp < oldest)) {
                     oldest = next[i].timestamp;
                     oldestIdx = i;
                 }
             }
 
-            if (oldestIdx < 0)
-                break;
+            if (oldestIdx < 0) break;
 
-            Pattern pattern = Pattern.compile(
-                "count=([0-9]*) was=([0-9]*)");
+            Pattern pattern = Pattern.compile("count=([0-9]*) was=([0-9]*)");
             Matcher matcher = pattern.matcher(next[oldestIdx].args);
             if (!matcher.find()) {
-                throw new ParseException("Unexpected psci_mem_protect event: " +
-                                         next[oldestIdx], 0);
+                throw new ParseException(
+                        "Unexpected psci_mem_protect event: " + next[oldestIdx], 0);
             }
 
             int count = Integer.parseInt(matcher.group(1));
diff --git a/tests/hostside/helper/java/com/android/microdroid/test/host/LogArchiver.java b/tests/hostside/helper/java/com/android/microdroid/test/host/LogArchiver.java
index 96ab543..ed753d0 100644
--- a/tests/hostside/helper/java/com/android/microdroid/test/host/LogArchiver.java
+++ b/tests/hostside/helper/java/com/android/microdroid/test/host/LogArchiver.java
@@ -27,15 +27,17 @@
 
 /** A helper class for archiving device log files to the host's tradefed output directory. */
 public abstract class LogArchiver {
-    /** Copy device log (then delete) to a tradefed output directory on the host.
+    /**
+     * Copy device log (then delete) to a tradefed output directory on the host.
      *
      * @param logs A {@link TestLogData} that needs to be owned by the actual test case.
      * @param device The device to pull the log file from.
      * @param remotePath The path on the device.
      * @param localName Local file name to be copied to.
      */
-    public static void archiveLogThenDelete(TestLogData logs, ITestDevice device, String remotePath,
-            String localName) throws DeviceNotAvailableException {
+    public static void archiveLogThenDelete(
+            TestLogData logs, ITestDevice device, String remotePath, String localName)
+            throws DeviceNotAvailableException {
         File logFile = device.pullFile(remotePath);
         if (logFile != null) {
             logs.addTestLog(localName, LogDataType.TEXT, new FileInputStreamSource(logFile));
diff --git a/tests/hostside/helper/java/com/android/microdroid/test/host/MicrodroidHostTestCaseBase.java b/tests/hostside/helper/java/com/android/microdroid/test/host/MicrodroidHostTestCaseBase.java
index cd90fbe..974a58c 100644
--- a/tests/hostside/helper/java/com/android/microdroid/test/host/MicrodroidHostTestCaseBase.java
+++ b/tests/hostside/helper/java/com/android/microdroid/test/host/MicrodroidHostTestCaseBase.java
@@ -67,8 +67,11 @@
     protected static final long MICRODROID_COMMAND_TIMEOUT_MILLIS = 30000;
     private static final long MICRODROID_COMMAND_RETRY_INTERVAL_MILLIS = 500;
     protected static final int MICRODROID_ADB_CONNECT_MAX_ATTEMPTS =
-            (int) (MICRODROID_ADB_CONNECT_TIMEOUT_MINUTES * 60 * 1000
-                / MICRODROID_COMMAND_RETRY_INTERVAL_MILLIS);
+            (int)
+                    (MICRODROID_ADB_CONNECT_TIMEOUT_MINUTES
+                            * 60
+                            * 1000
+                            / MICRODROID_COMMAND_RETRY_INTERVAL_MILLIS);
 
     protected static final Set<String> SUPPORTED_GKI_VERSIONS =
             Collections.unmodifiableSet(new HashSet(Arrays.asList("android15-6.6")));
@@ -148,8 +151,9 @@
                 isGsi && vendorApiLevel < 202404);
     }
 
-    public static void archiveLogThenDelete(TestLogData logs, ITestDevice device, String remotePath,
-            String localName) throws DeviceNotAvailableException {
+    public static void archiveLogThenDelete(
+            TestLogData logs, ITestDevice device, String remotePath, String localName)
+            throws DeviceNotAvailableException {
         LogArchiver.archiveLogThenDelete(logs, device, remotePath, localName);
     }
 
@@ -167,6 +171,7 @@
         CommandResult result = RunUtil.getDefault().runTimedCmd(timeout, cmd);
         return result.getStdout().trim();
     }
+
     private static String join(String... strs) {
         return String.join(" ", Arrays.asList(strs));
     }
@@ -197,8 +202,7 @@
         throw new AssertionError("Failed to find test file " + name + " for module " + moduleName);
     }
 
-    public String getPathForPackage(String packageName)
-            throws DeviceNotAvailableException {
+    public String getPathForPackage(String packageName) throws DeviceNotAvailableException {
         return getPathForPackage(getDevice(), packageName);
     }
 
@@ -210,7 +214,8 @@
         CommandRunner android = new CommandRunner(device);
         String pathLine = android.run("pm", "path", packageName);
         assertWithMessage("Package " + packageName + " not found")
-                .that(pathLine).startsWith("package:");
+                .that(pathLine)
+                .startsWith("package:");
         return pathLine.substring("package:".length());
     }
 
diff --git a/tests/hostside/java/com/android/microdroid/test/MicrodroidHostTests.java b/tests/hostside/java/com/android/microdroid/test/MicrodroidHostTests.java
index 80d1fc6..0f7be20 100644
--- a/tests/hostside/java/com/android/microdroid/test/MicrodroidHostTests.java
+++ b/tests/hostside/java/com/android/microdroid/test/MicrodroidHostTests.java
@@ -19,7 +19,6 @@
 import static com.android.microdroid.test.host.CommandResultSubject.command_results;
 import static com.android.tradefed.device.TestDevice.MicrodroidBuilder;
 import static com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestLogData;
-import com.android.tradefed.device.DeviceRuntimeException;
 
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
@@ -45,6 +44,7 @@
 import com.android.os.AtomsProto;
 import com.android.os.StatsLog;
 import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.DeviceRuntimeException;
 import com.android.tradefed.device.ITestDevice;
 import com.android.tradefed.device.TestDevice;
 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestMetrics;
@@ -79,13 +79,13 @@
 import java.util.Collections;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.concurrent.Callable;
 import java.util.concurrent.TimeUnit;
 import java.util.function.Function;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 import java.util.stream.Collectors;
-import java.util.Objects;
 
 @RunWith(DeviceJUnit4Parameterized.class)
 @UseParametersRunnerFactory(DeviceJUnit4ClassRunnerWithParameters.RunnerFactory.class)
@@ -490,6 +490,7 @@
                         .cpuTopology("match_host")
                         .protectedVm(true)
                         .gki(mGki)
+                        .name("protected_vm_runs_pvmfw")
                         .build(getAndroidDevice());
 
         // Assert
@@ -785,6 +786,7 @@
                         .cpuTopology("match_host")
                         .protectedVm(mProtectedVm)
                         .gki(mGki)
+                        .name("test_telemetry_pushed_atoms")
                         .build(device);
         microdroid.waitForBootComplete(BOOT_COMPLETE_TIMEOUT);
         device.shutdownMicrodroid(microdroid);
@@ -816,7 +818,8 @@
         assertThat(atomVmCreationRequested.getIsProtected()).isEqualTo(mProtectedVm);
         assertThat(atomVmCreationRequested.getCreationSucceeded()).isTrue();
         assertThat(atomVmCreationRequested.getBinderExceptionCode()).isEqualTo(0);
-        assertThat(atomVmCreationRequested.getVmIdentifier()).isEqualTo("VmRunApp");
+        assertThat(atomVmCreationRequested.getVmIdentifier())
+                .isEqualTo("test_telemetry_pushed_atoms");
         assertThat(atomVmCreationRequested.getConfigType())
                 .isEqualTo(AtomsProto.VmCreationRequested.ConfigType.VIRTUAL_MACHINE_APP_CONFIG);
         assertThat(atomVmCreationRequested.getNumCpus()).isEqualTo(getDeviceNumCpus(device));
@@ -826,11 +829,11 @@
 
         // Check VmBooted atom
         AtomsProto.VmBooted atomVmBooted = data.get(1).getAtom().getVmBooted();
-        assertThat(atomVmBooted.getVmIdentifier()).isEqualTo("VmRunApp");
+        assertThat(atomVmBooted.getVmIdentifier()).isEqualTo("test_telemetry_pushed_atoms");
 
         // Check VmExited atom
         AtomsProto.VmExited atomVmExited = data.get(2).getAtom().getVmExited();
-        assertThat(atomVmExited.getVmIdentifier()).isEqualTo("VmRunApp");
+        assertThat(atomVmExited.getVmIdentifier()).isEqualTo("test_telemetry_pushed_atoms");
         assertThat(atomVmExited.getDeathReason()).isEqualTo(AtomsProto.VmExited.DeathReason.KILLED);
         assertThat(atomVmExited.getExitSignal()).isEqualTo(9);
         // In CPU & memory related fields, check whether positive values are collected or not.
@@ -927,6 +930,7 @@
                         .memoryMib(minMemorySize())
                         .cpuTopology("match_host")
                         .protectedVm(mProtectedVm)
+                        .name("test_microdroid_boots")
                         .gki(mGki));
     }
 
@@ -940,6 +944,7 @@
                         .cpuTopology("match_host")
                         .protectedVm(mProtectedVm)
                         .gki(mGki)
+                        .name("test_microdroid_ram_usage")
                         .build(getAndroidDevice());
         mMicrodroidDevice.waitForBootComplete(BOOT_COMPLETE_TIMEOUT);
         mMicrodroidDevice.enableAdbRoot();
@@ -1205,6 +1210,7 @@
                         .protectedVm(mProtectedVm)
                         .gki(mGki)
                         .hugePages(true)
+                        .name("test_huge_pages")
                         .build(getAndroidDevice());
         mMicrodroidDevice.waitForBootComplete(BOOT_COMPLETE_TIMEOUT);
 
diff --git a/tests/pvmfw/helper/java/com/android/pvmfw/test/host/Pvmfw.java b/tests/pvmfw/helper/java/com/android/pvmfw/test/host/Pvmfw.java
index a77ba40..5ae5186 100644
--- a/tests/pvmfw/helper/java/com/android/pvmfw/test/host/Pvmfw.java
+++ b/tests/pvmfw/helper/java/com/android/pvmfw/test/host/Pvmfw.java
@@ -27,8 +27,8 @@
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.IOException;
-import java.util.Objects;
 import java.nio.ByteBuffer;
+import java.util.Objects;
 
 /** pvmfw.bin with custom config payloads on host. */
 public final class Pvmfw {
diff --git a/tests/pvmfw/java/com/android/pvmfw/test/DebugPolicyHostTests.java b/tests/pvmfw/java/com/android/pvmfw/test/DebugPolicyHostTests.java
index 2a6ab2d..7efbbc7 100644
--- a/tests/pvmfw/java/com/android/pvmfw/test/DebugPolicyHostTests.java
+++ b/tests/pvmfw/java/com/android/pvmfw/test/DebugPolicyHostTests.java
@@ -30,8 +30,8 @@
 import com.android.tradefed.device.DeviceRuntimeException;
 import com.android.tradefed.device.ITestDevice;
 import com.android.tradefed.testtype.DeviceJUnit4ClassRunner;
-import com.android.tradefed.util.CommandStatus;
 import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
 
 import org.junit.After;
 import org.junit.Before;
diff --git a/tests/testapk/src/java/com/android/microdroid/test/HwTrustJni.java b/tests/testapk/src/java/com/android/microdroid/test/HwTrustJni.java
index 3b237aa..0cf0606 100644
--- a/tests/testapk/src/java/com/android/microdroid/test/HwTrustJni.java
+++ b/tests/testapk/src/java/com/android/microdroid/test/HwTrustJni.java
@@ -25,7 +25,8 @@
      * Validates a DICE chain.
      *
      * @param diceChain The dice chain to validate.
+     * @param allowAnyMode Allow the chain's certificates to have any mode.
      * @return true if the dice chain is valid, false otherwise.
      */
-    public static native boolean validateDiceChain(byte[] diceChain);
+    public static native boolean validateDiceChain(byte[] diceChain, boolean allowAnyMode);
 }
diff --git a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
index 1465e73..d38af45 100644
--- a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
+++ b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
@@ -335,6 +335,7 @@
         testResults.assertNoException();
         assertThat(testResults.mAddInteger).isEqualTo(37 + 73);
     }
+
     @Test
     @CddTest(requirements = {"9.17/C-1-1"})
     public void autoCloseVm() throws Exception {
@@ -737,7 +738,6 @@
         VirtualMachineConfig.Builder otherOsBuilder =
                 newBaselineBuilder().setOs("microdroid_gki-android14-6.1");
         assertConfigCompatible(microdroidOsConfig, otherOsBuilder).isFalse();
-
     }
 
     private VirtualMachineConfig.Builder newBaselineBuilder() {
@@ -870,11 +870,12 @@
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-            "9.17/C-1-2",
-            "9.17/C-1-4",
-    })
+    @CddTest(
+            requirements = {
+                "9.17/C-1-1",
+                "9.17/C-1-2",
+                "9.17/C-1-4",
+            })
     public void createVmWithConfigRequiresPermission() throws Exception {
         assumeSupportedDevice();
         revokePermission(VirtualMachine.USE_CUSTOM_VIRTUAL_MACHINE_PERMISSION);
@@ -890,14 +891,16 @@
         SecurityException e =
                 assertThrows(
                         SecurityException.class, () -> runVmTestService(TAG, vm, (ts, tr) -> {}));
-        assertThat(e).hasMessageThat()
+        assertThat(e)
+                .hasMessageThat()
                 .contains("android.permission.USE_CUSTOM_VIRTUAL_MACHINE permission");
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-    })
+    @CddTest(
+            requirements = {
+                "9.17/C-1-1",
+            })
     public void deleteVm() throws Exception {
         assumeSupportedDevice();
 
@@ -954,9 +957,10 @@
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-    })
+    @CddTest(
+            requirements = {
+                "9.17/C-1-1",
+            })
     public void validApkPathIsAccepted() throws Exception {
         assumeSupportedDevice();
 
@@ -989,10 +993,7 @@
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-            "9.17/C-2-1"
-    })
+    @CddTest(requirements = {"9.17/C-1-1", "9.17/C-2-1"})
     public void extraApk() throws Exception {
         assumeSupportedDevice();
 
@@ -1044,7 +1045,7 @@
 
     @Test
     public void bootFailsWhenLowMem() throws Exception {
-        for (int memMib : new int[]{ 10, 20, 40 }) {
+        for (int memMib : new int[] {10, 20, 40}) {
             VirtualMachineConfig lowMemConfig =
                     newVmConfigBuilderWithPayloadBinary("MicrodroidTestNativeLib.so")
                             .setMemoryBytes(memMib)
@@ -1061,8 +1062,9 @@
                             onPayloadReadyExecuted.complete(true);
                             super.onPayloadReady(vm);
                         }
+
                         @Override
-                        public void onStopped(VirtualMachine vm,  int reason) {
+                        public void onStopped(VirtualMachine vm, int reason) {
                             onStoppedExecuted.complete(true);
                             super.onStopped(vm, reason);
                         }
@@ -1210,10 +1212,7 @@
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-            "9.17/C-2-7"
-    })
+    @CddTest(requirements = {"9.17/C-1-1", "9.17/C-2-7"})
     public void instancesOfSameVmHaveDifferentCdis() throws Exception {
         assumeSupportedDevice();
         // TODO(b/325094712): VMs on CF with same payload have the same secret. This is because
@@ -1240,10 +1239,7 @@
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-            "9.17/C-2-7"
-    })
+    @CddTest(requirements = {"9.17/C-1-1", "9.17/C-2-7"})
     public void sameInstanceKeepsSameCdis() throws Exception {
         assumeSupportedDevice();
         assume().withMessage("Skip on CF. Too Slow. b/257270529").that(isCuttlefish()).isFalse();
@@ -1298,7 +1294,7 @@
                 // then pvmfw, vm_entry (Microdroid kernel) and Microdroid payload entries.
                 // Before Android V we did not require that vendor code contain any DICE entries
                 // preceding pvmfw, so the minimum is one less.
-                int minDiceChainSize = getVendorApiLevel() >= 202404 ? 5 : 4;
+                int minDiceChainSize = getVendorApiLevel() > 202404 ? 5 : 4;
                 assertThat(diceChainSize).isAtLeast(minDiceChainSize);
             } else {
                 // pvmfw truncates the DICE chain it gets, so we expect exactly entries for
@@ -1309,7 +1305,7 @@
     }
 
     @Test
-    @VsrTest(requirements = {"VSR-7.1-001.005"})
+    @VsrTest(requirements = {"VSR-7.1-001.004"})
     public void protectedVmHasValidDiceChain() throws Exception {
         // This test validates two things regarding the pVM DICE chain:
         // 1. The DICE chain is well-formed that all the entries conform to the DICE spec.
@@ -1317,12 +1313,12 @@
         assumeSupportedDevice();
         assumeProtectedVM();
         assumeVsrCompliant();
-        assumeTrue("Vendor API must be at least 202404", getVendorApiLevel() >= 202404);
+        assumeTrue("Vendor API must be newer than 202404", getVendorApiLevel() > 202404);
 
         grantPermission(VirtualMachine.USE_CUSTOM_VIRTUAL_MACHINE_PERMISSION);
         VirtualMachineConfig config =
                 newVmConfigBuilderWithPayloadConfig("assets/vm_config.json")
-                        .setDebugLevel(DEBUG_LEVEL_FULL)
+                        .setDebugLevel(DEBUG_LEVEL_NONE)
                         .build();
         VirtualMachine vm = forceCreateNewVirtualMachine("bcc_vm_for_vsr", config);
         TestResults testResults =
@@ -1335,14 +1331,15 @@
         testResults.assertNoException();
         byte[] bccBytes = testResults.mBcc;
         assertThat(bccBytes).isNotNull();
-        assertThat(HwTrustJni.validateDiceChain(bccBytes)).isTrue();
+
+        String buildType = SystemProperties.get("ro.build.type");
+        boolean nonUserBuild = !buildType.isEmpty() && buildType != "user";
+
+        assertThat(HwTrustJni.validateDiceChain(bccBytes, nonUserBuild)).isTrue();
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-            "9.17/C-1-2"
-    })
+    @CddTest(requirements = {"9.17/C-1-1", "9.17/C-1-2"})
     public void accessToCdisIsRestricted() throws Exception {
         assumeSupportedDevice();
 
@@ -1399,8 +1396,7 @@
 
     private void assertThatPartitionIsMissing(UUID partitionUuid) throws Exception {
         RandomAccessFile instanceFile = prepareInstanceImage("test_vm_integrity");
-        assertThat(findPartitionDataOffset(instanceFile, partitionUuid).isPresent())
-                .isFalse();
+        assertThat(findPartitionDataOffset(instanceFile, partitionUuid).isPresent()).isFalse();
     }
 
     // Flips a bit of given partition, and then see if boot fails.
@@ -1420,10 +1416,7 @@
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-            "9.17/C-2-7"
-    })
+    @CddTest(requirements = {"9.17/C-1-1", "9.17/C-2-7"})
     public void bootFailsWhenMicrodroidDataIsCompromised() throws Exception {
         // If Updatable VM is supported => No instance.img required
         assumeNoUpdatableVmSupport();
@@ -1431,10 +1424,7 @@
     }
 
     @Test
-    @CddTest(requirements = {
-            "9.17/C-1-1",
-            "9.17/C-2-7"
-    })
+    @CddTest(requirements = {"9.17/C-1-1", "9.17/C-2-7"})
     public void bootFailsWhenPvmFwDataIsCompromised() throws Exception {
         // If Updatable VM is supported => No instance.img required
         assumeNoUpdatableVmSupport();
@@ -1456,8 +1446,8 @@
 
         BootResult bootResult = tryBootVmWithConfig(config, "test_vm_invalid_config");
         assertThat(bootResult.payloadStarted).isFalse();
-        assertThat(bootResult.deathReason).isEqualTo(
-                VirtualMachineCallback.STOP_REASON_MICRODROID_INVALID_PAYLOAD_CONFIG);
+        assertThat(bootResult.deathReason)
+                .isEqualTo(VirtualMachineCallback.STOP_REASON_MICRODROID_INVALID_PAYLOAD_CONFIG);
     }
 
     @Test
@@ -2143,7 +2133,6 @@
         IVmShareTestService service = connection.waitForService();
         assertWithMessage("Timed out connecting to " + serviceIntent).that(service).isNotNull();
 
-
         try {
             ITestService testServiceProxy = transferAndStartVm(service, vmDesc, "vm_to_share");
 
@@ -2627,16 +2616,15 @@
     }
 
     private long minMemoryRequired() {
-      assertThat(Build.SUPPORTED_ABIS).isNotEmpty();
-      String primaryAbi = Build.SUPPORTED_ABIS[0];
-      switch (primaryAbi) {
-        case "x86_64":
-          return MIN_MEM_X86_64;
-        case "arm64-v8a":
-        case "arm64-v8a-hwasan":
-          return MIN_MEM_ARM64;
-      }
-      throw new AssertionError("Unsupported ABI: " + primaryAbi);
+        assertThat(Build.SUPPORTED_ABIS).isNotEmpty();
+        String primaryAbi = Build.SUPPORTED_ABIS[0];
+        switch (primaryAbi) {
+            case "x86_64":
+                return MIN_MEM_X86_64;
+            case "arm64-v8a":
+            case "arm64-v8a-hwasan":
+                return MIN_MEM_ARM64;
+        }
+        throw new AssertionError("Unsupported ABI: " + primaryAbi);
     }
-
 }
diff --git a/tests/testapk/src/native/hwtrust_jni.rs b/tests/testapk/src/native/hwtrust_jni.rs
index 3b00364..058b1a6 100644
--- a/tests/testapk/src/native/hwtrust_jni.rs
+++ b/tests/testapk/src/native/hwtrust_jni.rs
@@ -29,6 +29,7 @@
     env: JNIEnv,
     _class: JClass,
     dice_chain: JByteArray,
+    allow_any_mode: jboolean,
 ) -> jboolean {
     android_logger::init_once(
         android_logger::Config::default()
@@ -36,7 +37,7 @@
             .with_max_level(log::LevelFilter::Debug),
     );
     debug!("Starting the DICE chain validation ...");
-    match validate_dice_chain(env, dice_chain) {
+    match validate_dice_chain(env, dice_chain, allow_any_mode) {
         Ok(_) => {
             info!("DICE chain validated successfully");
             true
@@ -49,9 +50,14 @@
     .into()
 }
 
-fn validate_dice_chain(env: JNIEnv, jdice_chain: JByteArray) -> Result<()> {
+fn validate_dice_chain(
+    env: JNIEnv,
+    jdice_chain: JByteArray,
+    allow_any_mode: jboolean,
+) -> Result<()> {
     let dice_chain = env.convert_byte_array(jdice_chain)?;
-    let session = Session::default();
+    let mut session = Session::default();
+    session.set_allow_any_mode(allow_any_mode == jboolean::from(true));
     let _chain = dice::Chain::from_cbor(&session, &dice_chain)?;
     Ok(())
 }
diff --git a/tests/testapk_no_perm/src/java/com/android/microdroid/test/MicrodroidTestAppNoPerm.java b/tests/testapk_no_perm/src/java/com/android/microdroid/test/MicrodroidTestAppNoPerm.java
index 1772e6b..27e26e5 100644
--- a/tests/testapk_no_perm/src/java/com/android/microdroid/test/MicrodroidTestAppNoPerm.java
+++ b/tests/testapk_no_perm/src/java/com/android/microdroid/test/MicrodroidTestAppNoPerm.java
@@ -16,18 +16,19 @@
 
 package com.android.microdroid.test;
 
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertThrows;
+
 import android.system.virtualmachine.VirtualMachineConfig;
 
 import com.android.compatibility.common.util.CddTest;
 import com.android.microdroid.test.device.MicrodroidDeviceTestBase;
 
-import static com.google.common.truth.Truth.assertThat;
-import static org.junit.Assert.assertThrows;
-
 import org.junit.Before;
-import org.junit.runners.Parameterized;
 import org.junit.Test;
 import org.junit.runner.RunWith;
+import org.junit.runners.Parameterized;
 
 /**
  * Test that the android.permission.MANAGE_VIRTUAL_MACHINE is enforced and that an app cannot launch
diff --git a/tests/vm_accessor/apex/accessor_demo.init.rc b/tests/vm_accessor/apex/accessor_demo.init.rc
index f3dfae9..1ebb038 100644
--- a/tests/vm_accessor/apex/accessor_demo.init.rc
+++ b/tests/vm_accessor/apex/accessor_demo.init.rc
@@ -16,5 +16,6 @@
 service accessor_demo /apex/com.android.virt.accessor_demo/bin/accessor_demo
     disabled
     oneshot
+    user root
     # MUST match with VINTF and accessor/src/main.rs
     interface aidl android.os.IAccessor/IAccessorVmService/default
diff --git a/tests/vmbase_example/Android.bp b/tests/vmbase_example/Android.bp
new file mode 100644
index 0000000..4c1aa30
--- /dev/null
+++ b/tests/vmbase_example/Android.bp
@@ -0,0 +1,31 @@
+package {
+    default_applicable_licenses: ["Android-Apache-2.0"],
+}
+
+rust_test {
+    name: "vmbase_example.integration_test",
+    crate_name: "vmbase_example_test",
+    srcs: ["src/main.rs"],
+    prefer_rlib: true,
+    edition: "2021",
+    rustlibs: [
+        "android.system.virtualizationservice-rust",
+        "libandroid_logger",
+        "libanyhow",
+        "liblibc",
+        "liblog_rust",
+        "libnix",
+        "libvmclient",
+    ],
+    data: [
+        ":vmbase_example_bios_bin",
+        ":vmbase_example_kernel_bin",
+    ],
+    test_suites: ["general-tests"],
+    enabled: false,
+    target: {
+        android_arm64: {
+            enabled: true,
+        },
+    },
+}
diff --git a/libs/libvmbase/example/tests/test.rs b/tests/vmbase_example/src/main.rs
similarity index 86%
rename from libs/libvmbase/example/tests/test.rs
rename to tests/vmbase_example/src/main.rs
index 8f9fafc..e0563b7 100644
--- a/libs/libvmbase/example/tests/test.rs
+++ b/tests/vmbase_example/src/main.rs
@@ -31,13 +31,27 @@
 };
 use vmclient::{DeathReason, VmInstance};
 
-const VMBASE_EXAMPLE_PATH: &str = "vmbase_example.bin";
+const VMBASE_EXAMPLE_KERNEL_PATH: &str = "vmbase_example_kernel.bin";
+const VMBASE_EXAMPLE_BIOS_PATH: &str = "vmbase_example_bios.bin";
 const TEST_DISK_IMAGE_PATH: &str = "test_disk.img";
 const EMPTY_DISK_IMAGE_PATH: &str = "empty_disk.img";
 
-/// Runs the vmbase_example VM as an unprotected VM via VirtualizationService.
+/// Runs the vmbase_example VM as an unprotected VM kernel via VirtualizationService.
 #[test]
-fn test_run_example_vm() -> Result<(), Error> {
+fn test_run_example_kernel_vm() -> Result<(), Error> {
+    run_test(Some(open_payload(VMBASE_EXAMPLE_KERNEL_PATH)?), None)
+}
+
+/// Runs the vmbase_example VM as an unprotected VM BIOS via VirtualizationService.
+#[test]
+fn test_run_example_bios_vm() -> Result<(), Error> {
+    run_test(None, Some(open_payload(VMBASE_EXAMPLE_BIOS_PATH)?))
+}
+
+fn run_test(
+    kernel: Option<ParcelFileDescriptor>,
+    bootloader: Option<ParcelFileDescriptor>,
+) -> Result<(), Error> {
     android_logger::init_once(
         android_logger::Config::default()
             .with_tag("vmbase")
@@ -56,12 +70,6 @@
         vmclient::VirtualizationService::new().context("Failed to spawn VirtualizationService")?;
     let service = virtmgr.connect().context("Failed to connect to VirtualizationService")?;
 
-    // Start example VM.
-    let bootloader = ParcelFileDescriptor::new(
-        File::open(VMBASE_EXAMPLE_PATH)
-            .with_context(|| format!("Failed to open VM image {}", VMBASE_EXAMPLE_PATH))?,
-    );
-
     // Make file for test disk image.
     let mut test_image = File::options()
         .create(true)
@@ -91,10 +99,10 @@
 
     let config = VirtualMachineConfig::RawConfig(VirtualMachineRawConfig {
         name: String::from("VmBaseTest"),
-        kernel: None,
+        kernel,
         initrd: None,
         params: None,
-        bootloader: Some(bootloader),
+        bootloader,
         disks: vec![disk_image, empty_disk_image],
         protectedVm: false,
         memoryMib: 300,
@@ -142,6 +150,11 @@
     Ok((reader_fd.into(), writer_fd.into()))
 }
 
+fn open_payload(path: &str) -> Result<ParcelFileDescriptor, Error> {
+    let file = File::open(path).with_context(|| format!("Failed to open VM image {path}"))?;
+    Ok(ParcelFileDescriptor::new(file))
+}
+
 struct VmLogProcessor {
     reader: Option<File>,
     expected: VecDeque<String>,
diff --git a/tests/vmshareapp/src/java/com/android/microdroid/test/sharevm/VmShareServiceImpl.java b/tests/vmshareapp/src/java/com/android/microdroid/test/sharevm/VmShareServiceImpl.java
index 109486c..9f606e5 100644
--- a/tests/vmshareapp/src/java/com/android/microdroid/test/sharevm/VmShareServiceImpl.java
+++ b/tests/vmshareapp/src/java/com/android/microdroid/test/sharevm/VmShareServiceImpl.java
@@ -28,8 +28,8 @@
 import android.util.Log;
 
 import com.android.microdroid.test.vmshare.IVmShareTestService;
-import com.android.microdroid.testservice.ITestService;
 import com.android.microdroid.testservice.IAppCallback;
+import com.android.microdroid.testservice.ITestService;
 
 import java.util.UUID;
 import java.util.concurrent.CountDownLatch;