Merge "pvmfw: suppress error about unused -c argument" into main
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.java b/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.java
deleted file mode 100644
index e3d1a67..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.java
+++ /dev/null
@@ -1,100 +0,0 @@
-/*
- * 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.
- */
-
-package com.android.virtualization.terminal;
-
-import static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.content.Context;
-import android.security.keystore.KeyGenParameterSpec;
-import android.security.keystore.KeyProperties;
-import android.util.Base64;
-import android.util.Log;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.security.InvalidAlgorithmParameterException;
-import java.security.KeyPairGenerator;
-import java.security.KeyStore;
-import java.security.NoSuchAlgorithmException;
-import java.security.NoSuchProviderException;
-import java.security.cert.Certificate;
-import java.security.cert.CertificateEncodingException;
-import java.security.cert.CertificateExpiredException;
-import java.security.cert.CertificateNotYetValidException;
-import java.security.cert.X509Certificate;
-
-public class CertificateUtils {
-    private static final String ALIAS = "ttyd";
-
-    public static KeyStore.PrivateKeyEntry createOrGetKey() {
-        try {
-            KeyStore ks = KeyStore.getInstance("AndroidKeyStore");
-            ks.load(null);
-
-            if (!ks.containsAlias(ALIAS)) {
-                Log.d(TAG, "there is no keypair, will generate it");
-                createKey();
-            } else if (!(ks.getCertificate(ALIAS) instanceof X509Certificate)) {
-                Log.d(TAG, "certificate isn't X509Certificate or it is invalid");
-                createKey();
-            } else {
-                try {
-                    ((X509Certificate) ks.getCertificate(ALIAS)).checkValidity();
-                } catch (CertificateExpiredException | CertificateNotYetValidException e) {
-                    Log.d(TAG, "certificate is invalid", e);
-                    createKey();
-                }
-            }
-            return ((KeyStore.PrivateKeyEntry) ks.getEntry(ALIAS, null));
-        } catch (Exception e) {
-            throw new RuntimeException("cannot generate or get key", e);
-        }
-    }
-
-    private static void createKey()
-            throws NoSuchAlgorithmException,
-                    NoSuchProviderException,
-                    InvalidAlgorithmParameterException {
-        KeyPairGenerator kpg =
-                KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore");
-        kpg.initialize(
-                new KeyGenParameterSpec.Builder(
-                                ALIAS, KeyProperties.PURPOSE_SIGN | KeyProperties.PURPOSE_VERIFY)
-                        .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
-                        .build());
-
-        kpg.generateKeyPair();
-    }
-
-    public static void writeCertificateToFile(Context context, Certificate cert) {
-        String certFileName = "ca.crt";
-        File certFile = new File(context.getFilesDir(), certFileName);
-        try (FileOutputStream writer = new FileOutputStream(certFile)) {
-            String cert_begin = "-----BEGIN CERTIFICATE-----\n";
-            String end_cert = "-----END CERTIFICATE-----\n";
-            String output =
-                    cert_begin
-                            + Base64.encodeToString(cert.getEncoded(), Base64.DEFAULT)
-                                    .replaceAll("(.{64})", "$1\n")
-                            + end_cert;
-            writer.write(output.getBytes());
-        } catch (IOException | CertificateEncodingException e) {
-            throw new RuntimeException("cannot write certs", e);
-        }
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.kt b/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.kt
new file mode 100644
index 0000000..53809e5
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/CertificateUtils.kt
@@ -0,0 +1,110 @@
+/*
+ * 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.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import android.util.Log
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.lang.Exception
+import java.lang.RuntimeException
+import java.security.InvalidAlgorithmParameterException
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.NoSuchAlgorithmException
+import java.security.NoSuchProviderException
+import java.security.cert.Certificate
+import java.security.cert.CertificateEncodingException
+import java.security.cert.CertificateExpiredException
+import java.security.cert.CertificateNotYetValidException
+import java.security.cert.X509Certificate
+
+object CertificateUtils {
+    private const val ALIAS = "ttyd"
+
+    @JvmStatic
+    fun createOrGetKey(): KeyStore.PrivateKeyEntry {
+        try {
+            val ks = KeyStore.getInstance("AndroidKeyStore")
+            ks.load(null)
+
+            if (!ks.containsAlias(ALIAS)) {
+                Log.d(MainActivity.TAG, "there is no keypair, will generate it")
+                createKey()
+            } else if (ks.getCertificate(ALIAS) !is X509Certificate) {
+                Log.d(MainActivity.TAG, "certificate isn't X509Certificate or it is invalid")
+                createKey()
+            } else {
+                try {
+                    (ks.getCertificate(ALIAS) as X509Certificate).checkValidity()
+                } catch (e: CertificateExpiredException) {
+                    Log.d(MainActivity.TAG, "certificate is invalid", e)
+                    createKey()
+                } catch (e: CertificateNotYetValidException) {
+                    Log.d(MainActivity.TAG, "certificate is invalid", e)
+                    createKey()
+                }
+            }
+            return ks.getEntry(ALIAS, null) as KeyStore.PrivateKeyEntry
+        } catch (e: Exception) {
+            throw RuntimeException("cannot generate or get key", e)
+        }
+    }
+
+    @Throws(
+        NoSuchAlgorithmException::class,
+        NoSuchProviderException::class,
+        InvalidAlgorithmParameterException::class,
+    )
+    private fun createKey() {
+        val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
+        kpg.initialize(
+            KeyGenParameterSpec.Builder(
+                    ALIAS,
+                    KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY,
+                )
+                .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512)
+                .build()
+        )
+
+        kpg.generateKeyPair()
+    }
+
+    @JvmStatic
+    fun writeCertificateToFile(context: Context, cert: Certificate) {
+        val certFile = File(context.getFilesDir(), "ca.crt")
+        try {
+            FileOutputStream(certFile).use { writer ->
+                val certBegin = "-----BEGIN CERTIFICATE-----\n"
+                val certEnd = "-----END CERTIFICATE-----\n"
+                val output =
+                    (certBegin +
+                        Base64.encodeToString(cert.encoded, Base64.DEFAULT)
+                            .replace("(.{64})".toRegex(), "$1\n") +
+                        certEnd)
+                writer.write(output.toByteArray())
+            }
+        } catch (e: IOException) {
+            throw RuntimeException("cannot write certs", e)
+        } catch (e: CertificateEncodingException) {
+            throw RuntimeException("cannot write certs", e)
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java
deleted file mode 100644
index 7099f22..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java
+++ /dev/null
@@ -1,88 +0,0 @@
-/*
- * 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.
- */
-
-package com.android.virtualization.terminal;
-
-import android.content.Context;
-import android.content.Intent;
-import android.os.Bundle;
-import android.view.View;
-import android.widget.TextView;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-
-import java.io.IOException;
-import java.io.PrintWriter;
-import java.io.StringWriter;
-
-public class ErrorActivity extends BaseActivity {
-    private static final String EXTRA_CAUSE = "cause";
-
-    public static void start(Context context, Exception e) {
-        Intent intent = new Intent(context, ErrorActivity.class);
-        intent.putExtra(EXTRA_CAUSE, e);
-        intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
-        context.startActivity(intent);
-    }
-
-    @Override
-    protected void onCreate(@Nullable Bundle savedInstanceState) {
-        super.onCreate(savedInstanceState);
-
-        setContentView(R.layout.activity_error);
-
-        View button = findViewById(R.id.recovery);
-        button.setOnClickListener((event) -> launchRecoveryActivity());
-    }
-
-    @Override
-    protected void onNewIntent(@NonNull Intent intent) {
-        super.onNewIntent(intent);
-        setIntent(intent);
-    }
-
-    @Override
-    public void onResume() {
-        super.onResume();
-
-        Intent intent = getIntent();
-        Exception e = intent.getParcelableExtra(EXTRA_CAUSE, Exception.class);
-        TextView cause = findViewById(R.id.cause);
-        if (e != null) {
-            String stackTrace = getStackTrace(e);
-            cause.setText(getString(R.string.error_code, stackTrace));
-        } else {
-            cause.setText(null);
-        }
-    }
-
-    private void launchRecoveryActivity() {
-        Intent intent = new Intent(this, SettingsRecoveryActivity.class);
-        startActivity(intent);
-    }
-
-    private static String getStackTrace(Exception e) {
-        try (StringWriter sWriter = new StringWriter();
-                PrintWriter pWriter = new PrintWriter(sWriter)) {
-            e.printStackTrace(pWriter);
-            return sWriter.toString();
-        } catch (IOException ex) {
-            // This shall never happen
-            throw new RuntimeException(ex);
-        }
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.kt
new file mode 100644
index 0000000..f253f86
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.kt
@@ -0,0 +1,83 @@
+/*
+ * 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.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.content.Intent
+import android.os.Bundle
+import android.view.View
+import android.widget.TextView
+import java.io.IOException
+import java.io.PrintWriter
+import java.io.StringWriter
+import java.lang.Exception
+import java.lang.RuntimeException
+
+class ErrorActivity : BaseActivity() {
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+
+        setContentView(R.layout.activity_error)
+
+        val button = findViewById<View>(R.id.recovery)
+        button.setOnClickListener(View.OnClickListener { _ -> launchRecoveryActivity() })
+    }
+
+    override fun onNewIntent(intent: Intent) {
+        super.onNewIntent(intent)
+        setIntent(intent)
+    }
+
+    override fun onResume() {
+        super.onResume()
+
+        val intent = getIntent()
+        val e = intent.getParcelableExtra<Exception?>(EXTRA_CAUSE, Exception::class.java)
+        val cause = findViewById<TextView>(R.id.cause)
+        cause.text = e?.let { getString(R.string.error_code, getStackTrace(it)) }
+    }
+
+    private fun launchRecoveryActivity() {
+        val intent = Intent(this, SettingsRecoveryActivity::class.java)
+        startActivity(intent)
+    }
+
+    companion object {
+        private const val EXTRA_CAUSE = "cause"
+
+        @JvmStatic
+        fun start(context: Context, e: Exception) {
+            val intent = Intent(context, ErrorActivity::class.java)
+            intent.putExtra(EXTRA_CAUSE, e)
+            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK or Intent.FLAG_ACTIVITY_NEW_TASK)
+            context.startActivity(intent)
+        }
+
+        private fun getStackTrace(e: Exception): String? {
+            try {
+                StringWriter().use { sWriter ->
+                    PrintWriter(sWriter).use { pWriter ->
+                        e.printStackTrace(pWriter)
+                        return sWriter.toString()
+                    }
+                }
+            } catch (ex: IOException) {
+                // This shall never happen
+                throw RuntimeException(ex)
+            }
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java
deleted file mode 100644
index 7f14179..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java
+++ /dev/null
@@ -1,175 +0,0 @@
-/*
- * 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 static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.os.Build;
-import android.os.Environment;
-import android.util.Log;
-
-import org.apache.commons.compress.archivers.ArchiveEntry;
-import org.apache.commons.compress.archivers.tar.TarArchiveInputStream;
-import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
-
-import java.io.BufferedInputStream;
-import java.io.FileInputStream;
-import java.io.IOException;
-import java.io.InputStream;
-import java.net.HttpURLConnection;
-import java.net.MalformedURLException;
-import java.net.URL;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.util.Arrays;
-import java.util.function.Function;
-
-/**
- * ImageArchive models the archive file (images.tar.gz) where VM payload files are in. This class
- * provides methods for handling the archive file, most importantly installing it.
- */
-class ImageArchive {
-    private static final String DIR_IN_SDCARD = "linux";
-    private static final String ARCHIVE_NAME = "images.tar.gz";
-    private static final String BUILD_TAG = "latest"; // TODO: use actual tag name
-    private static final String HOST_URL = "https://dl.google.com/android/ferrochrome/" + BUILD_TAG;
-
-    // Only one can be non-null
-    private final URL mUrl;
-    private final Path mPath;
-
-    private ImageArchive(URL url) {
-        mUrl = url;
-        mPath = null;
-    }
-
-    private ImageArchive(Path path) {
-        mUrl = null;
-        mPath = path;
-    }
-
-    public static Path getSdcardPathForTesting() {
-        return Environment.getExternalStoragePublicDirectory(DIR_IN_SDCARD).toPath();
-    }
-
-    /** Creates ImageArchive which is located in the sdcard. This archive is for testing only. */
-    public static ImageArchive fromSdCard() {
-        Path file = getSdcardPathForTesting().resolve(ARCHIVE_NAME);
-        return new ImageArchive(file);
-    }
-
-    /** Creates ImageArchive which is hosted in the Google server. This is the official archive. */
-    public static ImageArchive fromInternet() {
-        String arch = Arrays.asList(Build.SUPPORTED_ABIS).contains("x86_64") ? "x86_64" : "aarch64";
-        try {
-            URL url = new URL(HOST_URL + "/" + arch + "/" + ARCHIVE_NAME);
-            return new ImageArchive(url);
-        } catch (MalformedURLException e) {
-            // cannot happen
-            throw new RuntimeException(e);
-        }
-    }
-
-    /**
-     * Creates ImageArchive from either SdCard or Internet. SdCard is used only when the build is
-     * debuggable and the file actually exists.
-     */
-    public static ImageArchive getDefault() {
-        ImageArchive archive = fromSdCard();
-        if (Build.isDebuggable() && archive.exists()) {
-            return archive;
-        } else {
-            return fromInternet();
-        }
-    }
-
-    /** Tests if ImageArchive exists on the medium. */
-    public boolean exists() {
-        if (mPath != null) {
-            return Files.exists(mPath);
-        } else {
-            // TODO
-            return true;
-        }
-    }
-
-    /** Returns size of the archive in bytes */
-    public long getSize() throws IOException {
-        if (!exists()) {
-            throw new IllegalStateException("Cannot get size of non existing archive");
-        }
-        if (mPath != null) {
-            return Files.size(mPath);
-        } else {
-            HttpURLConnection conn = null;
-            try {
-                conn = (HttpURLConnection) mUrl.openConnection();
-                conn.setRequestMethod("HEAD");
-                conn.getInputStream();
-                return conn.getContentLength();
-            } finally {
-                if (conn != null) {
-                    conn.disconnect();
-                }
-            }
-        }
-    }
-
-    private InputStream getInputStream(Function<InputStream, InputStream> filter)
-            throws IOException {
-        InputStream is = mPath != null ? new FileInputStream(mPath.toFile()) : mUrl.openStream();
-        BufferedInputStream bufStream = new BufferedInputStream(is);
-        return filter == null ? bufStream : filter.apply(bufStream);
-    }
-
-    /**
-     * Installs this ImageArchive to a directory pointed by path. filter can be supplied to provide
-     * an additional input stream which will be used during the installation.
-     */
-    public void installTo(Path dir, Function<InputStream, InputStream> filter) throws IOException {
-        String source = mPath != null ? mPath.toString() : mUrl.toString();
-        Log.d(TAG, "Installing. source: " + source + ", destination: " + dir.toString());
-        try (InputStream stream = getInputStream(filter);
-                GzipCompressorInputStream gzStream = new GzipCompressorInputStream(stream);
-                TarArchiveInputStream tarStream = new TarArchiveInputStream(gzStream)) {
-
-            Files.createDirectories(dir);
-            ArchiveEntry entry;
-            while ((entry = tarStream.getNextEntry()) != null) {
-                Path to = dir.resolve(entry.getName());
-                if (Files.isDirectory(to)) {
-                    Files.createDirectories(to);
-                    continue;
-                }
-                Files.copy(tarStream, to, StandardCopyOption.REPLACE_EXISTING);
-            }
-        }
-        commitInstallationAt(dir);
-    }
-
-    private void commitInstallationAt(Path dir) throws IOException {
-        // To save storage, delete the source archive on the disk.
-        if (mPath != null) {
-            Files.deleteIfExists(mPath);
-        }
-
-        // Mark the completion
-        Path marker = dir.resolve(InstalledImage.MARKER_FILENAME);
-        Files.createFile(marker);
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt
new file mode 100644
index 0000000..31c9a91
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.kt
@@ -0,0 +1,187 @@
+/*
+ * 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.os.Build
+import android.os.Environment
+import android.util.Log
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.BufferedInputStream
+import java.io.FileInputStream
+import java.io.IOException
+import java.io.InputStream
+import java.lang.RuntimeException
+import java.net.HttpURLConnection
+import java.net.MalformedURLException
+import java.net.URL
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+import java.util.function.Function
+import org.apache.commons.compress.archivers.ArchiveEntry
+import org.apache.commons.compress.archivers.tar.TarArchiveInputStream
+import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream
+
+/**
+ * ImageArchive models the archive file (images.tar.gz) where VM payload files are in. This class
+ * provides methods for handling the archive file, most importantly installing it.
+ */
+internal class ImageArchive {
+    // Only one can be non-null
+    private sealed class Source<out A, out B>
+
+    private data class UrlSource<out Url>(val value: Url) : Source<Url, Nothing>()
+
+    private data class PathSource<out Path>(val value: Path) : Source<Nothing, Path>()
+
+    private val source: Source<URL, Path>
+
+    private constructor(url: URL) {
+        source = UrlSource(url)
+    }
+
+    private constructor(path: Path) {
+        source = PathSource(path)
+    }
+
+    /** Tests if ImageArchive exists on the medium. */
+    fun exists(): Boolean {
+        return when (source) {
+            is UrlSource -> true
+            is PathSource -> Files.exists(source.value)
+        }
+    }
+
+    /** Returns size of the archive in bytes */
+    @Throws(IOException::class)
+    fun getSize(): Long {
+        check(exists()) { "Cannot get size of non existing archive" }
+        return when (source) {
+            is UrlSource -> {
+                val conn = source.value.openConnection() as HttpURLConnection
+                try {
+                    conn.requestMethod = "HEAD"
+                    conn.getInputStream()
+                    return conn.contentLength.toLong()
+                } finally {
+                    conn.disconnect()
+                }
+            }
+            is PathSource -> Files.size(source.value)
+        }
+    }
+
+    @Throws(IOException::class)
+    private fun getInputStream(filter: Function<InputStream, InputStream>?): InputStream? {
+        val bufStream =
+            BufferedInputStream(
+                when (source) {
+                    is UrlSource -> source.value.openStream()
+                    is PathSource -> FileInputStream(source.value.toFile())
+                }
+            )
+        return filter?.apply(bufStream) ?: bufStream
+    }
+
+    /**
+     * Installs this ImageArchive to a directory pointed by path. filter can be supplied to provide
+     * an additional input stream which will be used during the installation.
+     */
+    @Throws(IOException::class)
+    fun installTo(dir: Path, filter: Function<InputStream, InputStream>?) {
+        val source =
+            when (source) {
+                is PathSource -> source.value.toString()
+                is UrlSource -> source.value.toString()
+            }
+        Log.d(TAG, "Installing. source: $source, destination: $dir")
+        TarArchiveInputStream(GzipCompressorInputStream(getInputStream(filter))).use { tarStream ->
+            Files.createDirectories(dir)
+            var entry: ArchiveEntry?
+            while ((tarStream.nextEntry.also { entry = it }) != null) {
+                val to = dir.resolve(entry!!.getName())
+                if (Files.isDirectory(to)) {
+                    Files.createDirectories(to)
+                    continue
+                }
+                Files.copy(tarStream, to, StandardCopyOption.REPLACE_EXISTING)
+            }
+        }
+        commitInstallationAt(dir)
+    }
+
+    @Throws(IOException::class)
+    private fun commitInstallationAt(dir: Path) {
+        // To save storage, delete the source archive on the disk.
+        if (source is PathSource) {
+            Files.deleteIfExists(source.value)
+        }
+
+        // Mark the completion
+        val marker = dir.resolve(InstalledImage.MARKER_FILENAME)
+        Files.createFile(marker)
+    }
+
+    companion object {
+        private const val DIR_IN_SDCARD = "linux"
+        private const val ARCHIVE_NAME = "images.tar.gz"
+        private const val BUILD_TAG = "latest" // TODO: use actual tag name
+        private const val HOST_URL = "https://dl.google.com/android/ferrochrome/$BUILD_TAG"
+
+        @JvmStatic
+        fun getSdcardPathForTesting(): Path {
+            return Environment.getExternalStoragePublicDirectory(DIR_IN_SDCARD).toPath()
+        }
+
+        /**
+         * Creates ImageArchive which is located in the sdcard. This archive is for testing only.
+         */
+        @JvmStatic
+        fun fromSdCard(): ImageArchive {
+            return ImageArchive(getSdcardPathForTesting().resolve(ARCHIVE_NAME))
+        }
+
+        /**
+         * Creates ImageArchive which is hosted in the Google server. This is the official archive.
+         */
+        @JvmStatic
+        fun fromInternet(): ImageArchive {
+            val arch =
+                if (listOf<String?>(*Build.SUPPORTED_ABIS).contains("x86_64")) "x86_64"
+                else "aarch64"
+            try {
+                return ImageArchive(URL("$HOST_URL/$arch/$ARCHIVE_NAME"))
+            } catch (e: MalformedURLException) {
+                // cannot happen
+                throw RuntimeException(e)
+            }
+        }
+
+        /**
+         * Creates ImageArchive from either SdCard or Internet. SdCard is used only when the build
+         * is debuggable and the file actually exists.
+         */
+        @JvmStatic
+        fun getDefault(): ImageArchive {
+            val archive = fromSdCard()
+            return if (Build.isDebuggable() && archive.exists()) {
+                archive
+            } else {
+                fromInternet()
+            }
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java
deleted file mode 100644
index 318f49a..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java
+++ /dev/null
@@ -1,212 +0,0 @@
-/*
- * 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 static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.content.Context;
-import android.os.FileUtils;
-import android.system.ErrnoException;
-import android.system.Os;
-import android.util.Log;
-
-import java.io.BufferedReader;
-import java.io.FileDescriptor;
-import java.io.FileReader;
-import java.io.IOException;
-import java.io.RandomAccessFile;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-
-/** Collection of files that consist of a VM image. */
-class InstalledImage {
-    private static final String INSTALL_DIRNAME = "linux";
-    private static final String ROOTFS_FILENAME = "root_part";
-    private static final String BACKUP_FILENAME = "root_part_backup";
-    private static final String CONFIG_FILENAME = "vm_config.json";
-    private static final String BUILD_ID_FILENAME = "build_id";
-    static final String MARKER_FILENAME = "completed";
-
-    public static final long RESIZE_STEP_BYTES = 4 << 20; // 4 MiB
-
-    private final Path mDir;
-    private final Path mRootPartition;
-    private final Path mBackup;
-    private final Path mConfig;
-    private final Path mMarker;
-    private String mBuildId;
-
-    /** Returns InstalledImage for a given app context */
-    public static InstalledImage getDefault(Context context) {
-        Path installDir = context.getFilesDir().toPath().resolve(INSTALL_DIRNAME);
-        return new InstalledImage(installDir);
-    }
-
-    private InstalledImage(Path dir) {
-        mDir = dir;
-        mRootPartition = dir.resolve(ROOTFS_FILENAME);
-        mBackup = dir.resolve(BACKUP_FILENAME);
-        mConfig = dir.resolve(CONFIG_FILENAME);
-        mMarker = dir.resolve(MARKER_FILENAME);
-    }
-
-    public Path getInstallDir() {
-        return mDir;
-    }
-
-    /** Tests if this InstalledImage is actually installed. */
-    public boolean isInstalled() {
-        return Files.exists(mMarker);
-    }
-
-    /** Fully understalls this InstalledImage by deleting everything. */
-    public void uninstallFully() throws IOException {
-        FileUtils.deleteContentsAndDir(mDir.toFile());
-    }
-
-    /** Returns the path to the VM config file. */
-    public Path getConfigPath() {
-        return mConfig;
-    }
-
-    /** Returns the build ID of the installed image */
-    public String getBuildId() {
-        if (mBuildId == null) {
-            mBuildId = readBuildId();
-        }
-        return mBuildId;
-    }
-
-    private String readBuildId() {
-        Path file = mDir.resolve(BUILD_ID_FILENAME);
-        if (!Files.exists(file)) {
-            return "<no build id>";
-        }
-        try (BufferedReader r = new BufferedReader(new FileReader(file.toFile()))) {
-            return r.readLine();
-        } catch (IOException e) {
-            throw new RuntimeException("Failed to read build ID", e);
-        }
-    }
-
-    public Path uninstallAndBackup() throws IOException {
-        Files.delete(mMarker);
-        Files.move(mRootPartition, mBackup, StandardCopyOption.REPLACE_EXISTING);
-        return mBackup;
-    }
-
-    public Path getBackupFile() {
-        return mBackup;
-    }
-
-    public boolean hasBackup() {
-        return Files.exists(mBackup);
-    }
-
-    public void deleteBackup() throws IOException {
-        Files.deleteIfExists(mBackup);
-    }
-
-    public long getSize() throws IOException {
-        return Files.size(mRootPartition);
-    }
-
-    public long getSmallestSizePossible() throws IOException {
-        runE2fsck(mRootPartition);
-        String p = mRootPartition.toAbsolutePath().toString();
-        String result = runCommand("/system/bin/resize2fs", "-P", p);
-        // The return value is the number of 4k block
-        try {
-            long minSize =
-                    Long.parseLong(result.lines().toArray(String[]::new)[1].substring(42))
-                            * 4
-                            * 1024;
-            return roundUp(minSize);
-        } catch (NumberFormatException e) {
-            Log.e(TAG, "Failed to parse min size, p=" + p + ", result=" + result);
-            throw new IOException(e);
-        }
-    }
-
-    public long resize(long desiredSize) throws IOException {
-        desiredSize = roundUp(desiredSize);
-        final long curSize = getSize();
-
-        if (desiredSize == curSize) {
-            return desiredSize;
-        }
-
-        runE2fsck(mRootPartition);
-        if (desiredSize > curSize) {
-            allocateSpace(mRootPartition, desiredSize);
-        }
-        resizeFilesystem(mRootPartition, desiredSize);
-        return getSize();
-    }
-
-    private static void allocateSpace(Path path, long sizeInBytes) throws IOException {
-        try {
-            RandomAccessFile raf = new RandomAccessFile(path.toFile(), "rw");
-            FileDescriptor fd = raf.getFD();
-            Os.posix_fallocate(fd, 0, sizeInBytes);
-            raf.close();
-            Log.d(TAG, "Allocated space to: " + sizeInBytes + " bytes");
-        } catch (ErrnoException e) {
-            Log.e(TAG, "Failed to allocate space", e);
-            throw new IOException("Failed to allocate space", e);
-        }
-    }
-
-    private static void runE2fsck(Path path) throws IOException {
-        String p = path.toAbsolutePath().toString();
-        runCommand("/system/bin/e2fsck", "-y", "-f", p);
-        Log.d(TAG, "e2fsck completed: " + path);
-    }
-
-    private static void resizeFilesystem(Path path, long sizeInBytes) throws IOException {
-        long sizeInMB = sizeInBytes / (1024 * 1024);
-        if (sizeInMB == 0) {
-            Log.e(TAG, "Invalid size: " + sizeInBytes + " bytes");
-            throw new IllegalArgumentException("Size cannot be zero MB");
-        }
-        String sizeArg = sizeInMB + "M";
-        String p = path.toAbsolutePath().toString();
-        runCommand("/system/bin/resize2fs", p, sizeArg);
-        Log.d(TAG, "resize2fs completed: " + path + ", size: " + sizeArg);
-    }
-
-    private static String runCommand(String... command) throws IOException {
-        try {
-            Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
-            process.waitFor();
-            String result = new String(process.getInputStream().readAllBytes());
-            if (process.exitValue() != 0) {
-                Log.w(TAG, "Process returned with error, command=" + String.join(" ", command)
-                    + ", exitValue=" + process.exitValue() + ", result=" + result);
-            }
-            return result;
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            throw new IOException("Command interrupted", e);
-        }
-    }
-
-    private static long roundUp(long bytes) {
-        // Round up every diskSizeStep MB
-        return (long) Math.ceil(((double) bytes) / RESIZE_STEP_BYTES) * RESIZE_STEP_BYTES;
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
new file mode 100644
index 0000000..e52f996
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
@@ -0,0 +1,200 @@
+/*
+ * 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.content.Context
+import android.os.FileUtils
+import android.system.ErrnoException
+import android.system.Os
+import android.util.Log
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.BufferedReader
+import java.io.FileReader
+import java.io.IOException
+import java.io.RandomAccessFile
+import java.lang.IllegalArgumentException
+import java.lang.NumberFormatException
+import java.lang.RuntimeException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardCopyOption
+import kotlin.math.ceil
+
+/** Collection of files that consist of a VM image. */
+internal class InstalledImage private constructor(val installDir: Path) {
+    private val rootPartition: Path = installDir.resolve(ROOTFS_FILENAME)
+    val backupFile: Path = installDir.resolve(BACKUP_FILENAME)
+
+    /** The path to the VM config file. */
+    val configPath: Path = installDir.resolve(CONFIG_FILENAME)
+    private val marker: Path = installDir.resolve(MARKER_FILENAME)
+    /** The build ID of the installed image */
+    val buildId: String by lazy { readBuildId() }
+
+    /** Tests if this InstalledImage is actually installed. */
+    fun isInstalled(): Boolean {
+        return Files.exists(marker)
+    }
+
+    /** Fully uninstall this InstalledImage by deleting everything. */
+    @Throws(IOException::class)
+    fun uninstallFully() {
+        FileUtils.deleteContentsAndDir(installDir.toFile())
+    }
+
+    private fun readBuildId(): String {
+        val file = installDir.resolve(BUILD_ID_FILENAME)
+        if (!Files.exists(file)) {
+            return "<no build id>"
+        }
+        try {
+            BufferedReader(FileReader(file.toFile())).use { r ->
+                return r.readLine()
+            }
+        } catch (e: IOException) {
+            throw RuntimeException("Failed to read build ID", e)
+        }
+    }
+
+    @Throws(IOException::class)
+    fun uninstallAndBackup(): Path {
+        Files.delete(marker)
+        Files.move(rootPartition, backupFile, StandardCopyOption.REPLACE_EXISTING)
+        return backupFile
+    }
+
+    fun hasBackup(): Boolean {
+        return Files.exists(backupFile)
+    }
+
+    @Throws(IOException::class)
+    fun deleteBackup() {
+        Files.deleteIfExists(backupFile)
+    }
+
+    @Throws(IOException::class)
+    fun getSize(): Long {
+        return Files.size(rootPartition)
+    }
+
+    @Throws(IOException::class)
+    fun getSmallestSizePossible(): Long {
+        runE2fsck(rootPartition)
+        val p: String = rootPartition.toAbsolutePath().toString()
+        val result = runCommand("/system/bin/resize2fs", "-P", p)
+        // The return value is the number of 4k block
+        return try {
+            roundUp(result.lines().first().substring(42).toLong() * 4 * 1024)
+        } catch (e: NumberFormatException) {
+            Log.e(TAG, "Failed to parse min size, p=$p, result=$result")
+            throw IOException(e)
+        }
+    }
+
+    @Throws(IOException::class)
+    fun resize(desiredSize: Long): Long {
+        val roundedUpDesiredSize = roundUp(desiredSize)
+        val curSize = getSize()
+
+        if (roundedUpDesiredSize == curSize) {
+            return roundedUpDesiredSize
+        }
+
+        runE2fsck(rootPartition)
+        if (roundedUpDesiredSize > curSize) {
+            allocateSpace(rootPartition, roundedUpDesiredSize)
+        }
+        resizeFilesystem(rootPartition, roundedUpDesiredSize)
+        return getSize()
+    }
+
+    companion object {
+        private const val INSTALL_DIRNAME = "linux"
+        private const val ROOTFS_FILENAME = "root_part"
+        private const val BACKUP_FILENAME = "root_part_backup"
+        private const val CONFIG_FILENAME = "vm_config.json"
+        private const val BUILD_ID_FILENAME = "build_id"
+        const val MARKER_FILENAME: String = "completed"
+
+        const val RESIZE_STEP_BYTES: Long = 4 shl 20 // 4 MiB
+
+        /** Returns InstalledImage for a given app context */
+        @JvmStatic
+        fun getDefault(context: Context): InstalledImage {
+            val installDir = context.getFilesDir().toPath().resolve(INSTALL_DIRNAME)
+            return InstalledImage(installDir)
+        }
+
+        @Throws(IOException::class)
+        private fun allocateSpace(path: Path, sizeInBytes: Long) {
+            try {
+                val raf = RandomAccessFile(path.toFile(), "rw")
+                val fd = raf.fd
+                Os.posix_fallocate(fd, 0, sizeInBytes)
+                raf.close()
+                Log.d(TAG, "Allocated space to: $sizeInBytes bytes")
+            } catch (e: ErrnoException) {
+                Log.e(TAG, "Failed to allocate space", e)
+                throw IOException("Failed to allocate space", e)
+            }
+        }
+
+        @Throws(IOException::class)
+        private fun runE2fsck(path: Path) {
+            val p: String = path.toAbsolutePath().toString()
+            runCommand("/system/bin/e2fsck", "-y", "-f", p)
+            Log.d(TAG, "e2fsck completed: $path")
+        }
+
+        @Throws(IOException::class)
+        private fun resizeFilesystem(path: Path, sizeInBytes: Long) {
+            val sizeInMB = sizeInBytes / (1024 * 1024)
+            if (sizeInMB == 0L) {
+                Log.e(TAG, "Invalid size: $sizeInBytes bytes")
+                throw IllegalArgumentException("Size cannot be zero MB")
+            }
+            val sizeArg = sizeInMB.toString() + "M"
+            val p: String = path.toAbsolutePath().toString()
+            runCommand("/system/bin/resize2fs", p, sizeArg)
+            Log.d(TAG, "resize2fs completed: $path, size: $sizeArg")
+        }
+
+        @Throws(IOException::class)
+        private fun runCommand(vararg command: String): String {
+            try {
+                val process = ProcessBuilder(*command).redirectErrorStream(true).start()
+                process.waitFor()
+                val result = String(process.inputStream.readAllBytes())
+                if (process.exitValue() != 0) {
+                    Log.w(
+                        TAG,
+                        "Process returned with error, command=${listOf(*command).joinToString(" ")}," +
+                            "exitValue=${process.exitValue()}, result=$result",
+                    )
+                }
+                return result
+            } catch (e: InterruptedException) {
+                Thread.currentThread().interrupt()
+                throw IOException("Command interrupted", e)
+            }
+        }
+
+        private fun roundUp(bytes: Long): Long {
+            // Round up every diskSizeStep MB
+            return ceil((bytes.toDouble()) / RESIZE_STEP_BYTES).toLong() * RESIZE_STEP_BYTES
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java b/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java
deleted file mode 100644
index 2c0149e..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/Logger.java
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * 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.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/TerminalApp/java/com/android/virtualization/terminal/Logger.kt b/android/TerminalApp/java/com/android/virtualization/terminal/Logger.kt
new file mode 100644
index 0000000..3a273ec
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/Logger.kt
@@ -0,0 +1,81 @@
+/*
+ * 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.system.virtualmachine.VirtualMachine
+import android.system.virtualmachine.VirtualMachineConfig
+import android.system.virtualmachine.VirtualMachineException
+import android.util.Log
+import com.android.virtualization.terminal.Logger.LineBufferedOutputStream
+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.lang.RuntimeException
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.StandardOpenOption
+import java.util.concurrent.ExecutorService
+import libcore.io.Streams
+
+/**
+ * Forwards VM's console output to a file on the Android side, and VM's log output to Android logd.
+ */
+internal object Logger {
+    @JvmStatic
+    fun setup(vm: VirtualMachine, path: Path, executor: ExecutorService) {
+        if (vm.config.debugLevel != VirtualMachineConfig.DEBUG_LEVEL_FULL) {
+            return
+        }
+
+        try {
+            val console = vm.getConsoleOutput()
+            val file = Files.newOutputStream(path, StandardOpenOption.CREATE)
+            executor.submit<Int?> {
+                console.use { console ->
+                    LineBufferedOutputStream(file).use { fileOutput ->
+                        Streams.copy(console, fileOutput)
+                    }
+                }
+            }
+
+            val log = vm.getLogOutput()
+            executor.submit<Unit> { log.use { writeToLogd(it, vm.name) } }
+        } catch (e: VirtualMachineException) {
+            throw RuntimeException(e)
+        } catch (e: IOException) {
+            throw RuntimeException(e)
+        }
+    }
+
+    @Throws(IOException::class)
+    private fun writeToLogd(input: InputStream?, vmName: String?) {
+        val reader = BufferedReader(InputStreamReader(input))
+        reader
+            .useLines { lines -> lines.takeWhile { !Thread.interrupted() } }
+            .forEach { Log.d(vmName, it) }
+    }
+
+    private class LineBufferedOutputStream(out: OutputStream?) : BufferedOutputStream(out) {
+        @Throws(IOException::class)
+        override fun write(buf: ByteArray, off: Int, len: Int) {
+            super.write(buf, off, len)
+            (0 until len).firstOrNull { buf[off + it] == '\n'.code.toByte() }?.let { flush() }
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.java b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.java
deleted file mode 100644
index 0d70ab9..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.java
+++ /dev/null
@@ -1,146 +0,0 @@
-/*
- * 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 static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.app.Notification;
-import android.app.Notification.Action;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.content.BroadcastReceiver;
-import android.content.Context;
-import android.content.Intent;
-import android.content.IntentFilter;
-import android.graphics.drawable.Icon;
-
-import java.util.HashSet;
-import java.util.Locale;
-import java.util.Set;
-
-/**
- * PortNotifier is responsible for posting a notification when a new open port is detected. User can
- * enable or disable forwarding of the port in notification panel.
- */
-class PortNotifier {
-    private static final String ACTION_PORT_FORWARDING = "android.virtualization.PORT_FORWARDING";
-    private static final String KEY_PORT = "port";
-    private static final String KEY_ENABLED = "enabled";
-
-    private final Context mContext;
-    private final NotificationManager mNotificationManager;
-    private final BroadcastReceiver mReceiver;
-    private final PortsStateManager mPortsStateManager;
-    private final PortsStateManager.Listener mPortsStateListener;
-
-    public PortNotifier(Context context) {
-        mContext = context;
-        mNotificationManager = mContext.getSystemService(NotificationManager.class);
-        mReceiver = new PortForwardingRequestReceiver();
-
-        mPortsStateManager = PortsStateManager.getInstance(mContext);
-        mPortsStateListener =
-                new PortsStateManager.Listener() {
-                    @Override
-                    public void onPortsStateUpdated(
-                            Set<Integer> oldActivePorts, Set<Integer> newActivePorts) {
-                        Set<Integer> union = new HashSet<>(oldActivePorts);
-                        union.addAll(newActivePorts);
-                        for (int port : union) {
-                            if (!oldActivePorts.contains(port)) {
-                                showNotificationFor(port);
-                            } else if (!newActivePorts.contains(port)) {
-                                discardNotificationFor(port);
-                            }
-                        }
-                    }
-                };
-        mPortsStateManager.registerListener(mPortsStateListener);
-
-        IntentFilter intentFilter = new IntentFilter(ACTION_PORT_FORWARDING);
-        mContext.registerReceiver(mReceiver, intentFilter, Context.RECEIVER_NOT_EXPORTED);
-    }
-
-    public void stop() {
-        mPortsStateManager.unregisterListener(mPortsStateListener);
-        mContext.unregisterReceiver(mReceiver);
-    }
-
-    private String getString(int resId) {
-        return mContext.getString(resId);
-    }
-
-    private PendingIntent getPendingIntentFor(int port, boolean enabled) {
-        Intent intent = new Intent(ACTION_PORT_FORWARDING);
-        intent.setPackage(mContext.getPackageName());
-        intent.setIdentifier(String.format(Locale.ROOT, "%d_%b", port, enabled));
-        intent.putExtra(KEY_PORT, port);
-        intent.putExtra(KEY_ENABLED, enabled);
-        return PendingIntent.getBroadcast(mContext, 0, intent, PendingIntent.FLAG_IMMUTABLE);
-    }
-
-    private void showNotificationFor(int port) {
-        Intent tapIntent = new Intent(mContext, SettingsPortForwardingActivity.class);
-        tapIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
-        PendingIntent tapPendingIntent =
-                PendingIntent.getActivity(mContext, 0, tapIntent, PendingIntent.FLAG_IMMUTABLE);
-
-        String title = getString(R.string.settings_port_forwarding_notification_title);
-        String content =
-                mContext.getString(R.string.settings_port_forwarding_notification_content, port);
-        String acceptText = getString(R.string.settings_port_forwarding_notification_accept);
-        String denyText = getString(R.string.settings_port_forwarding_notification_deny);
-        Icon icon = Icon.createWithResource(mContext, R.drawable.ic_launcher_foreground);
-
-        Action acceptAction =
-                new Action.Builder(icon, acceptText, getPendingIntentFor(port, true /* enabled */))
-                        .build();
-        Action denyAction =
-                new Action.Builder(icon, denyText, getPendingIntentFor(port, false /* enabled */))
-                        .build();
-        Notification notification =
-                new Notification.Builder(mContext, mContext.getPackageName())
-                        .setSmallIcon(R.drawable.ic_launcher_foreground)
-                        .setContentTitle(title)
-                        .setContentText(content)
-                        .setContentIntent(tapPendingIntent)
-                        .addAction(acceptAction)
-                        .addAction(denyAction)
-                        .build();
-        mNotificationManager.notify(TAG, port, notification);
-    }
-
-    private void discardNotificationFor(int port) {
-        mNotificationManager.cancel(TAG, port);
-    }
-
-    private final class PortForwardingRequestReceiver extends BroadcastReceiver {
-        @Override
-        public void onReceive(Context context, Intent intent) {
-            if (ACTION_PORT_FORWARDING.equals(intent.getAction())) {
-                performActionPortForwarding(context, intent);
-            }
-        }
-
-        private void performActionPortForwarding(Context context, Intent intent) {
-            int port = intent.getIntExtra(KEY_PORT, 0);
-            boolean enabled = intent.getBooleanExtra(KEY_ENABLED, false);
-            mPortsStateManager.updateEnabledPort(port, enabled);
-            discardNotificationFor(port);
-        }
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
new file mode 100644
index 0000000..ed6e02e
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/PortNotifier.kt
@@ -0,0 +1,139 @@
+/*
+ * 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.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.graphics.drawable.Icon
+import java.util.HashSet
+import java.util.Locale
+
+/**
+ * PortNotifier is responsible for posting a notification when a new open port is detected. User can
+ * enable or disable forwarding of the port in notification panel.
+ */
+internal class PortNotifier(val context: Context) {
+    private val notificationManager: NotificationManager =
+        context.getSystemService<NotificationManager?>(NotificationManager::class.java)
+    private val receiver: BroadcastReceiver =
+        PortForwardingRequestReceiver().also {
+            val intentFilter = IntentFilter(ACTION_PORT_FORWARDING)
+            context.registerReceiver(it, intentFilter, Context.RECEIVER_NOT_EXPORTED)
+        }
+    private val portsStateListener: PortsStateManager.Listener =
+        object : PortsStateManager.Listener {
+            override fun onPortsStateUpdated(oldActivePorts: Set<Int>, newActivePorts: Set<Int>) {
+                val union: MutableSet<Int> = HashSet<Int>(oldActivePorts)
+                union.addAll(newActivePorts)
+                for (port in union) {
+                    if (!oldActivePorts.contains(port)) {
+                        showNotificationFor(port)
+                    } else if (!newActivePorts.contains(port)) {
+                        discardNotificationFor(port)
+                    }
+                }
+            }
+        }
+    private val portsStateManager: PortsStateManager =
+        PortsStateManager.getInstance(context).also { it.registerListener(portsStateListener) }
+
+    fun stop() {
+        portsStateManager.unregisterListener(portsStateListener)
+        context.unregisterReceiver(receiver)
+    }
+
+    private fun getString(resId: Int): String {
+        return context.getString(resId)
+    }
+
+    private fun getPendingIntentFor(port: Int, enabled: Boolean): PendingIntent? {
+        val intent = Intent(ACTION_PORT_FORWARDING)
+        intent.setPackage(context.getPackageName())
+        intent.setIdentifier(String.format(Locale.ROOT, "%d_%b", port, enabled))
+        intent.putExtra(KEY_PORT, port)
+        intent.putExtra(KEY_ENABLED, enabled)
+        return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
+    }
+
+    private fun showNotificationFor(port: Int) {
+        val tapIntent = Intent(context, SettingsPortForwardingActivity::class.java)
+        tapIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
+        val tapPendingIntent =
+            PendingIntent.getActivity(context, 0, tapIntent, PendingIntent.FLAG_IMMUTABLE)
+
+        val title = getString(R.string.settings_port_forwarding_notification_title)
+        val content =
+            context.getString(R.string.settings_port_forwarding_notification_content, port)
+        val acceptText = getString(R.string.settings_port_forwarding_notification_accept)
+        val denyText = getString(R.string.settings_port_forwarding_notification_deny)
+        val icon = Icon.createWithResource(context, R.drawable.ic_launcher_foreground)
+
+        val acceptAction: Notification.Action =
+            Notification.Action.Builder(
+                    icon,
+                    acceptText,
+                    getPendingIntentFor(port, true /* enabled */),
+                )
+                .build()
+        val denyAction: Notification.Action =
+            Notification.Action.Builder(
+                    icon,
+                    denyText,
+                    getPendingIntentFor(port, false /* enabled */),
+                )
+                .build()
+        val notification: Notification =
+            Notification.Builder(context, context.getPackageName())
+                .setSmallIcon(R.drawable.ic_launcher_foreground)
+                .setContentTitle(title)
+                .setContentText(content)
+                .setContentIntent(tapPendingIntent)
+                .addAction(acceptAction)
+                .addAction(denyAction)
+                .build()
+        notificationManager.notify(MainActivity.TAG, port, notification)
+    }
+
+    private fun discardNotificationFor(port: Int) {
+        notificationManager.cancel(MainActivity.TAG, port)
+    }
+
+    private inner class PortForwardingRequestReceiver : BroadcastReceiver() {
+        override fun onReceive(context: Context?, intent: Intent) {
+            if (ACTION_PORT_FORWARDING == intent.action) {
+                performActionPortForwarding(intent)
+            }
+        }
+
+        fun performActionPortForwarding(intent: Intent) {
+            val port = intent.getIntExtra(KEY_PORT, 0)
+            val enabled = intent.getBooleanExtra(KEY_ENABLED, false)
+            portsStateManager.updateEnabledPort(port, enabled)
+            discardNotificationFor(port)
+        }
+    }
+
+    companion object {
+        private const val ACTION_PORT_FORWARDING = "android.virtualization.PORT_FORWARDING"
+        private const val KEY_PORT = "port"
+        private const val KEY_ENABLED = "enabled"
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.java b/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.java
deleted file mode 100644
index 5321d89..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.java
+++ /dev/null
@@ -1,158 +0,0 @@
-/*
- * 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.content.Context;
-import android.content.SharedPreferences;
-
-import com.android.internal.annotations.GuardedBy;
-
-import java.util.HashSet;
-import java.util.Set;
-import java.util.stream.Collectors;
-
-/**
- * PortsStateManager is responsible for communicating with shared preferences and managing state of
- * ports.
- */
-public class PortsStateManager {
-    private static final String PREFS_NAME = ".PORTS";
-    private static final int FLAG_ENABLED = 1;
-
-    private static PortsStateManager mInstance;
-    private final Object mLock = new Object();
-
-    private final SharedPreferences mSharedPref;
-
-    @GuardedBy("mLock")
-    private Set<Integer> mActivePorts;
-
-    @GuardedBy("mLock")
-    private final Set<Integer> mEnabledPorts;
-
-    @GuardedBy("mLock")
-    private final Set<Listener> mListeners;
-
-    private PortsStateManager(SharedPreferences sharedPref) {
-        mSharedPref = sharedPref;
-        mEnabledPorts =
-                mSharedPref.getAll().entrySet().stream()
-                        .filter(entry -> entry.getValue() instanceof Integer)
-                        .filter(entry -> ((int) entry.getValue() & FLAG_ENABLED) == FLAG_ENABLED)
-                        .map(entry -> entry.getKey())
-                        .filter(
-                                key -> {
-                                    try {
-                                        Integer.parseInt(key);
-                                        return true;
-                                    } catch (NumberFormatException e) {
-                                        return false;
-                                    }
-                                })
-                        .map(Integer::parseInt)
-                        .collect(Collectors.toSet());
-        mActivePorts = new HashSet<>();
-        mListeners = new HashSet<>();
-    }
-
-    static synchronized PortsStateManager getInstance(Context context) {
-        if (mInstance == null) {
-            SharedPreferences sharedPref =
-                    context.getSharedPreferences(
-                            context.getPackageName() + PREFS_NAME, Context.MODE_PRIVATE);
-            mInstance = new PortsStateManager(sharedPref);
-        }
-        return mInstance;
-    }
-
-    Set<Integer> getActivePorts() {
-        synchronized (mLock) {
-            return new HashSet<>(mActivePorts);
-        }
-    }
-
-    Set<Integer> getEnabledPorts() {
-        synchronized (mLock) {
-            return new HashSet<>(mEnabledPorts);
-        }
-    }
-
-    void updateActivePorts(Set<Integer> ports) {
-        Set<Integer> oldPorts;
-        synchronized (mLock) {
-            oldPorts = mActivePorts;
-            mActivePorts = ports;
-        }
-        notifyPortsStateUpdated(oldPorts, ports);
-    }
-
-    void updateEnabledPort(int port, boolean enabled) {
-        Set<Integer> activePorts;
-        synchronized (mLock) {
-            SharedPreferences.Editor editor = mSharedPref.edit();
-            editor.putInt(String.valueOf(port), enabled ? FLAG_ENABLED : 0);
-            editor.apply();
-            if (enabled) {
-                mEnabledPorts.add(port);
-            } else {
-                mEnabledPorts.remove(port);
-            }
-            activePorts = mActivePorts;
-        }
-        notifyPortsStateUpdated(activePorts, activePorts);
-    }
-
-    void clearEnabledPorts() {
-        Set<Integer> activePorts;
-        synchronized (mLock) {
-            SharedPreferences.Editor editor = mSharedPref.edit();
-            editor.clear();
-            editor.apply();
-            mEnabledPorts.clear();
-            activePorts = mActivePorts;
-        }
-        notifyPortsStateUpdated(activePorts, activePorts);
-    }
-
-    void registerListener(Listener listener) {
-        synchronized (mLock) {
-            mListeners.add(listener);
-        }
-    }
-
-    void unregisterListener(Listener listener) {
-        synchronized (mLock) {
-            mListeners.remove(listener);
-        }
-    }
-
-    private void notifyPortsStateUpdated(Set<Integer> oldActivePorts, Set<Integer> newActivePorts) {
-        Set<Listener> listeners;
-        synchronized (mLock) {
-            listeners = new HashSet<>(mListeners);
-        }
-        for (Listener listener : listeners) {
-            listener.onPortsStateUpdated(
-                    new HashSet<>(oldActivePorts), new HashSet<>(newActivePorts));
-        }
-    }
-
-    interface Listener {
-        default void onPortsStateUpdated(
-                Set<Integer> oldActivePorts, Set<Integer> newActivePorts) {}
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt b/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt
new file mode 100644
index 0000000..736176a
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/PortsStateManager.kt
@@ -0,0 +1,137 @@
+/*
+ * 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.content.Context
+import android.content.SharedPreferences
+import com.android.internal.annotations.GuardedBy
+import java.util.HashSet
+
+/**
+ * PortsStateManager is responsible for communicating with shared preferences and managing state of
+ * ports.
+ */
+class PortsStateManager private constructor(private val sharedPref: SharedPreferences) {
+    private val lock = Any()
+
+    @GuardedBy("lock") private var activePorts: MutableSet<Int> = hashSetOf()
+
+    @GuardedBy("lock")
+    private val enabledPorts: MutableSet<Int> =
+        sharedPref
+            .getAll()
+            .entries
+            .filterIsInstance<MutableMap.MutableEntry<String, Int>>()
+            .filter { it.value and FLAG_ENABLED == FLAG_ENABLED }
+            .map { it.key.toIntOrNull() }
+            .filterNotNull()
+            .toMutableSet()
+
+    @GuardedBy("lock") private val listeners: MutableSet<Listener> = hashSetOf()
+
+    fun getActivePorts(): MutableSet<Int> {
+        synchronized(lock) {
+            return HashSet<Int>(activePorts)
+        }
+    }
+
+    fun getEnabledPorts(): MutableSet<Int> {
+        synchronized(lock) {
+            return HashSet<Int>(enabledPorts)
+        }
+    }
+
+    fun updateActivePorts(ports: MutableSet<Int>) {
+        var oldPorts: MutableSet<Int>
+        synchronized(lock) {
+            oldPorts = activePorts
+            activePorts = ports
+        }
+        notifyPortsStateUpdated(oldPorts, ports)
+    }
+
+    fun updateEnabledPort(port: Int, enabled: Boolean) {
+        var activePorts: MutableSet<Int>
+        synchronized(lock) {
+            val editor = sharedPref.edit()
+            editor.putInt(port.toString(), if (enabled) FLAG_ENABLED else 0)
+            editor.apply()
+            if (enabled) {
+                enabledPorts.add(port)
+            } else {
+                enabledPorts.remove(port)
+            }
+            activePorts = this@PortsStateManager.activePorts
+        }
+        notifyPortsStateUpdated(activePorts, activePorts)
+    }
+
+    fun clearEnabledPorts() {
+        var activePorts: MutableSet<Int>
+        synchronized(lock) {
+            val editor = sharedPref.edit()
+            editor.clear()
+            editor.apply()
+            enabledPorts.clear()
+            activePorts = this@PortsStateManager.activePorts
+        }
+        notifyPortsStateUpdated(activePorts, activePorts)
+    }
+
+    fun registerListener(listener: Listener) {
+        synchronized(lock) { listeners.add(listener) }
+    }
+
+    fun unregisterListener(listener: Listener) {
+        synchronized(lock) { listeners.remove(listener) }
+    }
+
+    private fun notifyPortsStateUpdated(
+        oldActivePorts: MutableSet<Int>,
+        newActivePorts: MutableSet<Int>,
+    ) {
+        var listeners: MutableSet<Listener>
+        synchronized(lock) { listeners = HashSet<Listener>(this@PortsStateManager.listeners) }
+        for (listener in listeners) {
+            listener.onPortsStateUpdated(HashSet<Int>(oldActivePorts), HashSet<Int>(newActivePorts))
+        }
+    }
+
+    interface Listener {
+        fun onPortsStateUpdated(oldActivePorts: Set<Int>, newActivePorts: Set<Int>) {}
+    }
+
+    companion object {
+        private const val PREFS_NAME = ".PORTS"
+        private const val FLAG_ENABLED = 1
+
+        private var instance: PortsStateManager? = null
+
+        @JvmStatic
+        @Synchronized
+        fun getInstance(context: Context): PortsStateManager {
+            if (instance == null) {
+                val sharedPref =
+                    context.getSharedPreferences(
+                        context.getPackageName() + PREFS_NAME,
+                        Context.MODE_PRIVATE,
+                    )
+                instance = PortsStateManager(sharedPref)
+            }
+            return instance!!
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java
deleted file mode 100644
index 4094025..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/Runner.java
+++ /dev/null
@@ -1,115 +0,0 @@
-/*
- * 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 static com.android.virtualization.terminal.MainActivity.TAG;
-
-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 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/TerminalApp/java/com/android/virtualization/terminal/Runner.kt b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt
new file mode 100644
index 0000000..897e182
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/Runner.kt
@@ -0,0 +1,93 @@
+/*
+ * 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.content.Context
+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 com.android.virtualization.terminal.MainActivity.TAG
+import java.util.concurrent.CompletableFuture
+import java.util.concurrent.ForkJoinPool
+
+/** Utility class for creating a VM and waiting for it to finish. */
+internal class Runner private constructor(val vm: VirtualMachine?, callback: Callback) {
+    /** Get future about VM's exit status. */
+    val exitStatus = callback.finishedSuccessfully
+
+    private class Callback : VirtualMachineCallback {
+        val finishedSuccessfully: CompletableFuture<Boolean> = CompletableFuture<Boolean>()
+
+        override fun onPayloadStarted(vm: VirtualMachine) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        override fun onPayloadReady(vm: VirtualMachine) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        override fun onPayloadFinished(vm: VirtualMachine, exitCode: Int) {
+            // This event is only from Microdroid-based VM. Custom VM shouldn't emit this.
+        }
+
+        override fun onError(vm: VirtualMachine, errorCode: Int, message: String) {
+            Log.e(TAG, "Error from VM. code: $errorCode ($message)")
+            finishedSuccessfully.complete(false)
+        }
+
+        override fun onStopped(vm: VirtualMachine, reason: Int) {
+            Log.d(TAG, "VM stopped. Reason: $reason")
+            finishedSuccessfully.complete(true)
+        }
+    }
+
+    companion object {
+        /** Create a virtual machine of the given config, under the given context. */
+        @JvmStatic
+        @Throws(VirtualMachineException::class)
+        fun create(context: Context, config: VirtualMachineConfig): Runner {
+            // 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.
+            val appContext = context.getApplicationContext()
+            val vmm =
+                appContext.getSystemService<VirtualMachineManager>(
+                    VirtualMachineManager::class.java
+                )
+            val customConfig = config.customImageConfig
+            requireNotNull(customConfig) { "CustomImageConfig is missing" }
+
+            val name = customConfig.name
+            require(!name.isNullOrEmpty()) { "Virtual machine's name is missing in the config" }
+
+            var vm = vmm.getOrCreate(name, config)
+            try {
+                vm.config = config
+            } catch (e: VirtualMachineException) {
+                vmm.delete(name)
+                vm = vmm.create(name, config)
+                Log.w(TAG, "Re-creating virtual machine ($name)", e)
+            }
+
+            val cb = Callback()
+            vm.setCallback(ForkJoinPool.commonPool(), cb)
+            vm.run()
+            return Runner(vm, cb)
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.java
deleted file mode 100644
index 4ab2b77..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.java
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
- * 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.
- */
-
-package com.android.virtualization.terminal;
-
-import android.content.Context;
-import android.util.Log;
-
-public class TerminalExceptionHandler implements Thread.UncaughtExceptionHandler {
-    private static final String TAG = "TerminalExceptionHandler";
-
-    private final Context mContext;
-
-    public TerminalExceptionHandler(Context context) {
-        mContext = context;
-    }
-
-    @Override
-    public void uncaughtException(Thread thread, Throwable throwable) {
-        Exception exception;
-        if (throwable instanceof Exception) {
-            exception = (Exception) throwable;
-        } else {
-            exception = new Exception(throwable);
-        }
-        try {
-            ErrorActivity.start(mContext, exception);
-        } catch (Exception ex) {
-            Log.wtf(TAG, "Failed to launch error activity for an exception", exception);
-        }
-
-        thread.getDefaultUncaughtExceptionHandler().uncaughtException(thread, throwable);
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.kt
new file mode 100644
index 0000000..3a8c444
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalExceptionHandler.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.util.Log
+import java.lang.Exception
+
+class TerminalExceptionHandler(private val context: Context) : Thread.UncaughtExceptionHandler {
+
+    override fun uncaughtException(thread: Thread, throwable: Throwable) {
+        val exception = (throwable as? Exception) ?: Exception(throwable)
+        try {
+            ErrorActivity.start(context, exception)
+        } catch (_: Exception) {
+            Log.wtf(TAG, "Failed to launch error activity for an exception", exception)
+        }
+        Thread.getDefaultUncaughtExceptionHandler()?.uncaughtException(thread, throwable)
+    }
+
+    companion object {
+        private const val TAG = "TerminalExceptionHandler"
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.java
deleted file mode 100644
index 5ee535d..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.java
+++ /dev/null
@@ -1,37 +0,0 @@
-/*
- * 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.
- */
-
-package com.android.virtualization.terminal;
-
-import android.content.Context;
-
-import java.util.concurrent.Executors;
-import java.util.concurrent.ThreadFactory;
-
-public class TerminalThreadFactory implements ThreadFactory {
-    private final Context mContext;
-
-    public TerminalThreadFactory(Context context) {
-        mContext = context;
-    }
-
-    @Override
-    public Thread newThread(Runnable r) {
-        Thread thread = Executors.defaultThreadFactory().newThread(r);
-        thread.setUncaughtExceptionHandler(new TerminalExceptionHandler(mContext));
-        return thread;
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.kt
new file mode 100644
index 0000000..f8e909d
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalThreadFactory.kt
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import java.util.concurrent.Executors
+import java.util.concurrent.ThreadFactory
+
+class TerminalThreadFactory(private val context: Context) : ThreadFactory {
+    override fun newThread(r: Runnable): Thread {
+        return Executors.defaultThreadFactory().newThread(r).also {
+            it.uncaughtExceptionHandler = TerminalExceptionHandler(context)
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
deleted file mode 100644
index 0ffc093..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
+++ /dev/null
@@ -1,313 +0,0 @@
-/*
- * 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 static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.content.Context;
-import android.graphics.Rect;
-import android.os.Bundle;
-import android.text.InputType;
-import android.text.TextUtils;
-import android.util.AttributeSet;
-import android.util.Log;
-import android.view.View;
-import android.view.View.AccessibilityDelegate;
-import android.view.ViewGroup;
-import android.view.accessibility.AccessibilityEvent;
-import android.view.accessibility.AccessibilityManager;
-import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
-import android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener;
-import android.view.accessibility.AccessibilityNodeInfo;
-import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
-import android.view.accessibility.AccessibilityNodeProvider;
-import android.view.inputmethod.EditorInfo;
-import android.view.inputmethod.InputConnection;
-import android.webkit.WebView;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.List;
-
-public class TerminalView extends WebView
-        implements AccessibilityStateChangeListener, TouchExplorationStateChangeListener {
-    // Maximum length of texts the talk back announcements can be. This value is somewhat
-    // arbitrarily set. We may want to adjust this in the future.
-    private static final int TEXT_TOO_LONG_TO_ANNOUNCE = 200;
-
-    private final String CTRL_KEY_HANDLER;
-    private final String ENABLE_CTRL_KEY;
-    private final String TOUCH_TO_MOUSE_HANDLER;
-
-    private final AccessibilityManager mA11yManager;
-
-    public TerminalView(Context context, AttributeSet attrs) {
-        super(context, attrs);
-
-        mA11yManager = context.getSystemService(AccessibilityManager.class);
-        mA11yManager.addTouchExplorationStateChangeListener(this);
-        mA11yManager.addAccessibilityStateChangeListener(this);
-        adjustToA11yStateChange();
-        try {
-            CTRL_KEY_HANDLER = readAssetAsString(context, "js/ctrl_key_handler.js");
-            ENABLE_CTRL_KEY = readAssetAsString(context, "js/enable_ctrl_key.js");
-            TOUCH_TO_MOUSE_HANDLER = readAssetAsString(context, "js/touch_to_mouse_handler.js");
-        } catch (IOException e) {
-            // It cannot happen
-            throw new IllegalArgumentException("cannot read code from asset", e);
-        }
-    }
-
-    private String readAssetAsString(Context context, String filePath) throws IOException {
-        try (InputStream is = context.getAssets().open(filePath)) {
-            return new String(is.readAllBytes());
-        }
-    }
-
-    public void mapTouchToMouseEvent() {
-        this.evaluateJavascript(TOUCH_TO_MOUSE_HANDLER, null);
-    }
-
-    public void mapCtrlKey() {
-        this.evaluateJavascript(CTRL_KEY_HANDLER, null);
-    }
-
-    public void enableCtrlKey() {
-        this.evaluateJavascript(ENABLE_CTRL_KEY, null);
-    }
-
-    @Override
-    public void onAccessibilityStateChanged(boolean enabled) {
-        Log.d(TAG, "accessibility " + enabled);
-        adjustToA11yStateChange();
-    }
-
-    @Override
-    public void onTouchExplorationStateChanged(boolean enabled) {
-        Log.d(TAG, "touch exploration " + enabled);
-        adjustToA11yStateChange();
-    }
-
-    private void adjustToA11yStateChange() {
-        if (!mA11yManager.isEnabled()) {
-            setFocusable(true);
-            return;
-        }
-
-        // When accessibility is on, the webview itself doesn't have to be focusable. The (virtual)
-        // edittext will be focusable to accept inputs. However, the webview has to be focusable for
-        // an accessibility purpose so that users can read the contents in it or scroll the view.
-        setFocusable(false);
-        setFocusableInTouchMode(true);
-    }
-
-    // AccessibilityEvents for WebView are sent directly from WebContentsAccessibilityImpl to the
-    // parent of WebView, without going through WebView. So, there's no WebView methods we can
-    // override to intercept the event handling process. To work around this, we attach an
-    // AccessibilityDelegate to the parent view where the events are sent to. And to guarantee that
-    // the parent view exists, wait until the WebView is attached to the window by when the parent
-    // must exist.
-    private final AccessibilityDelegate mA11yEventFilter =
-            new AccessibilityDelegate() {
-                @Override
-                public boolean onRequestSendAccessibilityEvent(
-                        ViewGroup host, View child, AccessibilityEvent e) {
-                    // We filter only the a11y events from the WebView
-                    if (child != TerminalView.this) {
-                        return super.onRequestSendAccessibilityEvent(host, child, e);
-                    }
-                    final int eventType = e.getEventType();
-                    switch (e.getEventType()) {
-                            // Skip reading texts that are too long. Right now, ttyd emits entire
-                            // text on the terminal to the live region, which is very annoying to
-                            // screen reader users.
-                        case AccessibilityEvent.TYPE_ANNOUNCEMENT:
-                            CharSequence text = e.getText().get(0); // there always is a text
-                            if (text.length() >= TEXT_TOO_LONG_TO_ANNOUNCE) {
-                                Log.i(TAG, "Announcement skipped because it's too long: " + text);
-                                return false;
-                            }
-                            break;
-                    }
-                    return super.onRequestSendAccessibilityEvent(host, child, e);
-                }
-            };
-
-    @Override
-    protected void onAttachedToWindow() {
-        super.onAttachedToWindow();
-        if (mA11yManager.isEnabled()) {
-            View parent = (View) getParent();
-            parent.setAccessibilityDelegate(mA11yEventFilter);
-        }
-    }
-
-    private final AccessibilityNodeProvider mA11yNodeProvider =
-            new AccessibilityNodeProvider() {
-
-                /** Returns the original NodeProvider that WebView implements. */
-                private AccessibilityNodeProvider getParent() {
-                    return TerminalView.super.getAccessibilityNodeProvider();
-                }
-
-                /** Convenience method for reading a string resource. */
-                private String getString(int resId) {
-                    return TerminalView.this.getContext().getResources().getString(resId);
-                }
-
-                /** Checks if NodeInfo renders an empty line in the terminal. */
-                private boolean isEmptyLine(AccessibilityNodeInfo info) {
-                    final CharSequence text = info.getText();
-                    // Node with no text is not consiered a line. ttyd emits at least one character,
-                    // which usually is NBSP.
-                    if (text == null) {
-                        return false;
-                    }
-                    for (int i = 0; i < text.length(); i++) {
-                        char c = text.charAt(i);
-                        // Note: don't use Characters.isWhitespace as it doesn't recognize NBSP as a
-                        // whitespace.
-                        if (!TextUtils.isWhitespace(c)) {
-                            return false;
-                        }
-                    }
-                    return true;
-                }
-
-                @Override
-                public AccessibilityNodeInfo createAccessibilityNodeInfo(int id) {
-                    AccessibilityNodeInfo info = getParent().createAccessibilityNodeInfo(id);
-                    if (info == null) {
-                        return null;
-                    }
-
-                    final String className = info.getClassName().toString();
-
-                    // By default all views except the cursor is not click-able. Other views are
-                    // read-only. This ensures that user is not navigated to non-clickable elements
-                    // when using switches.
-                    if (!"android.widget.EditText".equals(className)) {
-                        info.removeAction(AccessibilityAction.ACTION_CLICK);
-                    }
-
-                    switch (className) {
-                        case "android.webkit.WebView":
-                            // There are two NodeInfo objects of class name WebView. The one is the
-                            // real WebView whose ID is View.NO_ID as it's at the root of the
-                            // virtual view hierarchy. The second one is a virtual view for the
-                            // iframe. The latter one's text is set to the command that we give to
-                            // ttyd, which is "login -f droid ...". This is an impl detail which
-                            // doesn't have to be announced.  Replace the text with "Terminal
-                            // display".
-                            if (id != View.NO_ID) {
-                                info.setText(null);
-                                info.setContentDescription(getString(R.string.terminal_display));
-                                // b/376827536
-                                info.setHintText(getString(R.string.double_tap_to_edit_text));
-                            }
-
-                            // These two lines below are to prevent this WebView element from being
-                            // fousable by the screen reader, while allowing any other element in
-                            // the WebView to be focusable by the reader. In our case, the EditText
-                            // is a117_focusable.
-                            info.setScreenReaderFocusable(false);
-                            info.addAction(AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS);
-                            break;
-                        case "android.view.View":
-                            // Empty line was announced as "space" (via the NBSP character).
-                            // Localize the spoken text.
-                            if (isEmptyLine(info)) {
-                                info.setContentDescription(getString(R.string.empty_line));
-                                // b/376827536
-                                info.setHintText(getString(R.string.double_tap_to_edit_text));
-                            }
-                            break;
-                        case "android.widget.TextView":
-                            // There are several TextViews in the terminal, and one of them is an
-                            // invisible TextView which seems to be from the <div
-                            // class="live-region"> tag. Interestingly, its text is often populated
-                            // with the entire text on the screen. Silence this by forcibly setting
-                            // the text to null. Note that this TextView is identified by having a
-                            // zero width. This certainly is not elegant, but I couldn't find other
-                            // options.
-                            Rect rect = new Rect();
-                            info.getBoundsInScreen(rect);
-                            if (rect.width() == 0) {
-                                info.setText(null);
-                                info.setContentDescription(getString(R.string.empty_line));
-                            }
-                            info.setScreenReaderFocusable(false);
-                            break;
-                        case "android.widget.EditText":
-                            // This EditText is for the <textarea> accepting user input; the cursor.
-                            // ttyd name it as "Terminal input" but it's not i18n'ed. Override it
-                            // here for better i18n.
-                            info.setText(null);
-                            info.setHintText(getString(R.string.double_tap_to_edit_text));
-                            info.setContentDescription(getString(R.string.terminal_input));
-                            info.setScreenReaderFocusable(true);
-                            info.addAction(AccessibilityAction.ACTION_FOCUS);
-                            break;
-                    }
-                    return info;
-                }
-
-                @Override
-                public boolean performAction(int id, int action, Bundle arguments) {
-                    return getParent().performAction(id, action, arguments);
-                }
-
-                @Override
-                public void addExtraDataToAccessibilityNodeInfo(
-                        int virtualViewId,
-                        AccessibilityNodeInfo info,
-                        String extraDataKey,
-                        Bundle arguments) {
-                    getParent()
-                            .addExtraDataToAccessibilityNodeInfo(
-                                    virtualViewId, info, extraDataKey, arguments);
-                }
-
-                @Override
-                public List<AccessibilityNodeInfo> findAccessibilityNodeInfosByText(
-                        String text, int virtualViewId) {
-                    return getParent().findAccessibilityNodeInfosByText(text, virtualViewId);
-                }
-
-                @Override
-                public AccessibilityNodeInfo findFocus(int focus) {
-                    return getParent().findFocus(focus);
-                }
-            };
-
-    @Override
-    public AccessibilityNodeProvider getAccessibilityNodeProvider() {
-        AccessibilityNodeProvider p = super.getAccessibilityNodeProvider();
-        if (p != null && mA11yManager.isEnabled()) {
-            return mA11yNodeProvider;
-        }
-        return p;
-    }
-
-    @Override
-    public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
-        InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
-        if (outAttrs != null) {
-            outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
-        }
-        return inputConnection;
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
new file mode 100644
index 0000000..18a39fa
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
@@ -0,0 +1,283 @@
+/*
+ * 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.content.Context
+import android.graphics.Rect
+import android.os.Bundle
+import android.text.InputType
+import android.text.TextUtils
+import android.util.AttributeSet
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityManager
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityNodeProvider
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.webkit.WebView
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.IOException
+
+class TerminalView(context: Context, attrs: AttributeSet?) :
+    WebView(context, attrs),
+    AccessibilityManager.AccessibilityStateChangeListener,
+    AccessibilityManager.TouchExplorationStateChangeListener {
+    private val ctrlKeyHandler: String = readAssetAsString(context, "js/ctrl_key_handler.js")
+    private val enableCtrlKey: String = readAssetAsString(context, "js/enable_ctrl_key.js")
+    private val touchToMouseHandler: String =
+        readAssetAsString(context, "js/touch_to_mouse_handler.js")
+    private val a11yManager =
+        context.getSystemService<AccessibilityManager>(AccessibilityManager::class.java).also {
+            it.addTouchExplorationStateChangeListener(this)
+            it.addAccessibilityStateChangeListener(this)
+        }
+
+    @Throws(IOException::class)
+    private fun readAssetAsString(context: Context, filePath: String): String {
+        return String(context.assets.open(filePath).readAllBytes())
+    }
+
+    fun mapTouchToMouseEvent() {
+        this.evaluateJavascript(touchToMouseHandler, null)
+    }
+
+    fun mapCtrlKey() {
+        this.evaluateJavascript(ctrlKeyHandler, null)
+    }
+
+    fun enableCtrlKey() {
+        this.evaluateJavascript(enableCtrlKey, null)
+    }
+
+    override fun onAccessibilityStateChanged(enabled: Boolean) {
+        Log.d(TAG, "accessibility $enabled")
+        adjustToA11yStateChange()
+    }
+
+    override fun onTouchExplorationStateChanged(enabled: Boolean) {
+        Log.d(TAG, "touch exploration $enabled")
+        adjustToA11yStateChange()
+    }
+
+    private fun adjustToA11yStateChange() {
+        if (!a11yManager.isEnabled) {
+            setFocusable(true)
+            return
+        }
+
+        // When accessibility is on, the webview itself doesn't have to be focusable. The (virtual)
+        // edittext will be focusable to accept inputs. However, the webview has to be focusable for
+        // an accessibility purpose so that users can read the contents in it or scroll the view.
+        setFocusable(false)
+        setFocusableInTouchMode(true)
+    }
+
+    // AccessibilityEvents for WebView are sent directly from WebContentsAccessibilityImpl to the
+    // parent of WebView, without going through WebView. So, there's no WebView methods we can
+    // override to intercept the event handling process. To work around this, we attach an
+    // AccessibilityDelegate to the parent view where the events are sent to. And to guarantee that
+    // the parent view exists, wait until the WebView is attached to the window by when the parent
+    // must exist.
+    private val mA11yEventFilter: AccessibilityDelegate =
+        object : AccessibilityDelegate() {
+            override fun onRequestSendAccessibilityEvent(
+                host: ViewGroup,
+                child: View,
+                e: AccessibilityEvent,
+            ): Boolean {
+                // We filter only the a11y events from the WebView
+                if (child !== this@TerminalView) {
+                    return super.onRequestSendAccessibilityEvent(host, child, e)
+                }
+                when (e.eventType) {
+                    AccessibilityEvent.TYPE_ANNOUNCEMENT -> {
+                        val text = e.text[0] // there always is a text
+                        if (text.length >= TEXT_TOO_LONG_TO_ANNOUNCE) {
+                            Log.i(TAG, "Announcement skipped because it's too long: $text")
+                            return false
+                        }
+                    }
+                }
+                return super.onRequestSendAccessibilityEvent(host, child, e)
+            }
+        }
+
+    override fun onAttachedToWindow() {
+        super.onAttachedToWindow()
+        if (a11yManager.isEnabled) {
+            val parent = getParent() as View
+            parent.setAccessibilityDelegate(mA11yEventFilter)
+        }
+    }
+
+    private val mA11yNodeProvider: AccessibilityNodeProvider =
+        object : AccessibilityNodeProvider() {
+            /** Returns the original NodeProvider that WebView implements. */
+            private fun getParent(): AccessibilityNodeProvider? {
+                return super@TerminalView.getAccessibilityNodeProvider()
+            }
+
+            /** Convenience method for reading a string resource. */
+            private fun getString(resId: Int): String {
+                return this@TerminalView.context.getResources().getString(resId)
+            }
+
+            /** Checks if NodeInfo renders an empty line in the terminal. */
+            private fun isEmptyLine(info: AccessibilityNodeInfo): Boolean {
+                // Node with no text is not considered a line. ttyd emits at least one character,
+                // which usually is NBSP.
+                // Note: don't use Characters.isWhitespace as it doesn't recognize NBSP as a
+                // whitespace.
+                return (info.getText()?.all { TextUtils.isWhitespace(it.code) }) == true
+            }
+
+            override fun createAccessibilityNodeInfo(id: Int): AccessibilityNodeInfo? {
+                val info: AccessibilityNodeInfo? = getParent()?.createAccessibilityNodeInfo(id)
+                if (info == null) {
+                    return null
+                }
+
+                val className = info.className.toString()
+
+                // By default all views except the cursor is not click-able. Other views are
+                // read-only. This ensures that user is not navigated to non-clickable elements
+                // when using switches.
+                if ("android.widget.EditText" != className) {
+                    info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK)
+                }
+
+                when (className) {
+                    "android.webkit.WebView" -> {
+                        // There are two NodeInfo objects of class name WebView. The one is the
+                        // real WebView whose ID is View.NO_ID as it's at the root of the
+                        // virtual view hierarchy. The second one is a virtual view for the
+                        // iframe. The latter one's text is set to the command that we give to
+                        // ttyd, which is "login -f droid ...". This is an impl detail which
+                        // doesn't have to be announced.  Replace the text with "Terminal
+                        // display".
+                        if (id != NO_ID) {
+                            info.setText(null)
+                            info.setContentDescription(getString(R.string.terminal_display))
+                            // b/376827536
+                            info.setHintText(getString(R.string.double_tap_to_edit_text))
+                        }
+
+                        // These two lines below are to prevent this WebView element from being
+                        // focusable by the screen reader, while allowing any other element in
+                        // the WebView to be focusable by the reader. In our case, the EditText
+                        // is a117_focusable.
+                        info.isScreenReaderFocusable = false
+                        info.addAction(
+                            AccessibilityNodeInfo.AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS
+                        )
+                    }
+
+                    "android.view.View" ->
+                        // Empty line was announced as "space" (via the NBSP character).
+                        // Localize the spoken text.
+                        if (isEmptyLine(info)) {
+                            info.setContentDescription(getString(R.string.empty_line))
+                            // b/376827536
+                            info.setHintText(getString(R.string.double_tap_to_edit_text))
+                        }
+
+                    "android.widget.TextView" -> {
+                        // There are several TextViews in the terminal, and one of them is an
+                        // invisible TextView which seems to be from the <div
+                        // class="live-region"> tag. Interestingly, its text is often populated
+                        // with the entire text on the screen. Silence this by forcibly setting
+                        // the text to null. Note that this TextView is identified by having a
+                        // zero width. This certainly is not elegant, but I couldn't find other
+                        // options.
+                        val rect = Rect()
+                        info.getBoundsInScreen(rect)
+                        if (rect.width() == 0) {
+                            info.setText(null)
+                            info.setContentDescription(getString(R.string.empty_line))
+                        }
+                        info.isScreenReaderFocusable = false
+                    }
+
+                    "android.widget.EditText" -> {
+                        // This EditText is for the <textarea> accepting user input; the cursor.
+                        // ttyd name it as "Terminal input" but it's not i18n'ed. Override it
+                        // here for better i18n.
+                        info.setText(null)
+                        info.setHintText(getString(R.string.double_tap_to_edit_text))
+                        info.setContentDescription(getString(R.string.terminal_input))
+                        info.isScreenReaderFocusable = true
+                        info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_FOCUS)
+                    }
+                }
+                return info
+            }
+
+            override fun performAction(id: Int, action: Int, arguments: Bundle?): Boolean {
+                return getParent()?.performAction(id, action, arguments) == true
+            }
+
+            override fun addExtraDataToAccessibilityNodeInfo(
+                virtualViewId: Int,
+                info: AccessibilityNodeInfo?,
+                extraDataKey: String?,
+                arguments: Bundle?,
+            ) {
+                getParent()
+                    ?.addExtraDataToAccessibilityNodeInfo(
+                        virtualViewId,
+                        info,
+                        extraDataKey,
+                        arguments,
+                    )
+            }
+
+            override fun findAccessibilityNodeInfosByText(
+                text: String?,
+                virtualViewId: Int,
+            ): MutableList<AccessibilityNodeInfo?>? {
+                return getParent()?.findAccessibilityNodeInfosByText(text, virtualViewId)
+            }
+
+            override fun findFocus(focus: Int): AccessibilityNodeInfo? {
+                return getParent()?.findFocus(focus)
+            }
+        }
+
+    override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider? {
+        val p = super.getAccessibilityNodeProvider()
+        if (p != null && a11yManager.isEnabled) {
+            return mA11yNodeProvider
+        }
+        return p
+    }
+
+    override fun onCreateInputConnection(outAttrs: EditorInfo?): InputConnection? {
+        val inputConnection = super.onCreateInputConnection(outAttrs)
+        if (outAttrs != null) {
+            outAttrs.inputType = outAttrs.inputType or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+        }
+        return inputConnection
+    }
+
+    companion object {
+        // Maximum length of texts the talk back announcements can be. This value is somewhat
+        // arbitrarily set. We may want to adjust this in the future.
+        private const val TEXT_TOO_LONG_TO_ANNOUNCE = 200
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
deleted file mode 100644
index 09b58d3..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
+++ /dev/null
@@ -1,375 +0,0 @@
-/*
- * 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 static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.app.Notification;
-import android.app.NotificationManager;
-import android.app.PendingIntent;
-import android.app.Service;
-import android.content.Context;
-import android.content.Intent;
-import android.graphics.drawable.Icon;
-import android.net.nsd.NsdManager;
-import android.net.nsd.NsdServiceInfo;
-import android.os.Bundle;
-import android.os.Handler;
-import android.os.IBinder;
-import android.os.Looper;
-import android.os.Parcel;
-import android.os.ResultReceiver;
-import android.system.virtualmachine.VirtualMachine;
-import android.system.virtualmachine.VirtualMachineConfig;
-import android.system.virtualmachine.VirtualMachineCustomImageConfig;
-import android.system.virtualmachine.VirtualMachineCustomImageConfig.Disk;
-import android.system.virtualmachine.VirtualMachineException;
-import android.util.Log;
-import android.widget.Toast;
-
-import io.grpc.Grpc;
-import io.grpc.InsecureServerCredentials;
-import io.grpc.Metadata;
-import io.grpc.Server;
-import io.grpc.ServerCall;
-import io.grpc.ServerCallHandler;
-import io.grpc.ServerInterceptor;
-import io.grpc.Status;
-import io.grpc.okhttp.OkHttpServerBuilder;
-
-import java.io.File;
-import java.io.FileOutputStream;
-import java.io.IOException;
-import java.net.InetSocketAddress;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.util.Objects;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-public class VmLauncherService extends Service {
-    private static final String EXTRA_NOTIFICATION = "EXTRA_NOTIFICATION";
-    private static final String ACTION_START_VM_LAUNCHER_SERVICE =
-            "android.virtualization.START_VM_LAUNCHER_SERVICE";
-
-    public static final String ACTION_STOP_VM_LAUNCHER_SERVICE =
-            "android.virtualization.STOP_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 ExecutorService mExecutorService;
-    private VirtualMachine mVirtualMachine;
-    private ResultReceiver mResultReceiver;
-    private Server mServer;
-    private DebianServiceImpl mDebianService;
-    private PortNotifier mPortNotifier;
-
-    private static Intent getMyIntent(Context context) {
-        return new Intent(context.getApplicationContext(), VmLauncherService.class);
-    }
-
-    public interface VmLauncherServiceCallback {
-        void onVmStart();
-
-        void onVmStop();
-
-        void onVmError();
-    }
-
-    public static void run(
-            Context context, VmLauncherServiceCallback callback, Notification notification) {
-        Intent i = getMyIntent(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;
-                        }
-                    }
-                };
-        i.putExtra(Intent.EXTRA_RESULT_RECEIVER, getResultReceiverForIntent(resultReceiver));
-        i.putExtra(VmLauncherService.EXTRA_NOTIFICATION, notification);
-        context.startForegroundService(i);
-    }
-
-    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;
-    }
-
-    @Override
-    public IBinder onBind(Intent intent) {
-        return null;
-    }
-
-    @Override
-    public int onStartCommand(Intent intent, int flags, int startId) {
-        if (Objects.equals(intent.getAction(), ACTION_STOP_VM_LAUNCHER_SERVICE)) {
-
-            if (mDebianService != null && mDebianService.shutdownDebian()) {
-                // During shutdown, change the notification content to indicate that it's closing
-                Notification notification = createNotificationForTerminalClose();
-                getSystemService(NotificationManager.class).notify(this.hashCode(), notification);
-            } else {
-                // If there is no Debian service or it fails to shutdown, just stop the service.
-                stopSelf();
-            }
-            return START_NOT_STICKY;
-        }
-        if (mVirtualMachine != null) {
-            Log.d(TAG, "VM instance is already started");
-            return START_NOT_STICKY;
-        }
-        mExecutorService =
-                Executors.newCachedThreadPool(new TerminalThreadFactory(getApplicationContext()));
-
-        InstalledImage image = InstalledImage.getDefault(this);
-        ConfigJson json = ConfigJson.from(this, image.getConfigPath());
-        VirtualMachineConfig.Builder configBuilder = json.toConfigBuilder(this);
-        VirtualMachineCustomImageConfig.Builder customImageConfigBuilder =
-                json.toCustomImageConfigBuilder(this);
-        if (overrideConfigIfNecessary(customImageConfigBuilder)) {
-            configBuilder.setCustomImageConfig(customImageConfigBuilder.build());
-        }
-        VirtualMachineConfig config = configBuilder.build();
-
-        Runner runner;
-        try {
-            android.os.Trace.beginSection("vmCreate");
-            runner = Runner.create(this, config);
-            android.os.Trace.endSection();
-            android.os.Trace.beginAsyncSection("debianBoot", 0);
-        } catch (VirtualMachineException e) {
-            throw new RuntimeException("cannot create runner", e);
-        }
-        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);
-                            }
-                            stopSelf();
-                        });
-        Path logPath = getFileStreamPath(mVirtualMachine.getName() + ".log").toPath();
-        Logger.setup(mVirtualMachine, logPath, mExecutorService);
-
-        Notification notification =
-                intent.getParcelableExtra(EXTRA_NOTIFICATION, Notification.class);
-
-        startForeground(this.hashCode(), notification);
-
-        mResultReceiver.send(RESULT_START, null);
-
-        mPortNotifier = new PortNotifier(this);
-
-        // TODO: dedup this part
-        NsdManager nsdManager = getSystemService(NsdManager.class);
-        NsdServiceInfo info = new NsdServiceInfo();
-        info.setServiceType("_http._tcp");
-        info.setServiceName("ttyd");
-        nsdManager.registerServiceInfoCallback(
-                info,
-                mExecutorService,
-                new NsdManager.ServiceInfoCallback() {
-                    @Override
-                    public void onServiceInfoCallbackRegistrationFailed(int errorCode) {}
-
-                    @Override
-                    public void onServiceInfoCallbackUnregistered() {}
-
-                    @Override
-                    public void onServiceLost() {}
-
-                    @Override
-                    public void onServiceUpdated(NsdServiceInfo info) {
-                        nsdManager.unregisterServiceInfoCallback(this);
-                        Log.i(TAG, "Service found: " + info.toString());
-                        String ipAddress = info.getHostAddresses().get(0).getHostAddress();
-                        startDebianServer(ipAddress);
-                    }
-                });
-
-        return START_NOT_STICKY;
-    }
-
-    private Notification createNotificationForTerminalClose() {
-        Intent stopIntent = new Intent();
-        stopIntent.setClass(this, VmLauncherService.class);
-        stopIntent.setAction(VmLauncherService.ACTION_STOP_VM_LAUNCHER_SERVICE);
-        PendingIntent stopPendingIntent =
-                PendingIntent.getService(
-                        this,
-                        0,
-                        stopIntent,
-                        PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
-        Icon icon = Icon.createWithResource(getResources(), R.drawable.ic_launcher_foreground);
-        String stopActionText =
-                getResources().getString(R.string.service_notification_force_quit_action);
-        String stopNotificationTitle =
-                getResources().getString(R.string.service_notification_close_title);
-        return new Notification.Builder(this, this.getPackageName())
-                .setSmallIcon(R.drawable.ic_launcher_foreground)
-                .setContentTitle(stopNotificationTitle)
-                .setOngoing(true)
-                .setSilent(true)
-                .addAction(
-                        new Notification.Action.Builder(icon, stopActionText, stopPendingIntent)
-                                .build())
-                .build();
-    }
-
-    private boolean overrideConfigIfNecessary(VirtualMachineCustomImageConfig.Builder builder) {
-        boolean changed = false;
-        // TODO: check if ANGLE is enabled for the app.
-        if (Files.exists(ImageArchive.getSdcardPathForTesting().resolve("virglrenderer"))) {
-            builder.setGpuConfig(
-                    new VirtualMachineCustomImageConfig.GpuConfig.Builder()
-                            .setBackend("virglrenderer")
-                            .setRendererUseEgl(true)
-                            .setRendererUseGles(true)
-                            .setRendererUseGlx(false)
-                            .setRendererUseSurfaceless(true)
-                            .setRendererUseVulkan(false)
-                            .setContextTypes(new String[] {"virgl2"})
-                            .build());
-            Toast.makeText(this, R.string.virgl_enabled, Toast.LENGTH_SHORT).show();
-            changed = true;
-        }
-
-        InstalledImage image = InstalledImage.getDefault(this);
-        if (image.hasBackup()) {
-            Path backup = image.getBackupFile();
-            builder.addDisk(Disk.RWDisk(backup.toString()));
-            changed = true;
-        }
-        return changed;
-    }
-
-    private void startDebianServer(String ipAddress) {
-        ServerInterceptor interceptor =
-                new ServerInterceptor() {
-                    @Override
-                    public <ReqT, RespT> ServerCall.Listener<ReqT> interceptCall(
-                            ServerCall<ReqT, RespT> call,
-                            Metadata headers,
-                            ServerCallHandler<ReqT, RespT> next) {
-                        InetSocketAddress remoteAddr =
-                                (InetSocketAddress)
-                                        call.getAttributes().get(Grpc.TRANSPORT_ATTR_REMOTE_ADDR);
-
-                        if (remoteAddr != null
-                                && Objects.equals(
-                                        remoteAddr.getAddress().getHostAddress(), ipAddress)) {
-                            // Allow the request only if it is from VM
-                            return next.startCall(call, headers);
-                        }
-                        Log.d(TAG, "blocked grpc request from " + remoteAddr);
-                        call.close(Status.Code.PERMISSION_DENIED.toStatus(), new Metadata());
-                        return new ServerCall.Listener<ReqT>() {};
-                    }
-                };
-        try {
-            // TODO(b/372666638): gRPC for java doesn't support vsock for now.
-            int port = 0;
-            mDebianService = new DebianServiceImpl(this);
-            mServer =
-                    OkHttpServerBuilder.forPort(port, InsecureServerCredentials.create())
-                            .intercept(interceptor)
-                            .addService(mDebianService)
-                            .build()
-                            .start();
-        } catch (IOException e) {
-            Log.d(TAG, "grpc server error", e);
-            return;
-        }
-
-        mExecutorService.execute(
-                () -> {
-                    // TODO(b/373533555): we can use mDNS for that.
-                    String debianServicePortFileName = "debian_service_port";
-                    File debianServicePortFile = new File(getFilesDir(), debianServicePortFileName);
-                    try (FileOutputStream writer = new FileOutputStream(debianServicePortFile)) {
-                        writer.write(String.valueOf(mServer.getPort()).getBytes());
-                    } catch (IOException e) {
-                        Log.d(TAG, "cannot write grpc port number", e);
-                    }
-                });
-    }
-
-    public static void stop(Context context) {
-        Intent i = getMyIntent(context);
-        i.setAction(VmLauncherService.ACTION_STOP_VM_LAUNCHER_SERVICE);
-        context.startService(i);
-    }
-
-    @Override
-    public void onDestroy() {
-        if (mPortNotifier != null) {
-            mPortNotifier.stop();
-        }
-        getSystemService(NotificationManager.class).cancelAll();
-        stopDebianServer();
-        if (mVirtualMachine != null) {
-            if (mVirtualMachine.getStatus() == VirtualMachine.STATUS_RUNNING) {
-                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;
-        }
-        super.onDestroy();
-    }
-
-    private void stopDebianServer() {
-        if (mDebianService != null) {
-            mDebianService.killForwarderHost();
-        }
-        if (mServer != null) {
-            mServer.shutdown();
-        }
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
new file mode 100644
index 0000000..2796b86
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -0,0 +1,355 @@
+/*
+ * 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.Notification
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.app.Service
+import android.content.Context
+import android.content.Intent
+import android.graphics.drawable.Icon
+import android.net.nsd.NsdManager
+import android.net.nsd.NsdServiceInfo
+import android.os.Bundle
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.Parcel
+import android.os.ResultReceiver
+import android.os.Trace
+import android.system.virtualmachine.VirtualMachine
+import android.system.virtualmachine.VirtualMachineCustomImageConfig
+import android.system.virtualmachine.VirtualMachineException
+import android.util.Log
+import android.widget.Toast
+import com.android.virtualization.terminal.MainActivity.TAG
+import com.android.virtualization.terminal.Runner.Companion.create
+import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback
+import io.grpc.Grpc
+import io.grpc.InsecureServerCredentials
+import io.grpc.Metadata
+import io.grpc.Server
+import io.grpc.ServerCall
+import io.grpc.ServerCallHandler
+import io.grpc.ServerInterceptor
+import io.grpc.Status
+import io.grpc.okhttp.OkHttpServerBuilder
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.lang.RuntimeException
+import java.net.InetSocketAddress
+import java.net.SocketAddress
+import java.nio.file.Files
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+
+class VmLauncherService : Service() {
+    // TODO: using lateinit for some fields to avoid null
+    private var mExecutorService: ExecutorService? = null
+    private var mVirtualMachine: VirtualMachine? = null
+    private var mResultReceiver: ResultReceiver? = null
+    private var mServer: Server? = null
+    private var mDebianService: DebianServiceImpl? = null
+    private var mPortNotifier: PortNotifier? = null
+
+    interface VmLauncherServiceCallback {
+        fun onVmStart()
+
+        fun onVmStop()
+
+        fun onVmError()
+    }
+
+    override fun onBind(intent: Intent?): IBinder? {
+        return null
+    }
+
+    override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
+        if (intent.action == ACTION_STOP_VM_LAUNCHER_SERVICE) {
+            if (mDebianService != null && mDebianService!!.shutdownDebian()) {
+                // During shutdown, change the notification content to indicate that it's closing
+                val notification = createNotificationForTerminalClose()
+                getSystemService<NotificationManager?>(NotificationManager::class.java)
+                    .notify(this.hashCode(), notification)
+            } else {
+                // If there is no Debian service or it fails to shutdown, just stop the service.
+                stopSelf()
+            }
+            return START_NOT_STICKY
+        }
+        if (mVirtualMachine != null) {
+            Log.d(TAG, "VM instance is already started")
+            return START_NOT_STICKY
+        }
+        mExecutorService = Executors.newCachedThreadPool(TerminalThreadFactory(applicationContext))
+
+        val image = InstalledImage.getDefault(this)
+        val json = ConfigJson.from(this, image.configPath)
+        val configBuilder = json.toConfigBuilder(this)
+        val customImageConfigBuilder = json.toCustomImageConfigBuilder(this)
+        if (overrideConfigIfNecessary(customImageConfigBuilder)) {
+            configBuilder.setCustomImageConfig(customImageConfigBuilder.build())
+        }
+        val config = configBuilder.build()
+
+        Trace.beginSection("vmCreate")
+        val runner: Runner =
+            try {
+                create(this, config)
+            } catch (e: VirtualMachineException) {
+                throw RuntimeException("cannot create runner", e)
+            }
+        Trace.endSection()
+        Trace.beginAsyncSection("debianBoot", 0)
+
+        mVirtualMachine = runner.vm
+        mResultReceiver =
+            intent.getParcelableExtra<ResultReceiver?>(
+                Intent.EXTRA_RESULT_RECEIVER,
+                ResultReceiver::class.java,
+            )
+
+        runner.exitStatus.thenAcceptAsync { success: Boolean ->
+            mResultReceiver?.send(if (success) RESULT_STOP else RESULT_ERROR, null)
+            stopSelf()
+        }
+        val logPath = getFileStreamPath(mVirtualMachine!!.name + ".log").toPath()
+        Logger.setup(mVirtualMachine!!, logPath, mExecutorService!!)
+
+        val notification =
+            intent.getParcelableExtra<Notification?>(EXTRA_NOTIFICATION, Notification::class.java)
+
+        startForeground(this.hashCode(), notification)
+
+        mResultReceiver!!.send(RESULT_START, null)
+
+        mPortNotifier = PortNotifier(this)
+
+        // TODO: dedup this part
+        val nsdManager = getSystemService<NsdManager?>(NsdManager::class.java)
+        val info = NsdServiceInfo()
+        info.serviceType = "_http._tcp"
+        info.serviceName = "ttyd"
+        nsdManager.registerServiceInfoCallback(
+            info,
+            mExecutorService!!,
+            object : NsdManager.ServiceInfoCallback {
+                override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {}
+
+                override fun onServiceInfoCallbackUnregistered() {}
+
+                override fun onServiceLost() {}
+
+                override fun onServiceUpdated(info: NsdServiceInfo) {
+                    nsdManager.unregisterServiceInfoCallback(this)
+                    Log.i(TAG, "Service found: $info")
+                    startDebianServer(info.hostAddresses[0].hostAddress)
+                }
+            },
+        )
+
+        return START_NOT_STICKY
+    }
+
+    private fun createNotificationForTerminalClose(): Notification {
+        val stopIntent = Intent()
+        stopIntent.setClass(this, VmLauncherService::class.java)
+        stopIntent.setAction(ACTION_STOP_VM_LAUNCHER_SERVICE)
+        val stopPendingIntent =
+            PendingIntent.getService(
+                this,
+                0,
+                stopIntent,
+                PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+            )
+        val icon = Icon.createWithResource(resources, R.drawable.ic_launcher_foreground)
+        val stopActionText: String? =
+            resources.getString(R.string.service_notification_force_quit_action)
+        val stopNotificationTitle: String? =
+            resources.getString(R.string.service_notification_close_title)
+        return Notification.Builder(this, this.packageName)
+            .setSmallIcon(R.drawable.ic_launcher_foreground)
+            .setContentTitle(stopNotificationTitle)
+            .setOngoing(true)
+            .setSilent(true)
+            .addAction(Notification.Action.Builder(icon, stopActionText, stopPendingIntent).build())
+            .build()
+    }
+
+    private fun overrideConfigIfNecessary(
+        builder: VirtualMachineCustomImageConfig.Builder
+    ): Boolean {
+        var changed = false
+        // TODO: check if ANGLE is enabled for the app.
+        if (Files.exists(ImageArchive.getSdcardPathForTesting().resolve("virglrenderer"))) {
+            builder.setGpuConfig(
+                VirtualMachineCustomImageConfig.GpuConfig.Builder()
+                    .setBackend("virglrenderer")
+                    .setRendererUseEgl(true)
+                    .setRendererUseGles(true)
+                    .setRendererUseGlx(false)
+                    .setRendererUseSurfaceless(true)
+                    .setRendererUseVulkan(false)
+                    .setContextTypes(arrayOf<String>("virgl2"))
+                    .build()
+            )
+            Toast.makeText(this, R.string.virgl_enabled, Toast.LENGTH_SHORT).show()
+            changed = true
+        }
+
+        val image = InstalledImage.getDefault(this)
+        if (image.hasBackup()) {
+            val backup = image.backupFile
+            builder.addDisk(VirtualMachineCustomImageConfig.Disk.RWDisk(backup.toString()))
+            changed = true
+        }
+        return changed
+    }
+
+    private fun startDebianServer(ipAddress: String?) {
+        val interceptor: ServerInterceptor =
+            object : ServerInterceptor {
+                override fun <ReqT, RespT> interceptCall(
+                    call: ServerCall<ReqT?, RespT?>,
+                    headers: Metadata?,
+                    next: ServerCallHandler<ReqT?, RespT?>,
+                ): ServerCall.Listener<ReqT?>? {
+                    val remoteAddr =
+                        call.attributes.get<SocketAddress?>(Grpc.TRANSPORT_ATTR_REMOTE_ADDR)
+                            as InetSocketAddress?
+
+                    if (remoteAddr?.address?.hostAddress == ipAddress) {
+                        // Allow the request only if it is from VM
+                        return next.startCall(call, headers)
+                    }
+                    Log.d(TAG, "blocked grpc request from $remoteAddr")
+                    call.close(Status.Code.PERMISSION_DENIED.toStatus(), Metadata())
+                    return object : ServerCall.Listener<ReqT?>() {}
+                }
+            }
+        try {
+            // TODO(b/372666638): gRPC for java doesn't support vsock for now.
+            val port = 0
+            mDebianService = DebianServiceImpl(this)
+            mServer =
+                OkHttpServerBuilder.forPort(port, InsecureServerCredentials.create())
+                    .intercept(interceptor)
+                    .addService(mDebianService)
+                    .build()
+                    .start()
+        } catch (e: IOException) {
+            Log.d(TAG, "grpc server error", e)
+            return
+        }
+
+        mExecutorService!!.execute(
+            Runnable {
+                // TODO(b/373533555): we can use mDNS for that.
+                val debianServicePortFile = File(filesDir, "debian_service_port")
+                try {
+                    FileOutputStream(debianServicePortFile).use { writer ->
+                        writer.write(mServer!!.port.toString().toByteArray())
+                    }
+                } catch (e: IOException) {
+                    Log.d(TAG, "cannot write grpc port number", e)
+                }
+            }
+        )
+    }
+
+    override fun onDestroy() {
+        mPortNotifier?.stop()
+        getSystemService<NotificationManager?>(NotificationManager::class.java).cancelAll()
+        stopDebianServer()
+        if (mVirtualMachine != null) {
+            if (mVirtualMachine!!.getStatus() == VirtualMachine.STATUS_RUNNING) {
+                try {
+                    mVirtualMachine!!.stop()
+                    stopForeground(STOP_FOREGROUND_REMOVE)
+                } catch (e: VirtualMachineException) {
+                    Log.e(TAG, "failed to stop a VM instance", e)
+                }
+            }
+            mExecutorService?.shutdownNow()
+            mExecutorService = null
+            mVirtualMachine = null
+        }
+        super.onDestroy()
+    }
+
+    private fun stopDebianServer() {
+        mDebianService?.killForwarderHost()
+        mServer?.shutdown()
+    }
+
+    companion object {
+        private const val EXTRA_NOTIFICATION = "EXTRA_NOTIFICATION"
+        private const val ACTION_START_VM_LAUNCHER_SERVICE =
+            "android.virtualization.START_VM_LAUNCHER_SERVICE"
+
+        const val ACTION_STOP_VM_LAUNCHER_SERVICE: String =
+            "android.virtualization.STOP_VM_LAUNCHER_SERVICE"
+
+        private const val RESULT_START = 0
+        private const val RESULT_STOP = 1
+        private const val RESULT_ERROR = 2
+
+        private fun getMyIntent(context: Context): Intent {
+            return Intent(context.getApplicationContext(), VmLauncherService::class.java)
+        }
+
+        @JvmStatic
+        fun run(
+            context: Context,
+            callback: VmLauncherServiceCallback?,
+            notification: Notification?,
+        ) {
+            val i = getMyIntent(context)
+            val resultReceiver: ResultReceiver =
+                object : ResultReceiver(Handler(Looper.myLooper()!!)) {
+                    override fun onReceiveResult(resultCode: Int, resultData: Bundle?) {
+                        if (callback == null) {
+                            return
+                        }
+                        when (resultCode) {
+                            RESULT_START -> callback.onVmStart()
+                            RESULT_STOP -> callback.onVmStop()
+                            RESULT_ERROR -> callback.onVmError()
+                        }
+                    }
+                }
+            i.putExtra(Intent.EXTRA_RESULT_RECEIVER, getResultReceiverForIntent(resultReceiver))
+            i.putExtra(EXTRA_NOTIFICATION, notification)
+            context.startForegroundService(i)
+        }
+
+        private fun getResultReceiverForIntent(r: ResultReceiver): ResultReceiver {
+            val parcel = Parcel.obtain()
+            r.writeToParcel(parcel, 0)
+            parcel.setDataPosition(0)
+            return ResultReceiver.CREATOR.createFromParcel(parcel).also { parcel.recycle() }
+        }
+
+        @JvmStatic
+        fun stop(context: Context) {
+            val i = getMyIntent(context)
+            i.setAction(ACTION_STOP_VM_LAUNCHER_SERVICE)
+            context.startService(i)
+        }
+    }
+}
diff --git a/android/virtmgr/src/crosvm.rs b/android/virtmgr/src/crosvm.rs
index a90c1ff..096d3b5 100644
--- a/android/virtmgr/src/crosvm.rs
+++ b/android/virtmgr/src/crosvm.rs
@@ -324,7 +324,7 @@
             let tap =
                 if let Some(tap_file) = &config.tap { Some(tap_file.try_clone()?) } else { None };
 
-            run_virtiofs(&config)?;
+            let vhost_fs_devices = run_virtiofs(&config)?;
 
             // If this fails and returns an error, `self` will be left in the `Failed` state.
             let child =
@@ -339,7 +339,13 @@
             let child_clone = child.clone();
             let instance_clone = instance.clone();
             let monitor_vm_exit_thread = Some(thread::spawn(move || {
-                instance_clone.monitor_vm_exit(child_clone, failure_pipe_read, vfio_devices, tap);
+                instance_clone.monitor_vm_exit(
+                    child_clone,
+                    failure_pipe_read,
+                    vfio_devices,
+                    tap,
+                    vhost_fs_devices,
+                );
             }));
 
             if detect_hangup {
@@ -486,6 +492,7 @@
         failure_pipe_read: File,
         vfio_devices: Vec<VfioDevice>,
         tap: Option<File>,
+        vhost_user_devices: Vec<SharedChild>,
     ) {
         let failure_reason_thread = std::thread::spawn(move || {
             // Read the pipe to see if any failure reason is written
@@ -513,6 +520,34 @@
             }
         }
 
+        // In crosvm, when vhost_user frontend is dead, vhost_user backend device will detect and
+        // exit. We can safely wait() for vhost user device after waiting crosvm main
+        // process.
+        for device in vhost_user_devices {
+            match device.wait() {
+                Ok(status) => {
+                    info!("Vhost user device({}) exited with status {}", device.id(), status);
+                    if !status.success() {
+                        if let Some(code) = status.code() {
+                            // vhost_user backend device exit with error code
+                            error!(
+                                "vhost user device({}) exited with error code: {}",
+                                device.id(),
+                                code
+                            );
+                        } else {
+                            // The spawned child process of vhost_user backend device is
+                            // killed by signal
+                            error!("vhost user device({}) killed by signal", device.id());
+                        }
+                    }
+                }
+                Err(e) => {
+                    error!("Error waiting for vhost user device({}) to die: {}", device.id(), e);
+                }
+            }
+        }
+
         let failure_reason = failure_reason_thread.join().expect("failure_reason_thread panic'd");
 
         let mut vm_state = self.vm_state.lock().unwrap();
@@ -915,7 +950,8 @@
     }
 }
 
-fn run_virtiofs(config: &CrosvmConfig) -> io::Result<()> {
+fn run_virtiofs(config: &CrosvmConfig) -> io::Result<Vec<SharedChild>> {
+    let mut devices: Vec<SharedChild> = Vec::new();
     for shared_path in &config.shared_paths {
         if shared_path.app_domain {
             continue;
@@ -947,9 +983,10 @@
 
         let result = SharedChild::spawn(&mut command)?;
         info!("Spawned virtiofs crosvm({})", result.id());
+        devices.push(result);
     }
 
-    Ok(())
+    Ok(devices)
 }
 
 /// Starts an instance of `crosvm` to manage a new VM.
diff --git a/build/debian/build.sh b/build/debian/build.sh
index 3db6a40..3f33ec8 100755
--- a/build/debian/build.sh
+++ b/build/debian/build.sh
@@ -57,18 +57,16 @@
 			;;
 	esac
 	if [[ "${*:$OPTIND:1}" ]]; then
-		built_image="${*:$OPTIND:1}"
+		output="${*:$OPTIND:1}"
 	fi
 }
 
 prepare_build_id() {
-	local filename=build_id
 	if [ -z "${KOKORO_BUILD_NUMBER}" ]; then
-		echo eng-$(hostname)-$(date --utc) > ${filename}
+		echo eng-$(hostname)-$(date --utc)
 	else
-		echo ${KOKORO_BUILD_NUMBER} > ${filename}
+		echo ${KOKORO_BUILD_NUMBER}
 	fi
-	echo ${filename}
 }
 
 install_prerequisites() {
@@ -302,17 +300,23 @@
 }
 
 run_fai() {
-	local out="${built_image}"
+	local out="${raw_disk_image}"
 	make -C "${debian_cloud_image}" "image_bookworm_nocloud_${debian_arch}"
 	mv "${debian_cloud_image}/image_bookworm_nocloud_${debian_arch}.raw" "${out}"
 }
 
-extract_partitions() {
-	root_partition_num=1
-	bios_partition_num=14
-	efi_partition_num=15
+generate_output_package() {
+	fdisk -l "${raw_disk_image}"
+	local vm_config="$(realpath $(dirname "$0"))/vm_config.json.${arch}"
+	local root_partition_num=1
+	local bios_partition_num=14
+	local efi_partition_num=15
 
-	loop=$(losetup -f --show --partscan $built_image)
+	pushd ${workdir} > /dev/null
+
+	echo ${build_id} > build_id
+
+	loop=$(losetup -f --show --partscan $raw_disk_image)
 	dd if="${loop}p$root_partition_num" of=root_part
 	if [[ "$arch" == "x86_64" ]]; then
 		dd if="${loop}p$bios_partition_num" of=bios_part
@@ -320,11 +324,38 @@
 	dd if="${loop}p$efi_partition_num" of=efi_part
 	losetup -d "${loop}"
 
-	sed -i "s/{root_part_guid}/$(sfdisk --part-uuid $built_image $root_partition_num)/g" vm_config.json
+	cp ${vm_config} vm_config.json
+	sed -i "s/{root_part_guid}/$(sfdisk --part-uuid $raw_disk_image $root_partition_num)/g" vm_config.json
 	if [[ "$arch" == "x86_64" ]]; then
-		sed -i "s/{bios_part_guid}/$(sfdisk --part-uuid $built_image $bios_partition_num)/g" vm_config.json
+		sed -i "s/{bios_part_guid}/$(sfdisk --part-uuid $raw_disk_image $bios_partition_num)/g" vm_config.json
 	fi
-	sed -i "s/{efi_part_guid}/$(sfdisk --part-uuid $built_image $efi_partition_num)/g" vm_config.json
+	sed -i "s/{efi_part_guid}/$(sfdisk --part-uuid $raw_disk_image $efi_partition_num)/g" vm_config.json
+
+	images=()
+	if [[ "$arch" == "aarch64" ]]; then
+		images+=(
+			root_part
+			efi_part
+		)
+	# TODO(b/365955006): remove these lines when uboot supports x86_64 EFI application
+	elif [[ "$arch" == "x86_64" ]]; then
+		rm -f vmlinuz initrd.img
+		virt-get-kernel -a "${raw_disk_image}"
+		mv vmlinuz* vmlinuz
+		mv initrd.img* initrd.img
+		images+=(
+			bios_part
+			root_part
+			efi_part
+			vmlinuz
+			initrd.img
+		)
+	fi
+
+	popd > /dev/null
+
+	# --sparse option isn't supported in apache-commons-compress
+	tar czv -f ${output} -C ${workdir} build_id "${images[@]}" vm_config.json
 }
 
 clean_up() {
@@ -334,8 +365,9 @@
 set -e
 trap clean_up EXIT
 
-built_image=image.raw
+output=images.tar.gz
 workdir=$(mktemp -d)
+raw_disk_image=${workdir}/image.raw
 build_id=$(prepare_build_id)
 debian_cloud_image=${workdir}/debian_cloud_image
 debian_version=bookworm
@@ -353,32 +385,4 @@
 copy_android_config
 package_custom_kernel
 run_fai
-fdisk -l "${built_image}"
-images=()
-
-cp "$(dirname "$0")/vm_config.json.${arch}" vm_config.json
-
-extract_partitions
-
-if [[ "$arch" == "aarch64" ]]; then
-	images+=(
-		root_part
-		efi_part
-	)
-# TODO(b/365955006): remove these lines when uboot supports x86_64 EFI application
-elif [[ "$arch" == "x86_64" ]]; then
-	rm -f vmlinuz initrd.img
-	virt-get-kernel -a "${built_image}"
-	mv vmlinuz* vmlinuz
-	mv initrd.img* initrd.img
-	images+=(
-		bios_part
-		root_part
-		efi_part
-		vmlinuz
-		initrd.img
-	)
-fi
-
-# --sparse option isn't supported in apache-commons-compress
-tar czv -f images.tar.gz ${build_id} "${images[@]}" vm_config.json
+generate_output_package
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 0a7cca6..03657ed 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -9,7 +9,7 @@
 * aosp\_oriole (Pixel 6)
 * aosp\_raven (Pixel 6 Pro)
 * aosp\_felix (Pixel Fold)
-* aosp\_tangopro (Pixel Tablet)
+* aosp\_tangorpro (Pixel Tablet)
 * aosp\_cf\_x86\_64\_phone (Cuttlefish a.k.a. Cloud Android). Follow [this
   instruction](https://source.android.com/docs/setup/create/cuttlefish-use) to
   use.
diff --git a/libs/cstr/rules.mk b/libs/cstr/rules.mk
new file mode 100644
index 0000000..2309c30
--- /dev/null
+++ b/libs/cstr/rules.mk
@@ -0,0 +1,28 @@
+# 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.
+#
+
+LOCAL_DIR := $(GET_LOCAL_DIR)
+
+MODULE := $(LOCAL_DIR)
+
+SRC_DIR := packages/modules/Virtualization/libs/cstr
+
+MODULE_SRCS := $(SRC_DIR)/src/lib.rs
+
+MODULE_CRATE_NAME := cstr
+
+MODULE_RUST_EDITION := 2021
+
+include make/library.mk
diff --git a/libs/libavf/Android.bp b/libs/libavf/Android.bp
index 079f4ae..b583e21 100644
--- a/libs/libavf/Android.bp
+++ b/libs/libavf/Android.bp
@@ -10,6 +10,7 @@
     source_stem: "bindings",
     bindgen_flags: ["--default-enum-style rust"],
     apex_available: ["com.android.virt"],
+    visibility: ["//packages/modules/Virtualization/tests/vts"],
 }
 
 rust_defaults {
diff --git a/libs/libavf/include/android/virtualization.h b/libs/libavf/include/android/virtualization.h
index 6b54bf7..ef57325 100644
--- a/libs/libavf/include/android/virtualization.h
+++ b/libs/libavf/include/android/virtualization.h
@@ -70,7 +70,13 @@
                                      const char* _Nonnull name) __INTRODUCED_IN(36);
 
 /**
- * Set an instance ID of a virtual machine.
+ * Set an instance ID of a virtual machine. Every virtual machine is identified by a unique
+ * `instanceId` which the virtual machine uses as its persistent identity while performing stateful
+ * operations that are expected to outlast single boot of the VM. For example, some virtual machines
+ * use it as a `Id` for storing secrets in Secretkeeper, which are retrieved on next boot of th VM.
+ *
+ * The `instanceId` is expected to be re-used for the VM instance with an associated state (secret,
+ * encrypted storage) - i.e., rebooting the VM must not change the instanceId.
  *
  * \param config a virtual machine config object.
  * \param instanceId a pointer to a 64-byte buffer for the instance ID.
diff --git a/libs/libfdt/bindgen/rules.mk b/libs/libfdt/bindgen/rules.mk
new file mode 100644
index 0000000..130a317
--- /dev/null
+++ b/libs/libfdt/bindgen/rules.mk
@@ -0,0 +1,38 @@
+# 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.
+#
+
+LOCAL_DIR := $(GET_LOCAL_DIR)
+
+MODULE := $(LOCAL_DIR)
+
+MODULE_SRCS := $(LOCAL_DIR)/src/lib.rs
+
+MODULE_CRATE_NAME := libfdt_bindgen
+
+MODULE_DEPS += \
+	external/dtc/libfdt \
+
+MODULE_BINDGEN_ALLOW_FUNCTIONS := \
+	fdt_.* \
+
+MODULE_BINDGEN_ALLOW_VARS := \
+	FDT_.* \
+
+MODULE_BINDGEN_ALLOW_TYPES := \
+	fdt_.* \
+
+MODULE_BINDGEN_SRC_HEADER := $(LOCAL_DIR)/fdt.h
+
+include make/library.mk
diff --git a/libs/libfdt/bindgen/src/lib.rs b/libs/libfdt/bindgen/src/lib.rs
new file mode 100644
index 0000000..015132b
--- /dev/null
+++ b/libs/libfdt/bindgen/src/lib.rs
@@ -0,0 +1,24 @@
+/*
+ * 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.
+ */
+
+//! # Interface library for libfdt.
+
+#![no_std]
+#![allow(non_upper_case_globals)]
+#![allow(non_camel_case_types)]
+#![allow(non_snake_case)]
+
+include!(env!("BINDGEN_INC_FILE"));
diff --git a/libs/libfdt/rules.mk b/libs/libfdt/rules.mk
new file mode 100644
index 0000000..2b4e470
--- /dev/null
+++ b/libs/libfdt/rules.mk
@@ -0,0 +1,37 @@
+# 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.
+#
+
+LOCAL_DIR := $(GET_LOCAL_DIR)
+
+MODULE := $(LOCAL_DIR)
+
+SRC_DIR := packages/modules/Virtualization/libs/libfdt
+
+MODULE_SRCS := $(SRC_DIR)/src/lib.rs
+
+MODULE_CRATE_NAME := libfdt
+
+MODULE_RUST_EDITION := 2021
+
+MODULE_LIBRARY_DEPS += \
+	external/dtc/libfdt \
+	packages/modules/Virtualization/libs/cstr \
+	packages/modules/Virtualization/libs/libfdt/bindgen \
+	$(call FIND_CRATE,zerocopy) \
+	$(call FIND_CRATE,static_assertions) \
+
+MODULE_RUST_USE_CLIPPY := true
+
+include make/library.mk
diff --git a/tests/hostside/Android.bp b/tests/hostside/Android.bp
index 48e369c..0966c20 100644
--- a/tests/hostside/Android.bp
+++ b/tests/hostside/Android.bp
@@ -2,14 +2,9 @@
     default_applicable_licenses: ["Android-Apache-2.0"],
 }
 
-java_test_host {
-    name: "MicrodroidHostTestCases",
+java_defaults {
+    name: "MicrodroidHostTestCases.default",
     srcs: ["java/**/*.java"],
-    test_suites: [
-        "cts",
-        "general-tests",
-        "pts",
-    ],
     libs: [
         "androidx.annotation_annotation",
         "tradefed",
@@ -21,27 +16,6 @@
         "microdroid_payload_metadata",
     ],
     per_testcase_directory: true,
-    device_common_data: [
-        ":MicrodroidTestApp",
-        ":MicrodroidTestAppUpdated",
-        ":microdroid_general_sepolicy.conf",
-        ":test.com.android.virt.pem",
-        ":test2.com.android.virt.pem",
-        "java/**/goldens/dt_dump_*",
-    ],
-    data_native_bins: [
-        "sepolicy-analyze",
-        // For re-sign test
-        "avbtool",
-        "img2simg",
-        "initrd_bootconfig",
-        "lpmake",
-        "lpunpack",
-        "lz4",
-        "sign_virt_apex",
-        "simg2img",
-        "dtc",
-    ],
     // java_test_host doesn't have data_native_libs but jni_libs can be used to put
     // native modules under ./lib directory.
     // This works because host tools have rpath (../lib and ./lib).
@@ -58,3 +32,78 @@
         "libz",
     ],
 }
+
+DEVICE_DATA = [
+    ":MicrodroidTestApp",
+    ":MicrodroidTestAppUpdated",
+    ":microdroid_general_sepolicy.conf",
+    ":test.com.android.virt.pem",
+    ":test2.com.android.virt.pem",
+    "java/**/goldens/dt_dump_*",
+]
+
+BINS = [
+    "sepolicy-analyze",
+    // For re-sign test
+    "avbtool",
+    "img2simg",
+    "initrd_bootconfig",
+    "lpmake",
+    "lpunpack",
+    "lz4",
+    "sign_virt_apex",
+    "simg2img",
+    "dtc",
+]
+
+java_test_host {
+    name: "MicrodroidHostTestCases",
+    defaults: ["MicrodroidHostTestCases.default"],
+    test_config: "AndroidTest.xml",
+    test_suites: [
+        "general-tests",
+        "pts",
+    ],
+    device_common_data: DEVICE_DATA,
+    data_native_bins: BINS,
+}
+
+java_test_host {
+    name: "MicrodroidHostTestCases.CTS",
+    defaults: ["MicrodroidHostTestCases.default"],
+    test_config: ":MicrodroidHostTestCases.CTS.config",
+    test_suites: ["cts"],
+    device_common_data: DEVICE_DATA,
+    data_native_bins: BINS,
+}
+
+java_test_host {
+    name: "MicrodroidHostTestCases.VTS",
+    defaults: ["MicrodroidHostTestCases.default"],
+    test_config: ":MicrodroidHostTestCases.VTS.config",
+    test_suites: ["vts"],
+    device_common_data: DEVICE_DATA,
+    data_native_bins: BINS,
+}
+
+genrule {
+    name: "MicrodroidHostTestCases.CTS.config",
+    srcs: ["AndroidTest.xml"],
+    out: ["out.xml"],
+    cmd: "sed " +
+        "-e 's/<!-- PLACEHOLDER_FOR_ANNOTATION -->/" +
+        "<option name=\"include-annotation\" value=\"com.android.compatibility.common.util.CddTest\" \\/>/' " +
+        "-e 's/MicrodroidHostTestCases.jar/MicrodroidHostTestCases.CTS.jar/' " +
+        "$(in) > $(out)",
+}
+
+genrule {
+    name: "MicrodroidHostTestCases.VTS.config",
+    srcs: ["AndroidTest.xml"],
+    out: ["out.xml"],
+    cmd: "sed " +
+        "-e 's/<!-- PLACEHOLDER_FOR_ANNOTATION -->/" +
+        "<option name=\"include-annotation\" value=\"com.android.compatibility.common.util.VsrTest\" \\/>/' " +
+        "-e 's/MicrodroidHostTestCases.jar/MicrodroidHostTestCases.VTS.jar/' " +
+        "$(in) > $(out)",
+}
diff --git a/tests/hostside/AndroidTest.xml b/tests/hostside/AndroidTest.xml
index f77def3..c277865 100644
--- a/tests/hostside/AndroidTest.xml
+++ b/tests/hostside/AndroidTest.xml
@@ -32,4 +32,6 @@
     <!-- Controller that will skip the module if a native bridge situation is detected -->
     <!-- For example: module wants to run arm and device is x86 -->
     <object type="module_controller" class="com.android.tradefed.testtype.suite.module.NativeBridgeModuleController" />
+
+    <!-- PLACEHOLDER_FOR_ANNOTATION -->
 </configuration>
diff --git a/tests/vts/Android.bp b/tests/vts/Android.bp
new file mode 100644
index 0000000..c8e2523
--- /dev/null
+++ b/tests/vts/Android.bp
@@ -0,0 +1,36 @@
+prebuilt_etc {
+    name: "vts_libavf_test_kernel",
+    filename: "rialto.bin",
+    src: ":empty_file",
+    target: {
+        android_arm64: {
+            src: ":rialto_signed",
+        },
+    },
+    installable: false,
+    visibility: ["//visibility:private"],
+}
+
+rust_test {
+    name: "vts_libavf_test",
+    crate_name: "vts_libavf_test",
+    srcs: ["src/vts_libavf_test.rs"],
+    rustlibs: [
+        "libanyhow",
+        "libavf_bindgen",
+        "libciborium",
+        "liblog_rust",
+        "libhypervisor_props",
+        "libscopeguard",
+        "libservice_vm_comm",
+        "libvsock",
+    ],
+    shared_libs: ["libavf"],
+    test_suites: [
+        "general-tests",
+        "vts",
+    ],
+    data: [":vts_libavf_test_kernel"],
+    test_config: "AndroidTest.xml",
+    compile_multilib: "first",
+}
diff --git a/tests/vts/AndroidTest.xml b/tests/vts/AndroidTest.xml
new file mode 100644
index 0000000..75c8d31
--- /dev/null
+++ b/tests/vts/AndroidTest.xml
@@ -0,0 +1,37 @@
+<?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.
+-->
+<configuration description="Runs vts_libavf_test.">
+    <option name="test-suite-tag" value="vts" />
+
+    <target_preparer class="com.android.tradefed.targetprep.RootTargetPreparer" />
+
+    <target_preparer class="com.android.tradefed.targetprep.PushFilePreparer">
+        <option name="cleanup" value="true" />
+        <option name="push" value="vts_libavf_test->/data/local/tmp/vts_libavf_test" />
+        <option name="push" value="rialto.bin->/data/local/tmp/rialto.bin" />
+    </target_preparer>
+
+    <object type="module_controller" class="com.android.tradefed.testtype.suite.module.ArchModuleController">
+        <option name="arch" value="arm64" />
+    </object>
+
+    <test class="com.android.tradefed.testtype.rust.RustBinaryTest" >
+        <option name="test-device-path" value="/data/local/tmp" />
+        <option name="module-name" value="vts_libavf_test" />
+        <!-- rialto uses a fixed port number for the host, can't run two tests at the same time -->
+        <option name="native-test-flag" value="--test-threads=1" />
+    </test>
+</configuration>
diff --git a/tests/vts/src/vts_libavf_test.rs b/tests/vts/src/vts_libavf_test.rs
new file mode 100644
index 0000000..e30c175
--- /dev/null
+++ b/tests/vts/src/vts_libavf_test.rs
@@ -0,0 +1,196 @@
+// 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.
+
+//! Tests running a VM with LLNDK
+
+use anyhow::{bail, ensure, Context, Result};
+use log::info;
+use std::ffi::CStr;
+use std::fs::File;
+use std::io::{self, BufWriter, Write};
+use std::os::fd::IntoRawFd;
+use std::time::{Duration, Instant};
+use vsock::{VsockListener, VsockStream, VMADDR_CID_HOST};
+
+use avf_bindgen::*;
+use service_vm_comm::{Request, Response, ServiceVmRequest, VmType};
+
+const VM_MEMORY_MB: i32 = 16;
+const WRITE_BUFFER_CAPACITY: usize = 512;
+
+const LISTEN_TIMEOUT: Duration = Duration::from_secs(10);
+const READ_TIMEOUT: Duration = Duration::from_secs(10);
+const WRITE_TIMEOUT: Duration = Duration::from_secs(10);
+const STOP_TIMEOUT: timespec = timespec { tv_sec: 10, tv_nsec: 0 };
+
+/// Processes the request in the service VM.
+fn process_request(vsock_stream: &mut VsockStream, request: Request) -> Result<Response> {
+    write_request(vsock_stream, &ServiceVmRequest::Process(request))?;
+    read_response(vsock_stream)
+}
+
+/// Sends the request to the service VM.
+fn write_request(vsock_stream: &mut VsockStream, request: &ServiceVmRequest) -> Result<()> {
+    let mut buffer = BufWriter::with_capacity(WRITE_BUFFER_CAPACITY, vsock_stream);
+    ciborium::into_writer(request, &mut buffer)?;
+    buffer.flush().context("Failed to flush the buffer")?;
+    Ok(())
+}
+
+/// Reads the response from the service VM.
+fn read_response(vsock_stream: &mut VsockStream) -> Result<Response> {
+    let response: Response = ciborium::from_reader(vsock_stream)
+        .context("Failed to read the response from the service VM")?;
+    Ok(response)
+}
+
+fn listen_from_guest(port: u32) -> Result<VsockStream> {
+    let vsock_listener =
+        VsockListener::bind_with_cid_port(VMADDR_CID_HOST, port).context("Failed to bind vsock")?;
+    vsock_listener.set_nonblocking(true).context("Failed to set nonblocking")?;
+    let start_time = Instant::now();
+    loop {
+        if start_time.elapsed() >= LISTEN_TIMEOUT {
+            bail!("Timeout while listening");
+        }
+        match vsock_listener.accept() {
+            Ok((vsock_stream, _peer_addr)) => return Ok(vsock_stream),
+            Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
+                std::thread::sleep(Duration::from_millis(100));
+            }
+            Err(e) => bail!("Failed to listen: {e:?}"),
+        }
+    }
+}
+
+fn run_rialto(protected_vm: bool) -> Result<()> {
+    let kernel_file =
+        File::open("/data/local/tmp/rialto.bin").context("Failed to open kernel file")?;
+    let kernel_fd = kernel_file.into_raw_fd();
+
+    // SAFETY: AVirtualMachineRawConfig_create() isn't unsafe but rust_bindgen forces it to be seen
+    // as unsafe
+    let config = unsafe { AVirtualMachineRawConfig_create() };
+
+    info!("raw config created");
+
+    // SAFETY: config is the only reference to a valid object
+    unsafe {
+        AVirtualMachineRawConfig_setName(
+            config,
+            CStr::from_bytes_with_nul(b"vts_libavf_test_rialto\0").unwrap().as_ptr(),
+        );
+        AVirtualMachineRawConfig_setKernel(config, kernel_fd);
+        AVirtualMachineRawConfig_setProtectedVm(config, protected_vm);
+        AVirtualMachineRawConfig_setMemoryMiB(config, VM_MEMORY_MB);
+    }
+
+    let mut vm = std::ptr::null_mut();
+    let mut service = std::ptr::null_mut();
+
+    ensure!(
+        // SAFETY: &mut service is a valid pointer to *AVirtualizationService
+        unsafe { AVirtualizationService_create(&mut service, false) } == 0,
+        "AVirtualizationService_create failed"
+    );
+
+    scopeguard::defer! {
+        // SAFETY: service is a valid pointer to AVirtualizationService
+        unsafe { AVirtualizationService_destroy(service); }
+    }
+
+    ensure!(
+        // SAFETY: &mut vm is a valid pointer to *AVirtualMachine
+        unsafe {
+            AVirtualMachine_createRaw(
+                service, config, -1, // console_in
+                -1, // console_out
+                -1, // log
+                &mut vm,
+            )
+        } == 0,
+        "AVirtualMachine_createRaw failed"
+    );
+
+    scopeguard::defer! {
+        // SAFETY: vm is a valid pointer to AVirtualMachine
+        unsafe { AVirtualMachine_destroy(vm); }
+    }
+
+    info!("vm created");
+
+    let vm_type = if protected_vm { VmType::ProtectedVm } else { VmType::NonProtectedVm };
+
+    let listener_thread = std::thread::spawn(move || listen_from_guest(vm_type.port()));
+
+    // SAFETY: vm is the only reference to a valid object
+    unsafe {
+        AVirtualMachine_start(vm);
+    }
+
+    info!("VM started");
+
+    let mut vsock_stream = listener_thread.join().unwrap()?;
+    vsock_stream.set_read_timeout(Some(READ_TIMEOUT))?;
+    vsock_stream.set_write_timeout(Some(WRITE_TIMEOUT))?;
+
+    info!("client connected");
+
+    let request_data = vec![1, 2, 3, 4, 5];
+    let expected_data = vec![5, 4, 3, 2, 1];
+    let response = process_request(&mut vsock_stream, Request::Reverse(request_data))
+        .context("Failed to process request")?;
+    let Response::Reverse(reversed_data) = response else {
+        bail!("Expected Response::Reverse but was {response:?}");
+    };
+    ensure!(reversed_data == expected_data, "Expected {expected_data:?} but was {reversed_data:?}");
+
+    info!("request processed");
+
+    write_request(&mut vsock_stream, &ServiceVmRequest::Shutdown)
+        .context("Failed to send shutdown")?;
+
+    info!("shutdown sent");
+
+    let mut stop_reason = AVirtualMachineStopReason::AVIRTUAL_MACHINE_UNRECOGNISED;
+    ensure!(
+        // SAFETY: vm is the only reference to a valid object
+        unsafe { AVirtualMachine_waitForStop(vm, &STOP_TIMEOUT, &mut stop_reason) },
+        "AVirtualMachine_waitForStop failed"
+    );
+
+    info!("stopped");
+
+    Ok(())
+}
+
+#[test]
+fn test_run_rialto_protected() -> Result<()> {
+    if hypervisor_props::is_protected_vm_supported()? {
+        run_rialto(true /* protected_vm */)
+    } else {
+        info!("pVMs are not supported on device. skipping test");
+        Ok(())
+    }
+}
+
+#[test]
+fn test_run_rialto_non_protected() -> Result<()> {
+    if hypervisor_props::is_vm_supported()? {
+        run_rialto(false /* protected_vm */)
+    } else {
+        info!("non-pVMs are not supported on device. skipping test");
+        Ok(())
+    }
+}