Snap for 12715656 from d1903851e8d86d5b7fddb49892a23c60fdba2ab0 to 25Q1-release

Change-Id: I4b0dda1aa3b7187da21faf9d8303f6f137d8cd21
diff --git a/android/TerminalApp/Android.bp b/android/TerminalApp/Android.bp
index 2711af0..733a72b 100644
--- a/android/TerminalApp/Android.bp
+++ b/android/TerminalApp/Android.bp
@@ -9,7 +9,6 @@
         "java/**/*.kt",
     ],
     resource_dirs: ["res"],
-    asset_dirs: ["assets"],
     static_libs: [
         "androidx-constraintlayout_constraintlayout",
         "androidx.window_window",
diff --git a/android/TerminalApp/assets/.gitkeep b/android/TerminalApp/assets/.gitkeep
deleted file mode 100644
index e69de29..0000000
--- a/android/TerminalApp/assets/.gitkeep
+++ /dev/null
diff --git a/android/TerminalApp/assets/client.p12 b/android/TerminalApp/assets/client.p12
deleted file mode 100644
index f1f5820..0000000
--- a/android/TerminalApp/assets/client.p12
+++ /dev/null
Binary files differ
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ConfigJson.java b/android/TerminalApp/java/com/android/virtualization/terminal/ConfigJson.java
index e1342e9..ab03049 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/ConfigJson.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ConfigJson.java
@@ -35,9 +35,16 @@
 import com.google.gson.Gson;
 import com.google.gson.annotations.SerializedName;
 
+import java.io.BufferedReader;
 import java.io.FileReader;
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.file.Path;
 import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
 import java.util.Objects;
+import java.util.stream.Collectors;
 
 /** This class and its inner classes model vm_config.json. */
 class ConfigJson {
@@ -69,14 +76,40 @@
     private GpuJson gpu;
 
     /** Parses JSON file at jsonPath */
-    static ConfigJson from(String jsonPath) {
-        try (FileReader r = new FileReader(jsonPath)) {
-            return new Gson().fromJson(r, ConfigJson.class);
+    static ConfigJson from(Context context, Path jsonPath) {
+        try (FileReader fileReader = new FileReader(jsonPath.toFile())) {
+            String content = replaceKeywords(fileReader, context);
+            return new Gson().fromJson(content, ConfigJson.class);
         } catch (Exception e) {
             throw new RuntimeException("Failed to parse " + jsonPath, e);
         }
     }
 
+    private static String replaceKeywords(Reader r, Context context) throws IOException {
+        Map<String, String> rules = new HashMap<>();
+        rules.put("\\$PAYLOAD_DIR", InstalledImage.getDefault(context).getInstallDir().toString());
+        rules.put("\\$USER_ID", String.valueOf(context.getUserId()));
+        rules.put("\\$PACKAGE_NAME", context.getPackageName());
+        String appDataDir = context.getDataDir().toString();
+        // TODO: remove this hack
+        if (context.getUserId() == 0) {
+            appDataDir = "/data/data/" + context.getPackageName();
+        }
+        rules.put("\\$APP_DATA_DIR", appDataDir);
+
+        try (BufferedReader br = new BufferedReader(r)) {
+            return br.lines()
+                    .map(
+                            line -> {
+                                for (Map.Entry<String, String> rule : rules.entrySet()) {
+                                    line = line.replaceAll(rule.getKey(), rule.getValue());
+                                }
+                                return line;
+                            })
+                    .collect(Collectors.joining("\n"));
+        }
+    }
+
     private int getCpuTopology() {
         switch (cpu_topology) {
             case "one_cpu":
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java
index ee1f1ad..44dcce5 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ErrorActivity.java
@@ -16,6 +16,7 @@
 
 package com.android.virtualization.terminal;
 
+import android.content.Context;
 import android.content.Intent;
 import android.os.Bundle;
 import android.view.View;
@@ -25,7 +26,14 @@
 import androidx.annotation.Nullable;
 
 public class ErrorActivity extends BaseActivity {
-    public static final String EXTRA_CAUSE = "cause";
+    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) {
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java
new file mode 100644
index 0000000..b2a2085
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ImageArchive.java
@@ -0,0 +1,169 @@
+/*
+ * 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 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 HOST_URL = "https://dl.google.com/android/ferrochrome/latest";
+
+    // 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 {
+        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);
+                } else {
+                    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/InstallUtils.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallUtils.java
deleted file mode 100644
index 71f2a2d..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallUtils.java
+++ /dev/null
@@ -1,194 +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.Environment;
-import android.os.FileUtils;
-import android.util.Log;
-
-import com.android.internal.annotations.VisibleForTesting;
-
-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.File;
-import java.io.FileNotFoundException;
-import java.io.IOException;
-import java.nio.file.Files;
-import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
-import java.util.HashMap;
-import java.util.Map;
-import java.util.function.Function;
-
-public class InstallUtils {
-    private static final String VM_CONFIG_FILENAME = "vm_config.json";
-    private static final String COMPRESSED_PAYLOAD_FILENAME = "images.tar.gz";
-    private static final String ROOTFS_FILENAME = "root_part";
-    private static final String BACKUP_FILENAME = "root_part_backup";
-    private static final String INSTALLATION_COMPLETED_FILENAME = "completed";
-    private static final String PAYLOAD_DIR = "linux";
-
-    public static String getVmConfigPath(Context context) {
-        return getInternalStorageDir(context).toPath().resolve(VM_CONFIG_FILENAME).toString();
-    }
-
-    public static boolean isImageInstalled(Context context) {
-        return Files.exists(getInstallationCompletedPath(context));
-    }
-
-    public static void backupRootFs(Context context) throws IOException {
-        Files.move(
-                getRootfsFile(context).toPath(),
-                getBackupFile(context).toPath(),
-                StandardCopyOption.REPLACE_EXISTING);
-    }
-
-    public static boolean createInstalledMarker(Context context) {
-        try {
-            File file = new File(getInstallationCompletedPath(context).toString());
-            return file.createNewFile();
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to mark install completed", e);
-            return false;
-        }
-    }
-
-    @VisibleForTesting
-    public static void deleteInstallation(Context context) {
-        FileUtils.deleteContentsAndDir(getInternalStorageDir(context));
-    }
-
-    private static Path getPayloadPath() {
-        File payloadDir = Environment.getExternalStoragePublicDirectory(PAYLOAD_DIR);
-        if (payloadDir == null) {
-            Log.d(TAG, "no payload dir: " + payloadDir);
-            return null;
-        }
-        Path payloadPath = payloadDir.toPath().resolve(COMPRESSED_PAYLOAD_FILENAME);
-        return payloadPath;
-    }
-
-    public static boolean payloadFromExternalStorageExists() {
-        return Files.exists(getPayloadPath());
-    }
-
-    public static File getInternalStorageDir(Context context) {
-        return new File(context.getFilesDir(), PAYLOAD_DIR);
-    }
-
-    public static File getBackupFile(Context context) {
-        return new File(context.getFilesDir(), BACKUP_FILENAME);
-    }
-
-    private static Path getInstallationCompletedPath(Context context) {
-        return getInternalStorageDir(context).toPath().resolve(INSTALLATION_COMPLETED_FILENAME);
-    }
-
-    public static boolean installImageFromExternalStorage(Context context) {
-        if (!payloadFromExternalStorageExists()) {
-            Log.d(TAG, "no artifact file from external storage");
-            return false;
-        }
-        Path payloadPath = getPayloadPath();
-        try (BufferedInputStream inputStream =
-                        new BufferedInputStream(Files.newInputStream(payloadPath));
-                TarArchiveInputStream tar =
-                        new TarArchiveInputStream(new GzipCompressorInputStream(inputStream))) {
-            ArchiveEntry entry;
-            Path baseDir = new File(context.getFilesDir(), PAYLOAD_DIR).toPath();
-            Files.createDirectories(baseDir);
-            while ((entry = tar.getNextEntry()) != null) {
-                Path extractTo = baseDir.resolve(entry.getName());
-                if (entry.isDirectory()) {
-                    Files.createDirectories(extractTo);
-                } else {
-                    Files.copy(tar, extractTo, StandardCopyOption.REPLACE_EXISTING);
-                }
-            }
-        } catch (IOException e) {
-            Log.e(TAG, "installation failed", e);
-            return false;
-        }
-        if (!resolvePathInVmConfig(context)) {
-            Log.d(TAG, "resolving path failed");
-            try {
-                Files.deleteIfExists(Path.of(getVmConfigPath(context)));
-            } catch (IOException e) {
-                return false;
-            }
-            return false;
-        }
-
-        // remove payload if installation is done.
-        try {
-            Files.deleteIfExists(payloadPath);
-        } catch (IOException e) {
-            Log.d(TAG, "failed to remove installed payload", e);
-        }
-
-        // Create marker for installation done.
-        return createInstalledMarker(context);
-    }
-
-    private static Function<String, String> getReplacer(Context context) {
-        Map<String, String> rules = new HashMap<>();
-        rules.put("\\$PAYLOAD_DIR", new File(context.getFilesDir(), PAYLOAD_DIR).toString());
-        rules.put("\\$USER_ID", String.valueOf(context.getUserId()));
-        rules.put("\\$PACKAGE_NAME", context.getPackageName());
-        String appDataDir = context.getDataDir().toString();
-        // TODO: remove this hack
-        if (context.getUserId() == 0) {
-            appDataDir = "/data/data/" + context.getPackageName();
-        }
-        rules.put("\\$APP_DATA_DIR", appDataDir);
-        return (s) -> {
-            for (Map.Entry<String, String> rule : rules.entrySet()) {
-                s = s.replaceAll(rule.getKey(), rule.getValue());
-            }
-            return s;
-        };
-    }
-
-    public static boolean resolvePathInVmConfig(Context context) {
-        try {
-            String replacedVmConfig =
-                    String.join(
-                            "\n",
-                            Files.readAllLines(Path.of(getVmConfigPath(context))).stream()
-                                    .map(getReplacer(context))
-                                    .toList());
-            Files.write(Path.of(getVmConfigPath(context)), replacedVmConfig.getBytes());
-            return true;
-        } catch (IOException e) {
-            return false;
-        }
-    }
-
-    public static File getRootfsFile(Context context) throws FileNotFoundException {
-        File file = new File(getInternalStorageDir(context), ROOTFS_FILENAME);
-        if (!file.exists()) {
-            Log.d(TAG, file.getAbsolutePath() + " - file not found");
-            throw new FileNotFoundException("File not found: " + ROOTFS_FILENAME);
-        }
-        return file;
-    }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java
new file mode 100644
index 0000000..623fbe4
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.java
@@ -0,0 +1,182 @@
+/*
+ * 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.FileDescriptor;
+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";
+    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;
+
+    /** 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;
+    }
+
+    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 roundUp(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) {
+            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();
+            return new String(process.getInputStream().readAllBytes());
+        } 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/InstallerActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
index 52ef3d4..1c62572 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerActivity.java
@@ -41,6 +41,7 @@
 import com.google.android.material.progressindicator.LinearProgressIndicator;
 import com.google.android.material.snackbar.Snackbar;
 
+import java.io.IOException;
 import java.lang.ref.WeakReference;
 
 public class InstallerActivity extends BaseActivity {
@@ -63,12 +64,8 @@
         mInstallProgressListener = new InstallProgressListener(this);
 
         setContentView(R.layout.activity_installer);
-
-        TextView desc = (TextView) findViewById(R.id.installer_desc);
-        desc.setText(
-                getString(
-                        R.string.installer_desc_text_format,
-                        Formatter.formatShortFileSize(this, ESTIMATED_IMG_SIZE_BYTES)));
+        updateSizeEstimation(ESTIMATED_IMG_SIZE_BYTES);
+        measureImageSizeAndUpdateDescription();
 
         mWaitForWifiCheckbox = (CheckBox) findViewById(R.id.installer_wait_for_wifi_checkbox);
         mInstallButton = (TextView) findViewById(R.id.installer_install_button);
@@ -85,11 +82,38 @@
         }
     }
 
+    private void updateSizeEstimation(long est) {
+        String desc =
+                getString(
+                        R.string.installer_desc_text_format,
+                        Formatter.formatShortFileSize(this, est));
+        runOnUiThread(
+                () -> {
+                    TextView view = (TextView) findViewById(R.id.installer_desc);
+                    view.setText(desc);
+                });
+    }
+
+    private void measureImageSizeAndUpdateDescription() {
+        new Thread(
+                        () -> {
+                            long est;
+                            try {
+                                est = ImageArchive.getDefault().getSize();
+                            } catch (IOException e) {
+                                Log.w(TAG, "Failed to measure image size.", e);
+                                return;
+                            }
+                            updateSizeEstimation(est);
+                        })
+                .start();
+    }
+
     @Override
     public void onResume() {
         super.onResume();
 
-        if (Build.isDebuggable() && InstallUtils.payloadFromExternalStorageExists()) {
+        if (Build.isDebuggable() && ImageArchive.fromSdCard().exists()) {
             showSnackbar("Auto installing", Snackbar.LENGTH_LONG);
             requestInstall();
         }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
index 6fd3b5c..c2b3fd4 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstallerService.java
@@ -28,7 +28,6 @@
 import android.net.NetworkCapabilities;
 import android.os.Build;
 import android.os.IBinder;
-import android.os.SELinux;
 import android.util.Log;
 
 import androidx.annotation.NonNull;
@@ -36,23 +35,13 @@
 
 import com.android.internal.annotations.GuardedBy;
 
-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.File;
 import java.io.IOException;
 import java.io.InputStream;
 import java.lang.ref.WeakReference;
 import java.net.SocketException;
-import java.net.URL;
 import java.net.UnknownHostException;
-import java.nio.file.Files;
 import java.nio.file.Path;
-import java.nio.file.StandardCopyOption;
 import java.util.Arrays;
-import java.util.Objects;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 
@@ -64,9 +53,6 @@
                     ? "https://dl.google.com/android/ferrochrome/latest/x86_64/images.tar.gz"
                     : "https://dl.google.com/android/ferrochrome/latest/aarch64/images.tar.gz";
 
-    private static final String SELINUX_FILE_CONTEXT =
-            "u:object_r:virtualizationservice_data_file:";
-
     private final Object mLock = new Object();
 
     private Notification mNotification;
@@ -162,9 +148,6 @@
         mExecutorService.execute(
                 () -> {
                     boolean success = downloadFromSdcard() || downloadFromUrl(isWifiOnly);
-                    if (success) {
-                        reLabelImagesSELinuxContext();
-                    }
                     stopForeground(STOP_FOREGROUND_REMOVE);
 
                     synchronized (mLock) {
@@ -176,34 +159,21 @@
                 });
     }
 
-    private void reLabelImagesSELinuxContext() {
-        File payloadFolder = InstallUtils.getInternalStorageDir(this);
-
-        // The context should be u:object_r:privapp_data_file:s0:c35,c257,c512,c768
-        // and we want to get s0:c35,c257,c512,c768 part
-        String level = SELinux.getFileContext(payloadFolder.toString()).split(":", 4)[3];
-        String targetContext = SELINUX_FILE_CONTEXT + level;
-
-        File[] files = payloadFolder.listFiles();
-        for (File file : files) {
-            if (file.isFile() &&
-                    !Objects.equals(SELinux.getFileContext(file.toString()),
-                            targetContext)) {
-                SELinux.setFileContext(file.toString(), targetContext);
-            }
-        }
-    }
-
     private boolean downloadFromSdcard() {
+        ImageArchive archive = ImageArchive.fromSdCard();
+
         // Installing from sdcard is preferred, but only supported only in debuggable build.
-        if (Build.isDebuggable()) {
+        if (Build.isDebuggable() && archive.exists()) {
             Log.i(TAG, "trying to install /sdcard/linux/images.tar.gz");
 
-            if (InstallUtils.installImageFromExternalStorage(this)) {
+            Path dest = InstalledImage.getDefault(this).getInstallDir();
+            try {
+                archive.installTo(dest, null);
                 Log.i(TAG, "image is installed from /sdcard/linux/images.tar.gz");
                 return true;
+            } catch (IOException e) {
+                Log.i(TAG, "Failed to install /sdcard/linux/images.tar.gz", e);
             }
-            Log.i(TAG, "Failed to install /sdcard/linux/images.tar.gz");
         } else {
             Log.i(TAG, "Non-debuggable build doesn't support installation from /sdcard/linux");
         }
@@ -229,23 +199,16 @@
             return false;
         }
 
-        try (BufferedInputStream inputStream =
-                        new BufferedInputStream(new URL(IMAGE_URL).openStream());
-                WifiCheckInputStream wifiInputStream =
-                        new WifiCheckInputStream(inputStream, isWifiOnly);
-                TarArchiveInputStream tar =
-                        new TarArchiveInputStream(new GzipCompressorInputStream(wifiInputStream))) {
-            ArchiveEntry entry;
-            Path baseDir = InstallUtils.getInternalStorageDir(this).toPath();
-            Files.createDirectories(baseDir);
-            while ((entry = tar.getNextEntry()) != null) {
-                Path extractTo = baseDir.resolve(entry.getName());
-                if (entry.isDirectory()) {
-                    Files.createDirectories(extractTo);
-                } else {
-                    Files.copy(tar, extractTo, StandardCopyOption.REPLACE_EXISTING);
-                }
-            }
+        Path dest = InstalledImage.getDefault(this).getInstallDir();
+        try {
+            ImageArchive.fromInternet()
+                    .installTo(
+                            dest,
+                            is -> {
+                                WifiCheckInputStream filter = new WifiCheckInputStream(is);
+                                filter.setWifiOnly(isWifiOnly);
+                                return filter;
+                            });
         } catch (WifiCheckInputStream.NoWifiException e) {
             Log.e(TAG, "Install failed because of Wi-Fi is gone");
             notifyError(getString(R.string.installer_error_no_wifi));
@@ -260,12 +223,7 @@
             notifyError(getString(R.string.installer_error_unknown));
             return false;
         }
-
-        if (!InstallUtils.resolvePathInVmConfig(this)) {
-            notifyError(getString(R.string.installer_error_unknown));
-            return false;
-        }
-        return InstallUtils.createInstalledMarker(this);
+        return true;
     }
 
     private void notifyError(String displayText) {
@@ -339,7 +297,7 @@
         public boolean isInstalled() {
             InstallerService service = ensureServiceConnected();
             synchronized (service.mLock) {
-                return !service.mIsInstalling && InstallUtils.isImageInstalled(service);
+                return !service.mIsInstalling && InstalledImage.getDefault(service).isInstalled();
             }
         }
     }
@@ -348,11 +306,14 @@
         private static final int READ_BYTES = 1024;
 
         private final InputStream mInputStream;
-        private final boolean mIsWifiOnly;
+        private boolean mIsWifiOnly;
 
-        public WifiCheckInputStream(InputStream is, boolean isWifiOnly) {
+        public WifiCheckInputStream(InputStream is) {
             super();
             mInputStream = is;
+        }
+
+        public void setWifiOnly(boolean isWifiOnly) {
             mIsWifiOnly = isWifiOnly;
         }
 
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
index bc5d037..deef825 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
@@ -35,8 +35,6 @@
 import android.os.ConditionVariable;
 import android.os.Environment;
 import android.provider.Settings;
-import android.system.ErrnoException;
-import android.system.Os;
 import android.util.Log;
 import android.view.KeyEvent;
 import android.view.Menu;
@@ -61,11 +59,7 @@
 
 import com.google.android.material.appbar.MaterialToolbar;
 
-import java.io.File;
-import java.io.FileDescriptor;
-import java.io.FileNotFoundException;
 import java.io.IOException;
-import java.io.RandomAccessFile;
 import java.net.InetAddress;
 import java.net.MalformedURLException;
 import java.net.URL;
@@ -83,6 +77,7 @@
     private static final int REQUEST_CODE_INSTALLER = 0x33;
     private static final int FONT_SIZE_DEFAULT = 13;
 
+    private InstalledImage mImage;
     private X509Certificate[] mCertificates;
     private PrivateKey mPrivateKey;
     private WebView mWebView;
@@ -90,7 +85,6 @@
     private ConditionVariable mBootCompleted = new ConditionVariable();
     private static final int POST_NOTIFICATIONS_PERMISSION_REQUEST_CODE = 101;
     private ActivityResultLauncher<Intent> mManageExternalStorageActivityResultLauncher;
-    private static int diskSizeStep;
     private static final Map<Integer, Integer> BTN_KEY_CODE_MAP =
             Map.ofEntries(
                     Map.entry(R.id.btn_tab, KeyEvent.KEYCODE_TAB),
@@ -110,6 +104,8 @@
     protected void onCreate(Bundle savedInstanceState) {
         super.onCreate(savedInstanceState);
 
+        mImage = InstalledImage.getDefault(this);
+
         NotificationManager notificationManager = getSystemService(NotificationManager.class);
         if (notificationManager.getNotificationChannel(this.getPackageName()) == null) {
             NotificationChannel channel =
@@ -123,8 +119,6 @@
         boolean launchInstaller = installIfNecessary();
 
         setContentView(R.layout.activity_headless);
-        diskSizeStep = getResources().getInteger(
-                R.integer.disk_size_round_up_step_size_in_mb) << 20;
 
         MaterialToolbar toolbar = (MaterialToolbar) findViewById(R.id.toolbar);
         setSupportActionBar(toolbar);
@@ -196,7 +190,7 @@
     public boolean dispatchKeyEvent(KeyEvent event) {
         if (Build.isDebuggable() && event.getKeyCode() == KeyEvent.KEYCODE_UNKNOWN) {
             if (event.getAction() == KeyEvent.ACTION_UP) {
-                launchErrorActivity(new Exception("Debug: KeyEvent.KEYCODE_UNKNOWN"));
+                ErrorActivity.start(this, new Exception("Debug: KeyEvent.KEYCODE_UNKNOWN"));
             }
             return true;
         }
@@ -334,80 +328,6 @@
                 .start();
     }
 
-    private void diskResize(File file, long sizeInBytes) throws IOException {
-        try {
-            if (sizeInBytes == 0) {
-                return;
-            }
-            String filePath = file.getAbsolutePath();
-            Log.d(TAG, "Disk-resize in progress for partition: " + filePath);
-
-            long currentSize = Os.stat(filePath).st_size;
-            runE2fsck(filePath);
-            if (sizeInBytes > currentSize) {
-                allocateSpace(file, sizeInBytes);
-            }
-
-            resizeFilesystem(filePath, sizeInBytes);
-        } catch (ErrnoException e) {
-            Log.e(TAG, "ErrnoException during disk resize", e);
-            throw new IOException("ErrnoException during disk resize", e);
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to resize disk", e);
-            throw e;
-        }
-    }
-
-    private static void allocateSpace(File file, long sizeInBytes) throws IOException {
-        try {
-            RandomAccessFile raf = new RandomAccessFile(file, "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(String filePath) throws IOException {
-        try {
-            runCommand("/system/bin/e2fsck", "-y", "-f", filePath);
-            Log.d(TAG, "e2fsck completed: " + filePath);
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to run e2fsck", e);
-            throw e;
-        }
-    }
-
-    private static void resizeFilesystem(String filePath, 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";
-        try {
-            runCommand("/system/bin/resize2fs", filePath, sizeArg);
-            Log.d(TAG, "resize2fs completed: " + filePath + ", size: " + sizeArg);
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to run resize2fs", e);
-            throw e;
-        }
-    }
-
-    private static String runCommand(String... command) throws IOException {
-        try {
-            Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
-            process.waitFor();
-            return new String(process.getInputStream().readAllBytes());
-        } catch (InterruptedException e) {
-            Thread.currentThread().interrupt();
-            throw new IOException("Command interrupted", e);
-        }
-    }
-
     private static void waitUntilVmStarts() {
         InetAddress addr = null;
         try {
@@ -445,7 +365,8 @@
     @Override
     public void onVmError() {
         Log.i(TAG, "onVmError()");
-        launchErrorActivity(new Exception("onVmError"));
+        // TODO: error cause is too simple.
+        ErrorActivity.start(this, new Exception("onVmError"));
     }
 
     @Override
@@ -502,17 +423,10 @@
         }
     }
 
-    private void launchErrorActivity(Exception e) {
-        Intent intent = new Intent(this, ErrorActivity.class);
-        intent.putExtra(ErrorActivity.EXTRA_CAUSE, e);
-        intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK | Intent.FLAG_ACTIVITY_NEW_TASK);
-        this.startActivity(intent);
-    }
-
     private boolean installIfNecessary() {
         // If payload from external storage exists(only for debuggable build) or there is no
         // installed image, launch installer activity.
-        if (!InstallUtils.isImageInstalled(this)) {
+        if (!mImage.isInstalled()) {
             Intent intent = new Intent(this, InstallerActivity.class);
             startActivityForResult(intent, REQUEST_CODE_INSTALLER);
             return true;
@@ -521,16 +435,17 @@
     }
 
     private void startVm() {
-        if (!InstallUtils.isImageInstalled(this)) {
+        InstalledImage image = InstalledImage.getDefault(this);
+        if (!image.isInstalled()) {
             return;
         }
 
-        resizeDiskIfNecessary();
+        resizeDiskIfNecessary(image);
 
         Intent tapIntent = new Intent(this, MainActivity.class);
         tapIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
-        PendingIntent tapPendingIntent = PendingIntent.getActivity(this, 0, tapIntent,
-                PendingIntent.FLAG_IMMUTABLE);
+        PendingIntent tapPendingIntent =
+                PendingIntent.getActivity(this, 0, tapIntent, PendingIntent.FLAG_IMMUTABLE);
 
         Intent settingsIntent = new Intent(this, SettingsActivity.class);
         settingsIntent.setFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
@@ -586,52 +501,25 @@
         return mBootCompleted.block(timeoutMillis);
     }
 
-    private static long roundUpDiskSize(long diskSize) {
-        // Round up every diskSizeStep MB
-        return (long) Math.ceil(((double) diskSize) / diskSizeStep) * diskSizeStep;
-    }
+    private void resizeDiskIfNecessary(InstalledImage image) {
+        String prefKey = getString(R.string.preference_file_key);
+        String key = getString(R.string.preference_disk_size_key);
+        SharedPreferences sharedPref = this.getSharedPreferences(prefKey, Context.MODE_PRIVATE);
+        long newSize = sharedPref.getLong(key, -1);
 
-    public static long getMinFilesystemSize(File file) throws IOException, NumberFormatException {
-        try {
-            runE2fsck(file.getAbsolutePath());
-            String result = runCommand("/system/bin/resize2fs", "-P", file.getAbsolutePath());
-            // The return value is the number of 4k block
-            long minSize = Long.parseLong(
-                    result.lines().toArray(String[]::new)[1].substring(42)) * 4 * 1024;
-            return roundUpDiskSize(minSize);
-        } catch (IOException | NumberFormatException e) {
-            Log.e(TAG, "Failed to get filesystem size", e);
-            throw e;
+        // No preferred size. Don't resize.
+        if (newSize == -1) {
+            return;
         }
-    }
 
-    private static long getFilesystemSize(File file) throws ErrnoException {
-        return Os.stat(file.getAbsolutePath()).st_size;
-    }
-
-    private void resizeDiskIfNecessary() {
         try {
-            File file = InstallUtils.getRootfsFile(this);
-            SharedPreferences sharedPref = this.getSharedPreferences(
-                    getString(R.string.preference_file_key), Context.MODE_PRIVATE);
-            SharedPreferences.Editor editor = sharedPref.edit();
-
-            long currentDiskSize = getFilesystemSize(file);
-            long newSizeInBytes = sharedPref.getLong(getString(R.string.preference_disk_size_key),
-                    roundUpDiskSize(currentDiskSize));
-            editor.putLong(getString(R.string.preference_disk_size_key), newSizeInBytes);
-            editor.apply();
-
-            Log.d(TAG, "Current disk size: " + currentDiskSize);
-            Log.d(TAG, "Targeting disk size: " + newSizeInBytes);
-
-            if (newSizeInBytes != currentDiskSize) {
-                diskResize(file, newSizeInBytes);
-            }
-        } catch (FileNotFoundException e) {
-            Log.d(TAG, "No partition file");
-        } catch (IOException | ErrnoException | NumberFormatException e) {
+            Log.d(TAG, "Resizing disk to " + newSize + " bytes");
+            newSize = image.resize(newSize);
+        } catch (IOException e) {
             Log.e(TAG, "Failed to resize disk", e);
+            return;
         }
+
+        sharedPref.edit().putLong(key, newSize).apply();
     }
 }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
index 817808f..442f896 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
@@ -55,9 +55,9 @@
                     0
                 )
             ).toFloat();
-        val partition = InstallUtils.getRootfsFile(this)
+        val image = InstalledImage.getDefault(this)
         val minDiskSizeMb =
-            bytesToMb(MainActivity.getMinFilesystemSize(partition)).toFloat()
+            bytesToMb(image.getSmallestSizePossible()).toFloat()
                 .coerceAtMost(diskSizeMb)
 
         val diskSizeText = findViewById<TextView>(R.id.settings_disk_resize_resize_gb_assigned)
@@ -141,4 +141,4 @@
         }
         return summary
     }
-}
\ No newline at end of file
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActivity.kt
index 1b39ff0..a332a9d 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsPortForwardingActivity.kt
@@ -47,7 +47,7 @@
                 HashSet<String>()
             )
 
-        for (port in ports!!) {
+        for (port in ports!!.sortedWith(compareBy( { it.toInt() } ))) {
             val enabled =
                 sharedPref.getBoolean(
                     getString(R.string.preference_forwarding_port_is_enabled) + port,
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
index e291b57..00730ff 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsRecoveryActivity.kt
@@ -27,6 +27,7 @@
 import com.google.android.material.dialog.MaterialAlertDialogBuilder
 import com.google.android.material.snackbar.Snackbar
 import java.io.IOException
+import java.nio.file.Files
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
@@ -55,7 +56,8 @@
             dialog.show()
         }
         val resetBackupCard = findViewById<View>(R.id.settings_recovery_reset_backup_card)
-        resetBackupCard.isVisible = InstallUtils.getBackupFile(this).exists()
+
+        resetBackupCard.isVisible = InstalledImage.getDefault(this).hasBackup()
 
         resetBackupCard.setOnClickListener {
             val dialog = MaterialAlertDialogBuilder(this)
@@ -73,7 +75,9 @@
     }
 
     private fun removeBackup(): Unit {
-        if (!InstallUtils.getBackupFile(this@SettingsRecoveryActivity).delete()) {
+        try {
+            InstalledImage.getDefault(this).deleteBackup()
+        } catch (e: IOException) {
             Snackbar.make(
                 findViewById(android.R.id.content),
                 R.string.settings_recovery_error_during_removing_backup,
@@ -85,12 +89,14 @@
 
     private fun uninstall(backupRootfs: Boolean): Unit {
         var backupDone = false
+        val image = InstalledImage.getDefault(this)
         try {
             if (backupRootfs) {
-                InstallUtils.backupRootFs(this@SettingsRecoveryActivity)
+                image.uninstallAndBackup()
                 backupDone = true
+            } else {
+                image.uninstallFully()
             }
-            InstallUtils.deleteInstallation(this@SettingsRecoveryActivity)
         } catch (e: IOException) {
             val errorMsgId = if (backupRootfs && !backupDone) R.string.settings_recovery_error_due_to_backup
                     else R.string.settings_recovery_error;
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
index 274f423..efee62f 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
@@ -244,6 +244,7 @@
                             info.getBoundsInScreen(rect);
                             if (rect.width() == 0) {
                                 info.setText(null);
+                                info.setContentDescription(getString(R.string.empty_line));
                             }
                             info.setScreenReaderFocusable(false);
                             break;
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
index 652af8e..c2d224a 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.java
@@ -35,6 +35,7 @@
 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;
@@ -50,6 +51,7 @@
 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.Set;
@@ -151,13 +153,12 @@
         }
         mExecutorService = Executors.newCachedThreadPool();
 
-        ConfigJson json = ConfigJson.from(InstallUtils.getVmConfigPath(this));
+        InstalledImage image = InstalledImage.getDefault(this);
+        ConfigJson json = ConfigJson.from(this, image.getConfigPath());
         VirtualMachineConfig.Builder configBuilder = json.toConfigBuilder(this);
         VirtualMachineCustomImageConfig.Builder customImageConfigBuilder =
                 json.toCustomImageConfigBuilder(this);
-        File backupFile = InstallUtils.getBackupFile(this);
-        if (backupFile.exists()) {
-            customImageConfigBuilder.addDisk(Disk.RWDisk(backupFile.getPath()));
+        if (overrideConfigIfNecessary(customImageConfigBuilder)) {
             configBuilder.setCustomImageConfig(customImageConfigBuilder.build());
         }
         VirtualMachineConfig config = configBuilder.build();
@@ -201,6 +202,33 @@
         return START_NOT_STICKY;
     }
 
+    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() {
         ServerInterceptor interceptor =
                 new ServerInterceptor() {
diff --git a/android/TerminalApp/res/values/config.xml b/android/TerminalApp/res/values/config.xml
index 9d2456c..ea762fc 100644
--- a/android/TerminalApp/res/values/config.xml
+++ b/android/TerminalApp/res/values/config.xml
@@ -17,7 +17,6 @@
 <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
     <string name="preference_file_key" translatable="false">com.android.virtualization.terminal.PREFERENCE_FILE_KEY</string>
     <string name="preference_disk_size_key" translatable="false">PREFERENCE_DISK_SIZE_KEY</string>
-    <string name="preference_min_disk_size_key" translatable="false">PREFERENCE_MIN_DISK_SIZE_KEY</string>
     <string name="preference_forwarding_ports" translatable="false">PREFERENCE_FORWARDING_PORTS</string>
     <string name="preference_forwarding_port_is_enabled" translatable="false">PREFERENCE_FORWARDING_PORT_IS_ENABLED_</string>
-</resources>
\ No newline at end of file
+</resources>
diff --git a/android/TerminalApp/res/values/strings.xml b/android/TerminalApp/res/values/strings.xml
index 851eaab..da8ca84 100644
--- a/android/TerminalApp/res/values/strings.xml
+++ b/android/TerminalApp/res/values/strings.xml
@@ -132,4 +132,7 @@
     <string name="service_notification_content">Click to open the terminal</string>
     <!-- Notification action button for closing the virtual machine [CHAR LIMIT=20] -->
     <string name="service_notification_quit_action">Close</string>
+
+    <!-- VirGL is the name of hardware acceleration for VM, the name is supposed not to be translated. [CHAR LIMIT=20] -->
+    <string name="virgl_enabled">VirGL is enabled</string>
 </resources>
diff --git a/android/virtmgr/src/aidl.rs b/android/virtmgr/src/aidl.rs
index 1cae344..9a733b6 100644
--- a/android/virtmgr/src/aidl.rs
+++ b/android/virtmgr/src/aidl.rs
@@ -573,41 +573,42 @@
                 .or_binder_exception(ExceptionCode::SECURITY)?;
         }
 
-        // Check if partition images are labeled incorrectly. This is to prevent random images
-        // which are not protected by the Android Verified Boot (e.g. bits downloaded by apps) from
-        // being loaded in a pVM. This applies to everything but the instance image in the raw
-        // config, and everything but the non-executable, generated partitions in the app
-        // config.
-        config
-            .disks
-            .iter()
-            .flat_map(|disk| disk.partitions.iter())
-            .filter(|partition| {
-                if is_app_config {
-                    !is_safe_app_partition(&partition.label)
-                } else {
-                    !is_safe_raw_partition(&partition.label)
-                }
-            })
-            .try_for_each(check_label_for_partition)
-            .or_service_specific_exception(-1)?;
+        let kernel = maybe_clone_file(&config.kernel)?;
+        let initrd = maybe_clone_file(&config.initrd)?;
+
+        if config.protectedVm {
+            // Fail fast with a meaningful error message in case device doesn't support pVMs.
+            check_protected_vm_is_supported()?;
+
+            // In a protected VM, we require custom kernels to come from a trusted source
+            // (b/237054515).
+            check_label_for_kernel_files(&kernel, &initrd).or_service_specific_exception(-1)?;
+
+            // Check if partition images are labeled incorrectly. This is to prevent random images
+            // which are not protected by the Android Verified Boot (e.g. bits downloaded by apps)
+            // from being loaded in a pVM. This applies to everything but the instance image in the
+            // raw config, and everything but the non-executable, generated partitions in the app
+            // config.
+            config
+                .disks
+                .iter()
+                .flat_map(|disk| disk.partitions.iter())
+                .filter(|partition| {
+                    if is_app_config {
+                        !is_safe_app_partition(&partition.label)
+                    } else {
+                        !is_safe_raw_partition(&partition.label)
+                    }
+                })
+                .try_for_each(check_label_for_partition)
+                .or_service_specific_exception(-1)?;
+        }
 
         // Check if files for payloads and bases are NOT coming from /vendor and /odm, as they may
         // have unstable interfaces.
         // TODO(b/316431494): remove once Treble interfaces are stabilized.
         check_partitions_for_files(config).or_service_specific_exception(-1)?;
 
-        let kernel = maybe_clone_file(&config.kernel)?;
-        let initrd = maybe_clone_file(&config.initrd)?;
-
-        if config.protectedVm {
-            // In a protected VM, we require custom kernels to come from a trusted source
-            // (b/237054515).
-            check_label_for_kernel_files(&kernel, &initrd).or_service_specific_exception(-1)?;
-            // Fail fast with a meaningful error message in case device doesn't support pVMs.
-            check_protected_vm_is_supported()?;
-        }
-
         let zero_filler_path = temporary_directory.join("zero.img");
         write_zero_filler(&zero_filler_path)
             .context("Failed to make composite image")
diff --git a/build/debian/build.sh b/build/debian/build.sh
index 59a98b6..8f232aa 100755
--- a/build/debian/build.sh
+++ b/build/debian/build.sh
@@ -144,7 +144,7 @@
 build_ttyd() {
 	local ttyd_version=1.7.7
 	local url="https://github.com/tsl0922/ttyd/archive/refs/tags/${ttyd_version}.tar.gz"
-	cp -r $(dirname $0)/ttyd ${workdir}/ttyd
+	cp -r "$(dirname "$0")/ttyd" "${workdir}/ttyd"
 
 	pushd "${workdir}" > /dev/null
 	wget "${url}" -O - | tar xz
@@ -152,7 +152,7 @@
 	pushd "$workdir/ttyd-${ttyd_version}" > /dev/null
 	bash -c "env BUILD_TARGET=${arch} ./scripts/cross-build.sh"
 	mkdir -p "${dst}/files/usr/local/bin/ttyd"
-	cp /tmp/stage/${arch}-linux-musl/bin/ttyd "${dst}/files/usr/local/bin/ttyd/AVF"
+	cp "/tmp/stage/${arch}-linux-musl/bin/ttyd" "${dst}/files/usr/local/bin/ttyd/AVF"
 	chmod 777 "${dst}/files/usr/local/bin/ttyd/AVF"
 	mkdir -p "${dst}/files/usr/share/doc/ttyd"
 	cp LICENSE "${dst}/files/usr/share/doc/ttyd/copyright"
@@ -161,8 +161,10 @@
 }
 
 copy_android_config() {
-	local src="$(dirname "$0")/fai_config"
-	local dst="${config_space}"
+	local src
+	local dst
+	src="$(dirname "$0")/fai_config"
+	dst="${config_space}"
 
 	cp -R "${src}"/* "${dst}"
 	cp "$(dirname "$0")/image.yaml" "${resources_dir}"
@@ -181,14 +183,21 @@
 
 extract_partitions() {
 	root_partition_num=1
+	bios_partition_num=14
 	efi_partition_num=15
 
 	loop=$(losetup -f --show --partscan $built_image)
-	dd if=${loop}p$root_partition_num of=root_part
-	dd if=${loop}p$efi_partition_num of=efi_part
-	losetup -d ${loop}
+	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
+	fi
+	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
+	if [[ "$arch" == "x86_64" ]]; then
+		sed -i "s/{bios_part_guid}/$(sfdisk --part-uuid $built_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
 }
 
@@ -217,27 +226,28 @@
 fdisk -l "${built_image}"
 images=()
 
-cp $(dirname $0)/vm_config.json.${arch} vm_config.json
+cp "$(dirname "$0")/vm_config.json.${arch}" vm_config.json
+
+extract_partitions
 
 if [[ "$arch" == "aarch64" ]]; then
-	extract_partitions
 	images+=(
 		root_part
 		efi_part
 	)
-fi
-
 # TODO(b/365955006): remove these lines when uboot supports x86_64 EFI application
-if [[ "$arch" == "x86_64" ]]; then
+elif [[ "$arch" == "x86_64" ]]; then
 	virt-get-kernel -a "${built_image}"
 	mv vmlinuz* vmlinuz
 	mv initrd.img* initrd.img
 	images+=(
-		"${built_image}"
+		boot_part
+		root_part
+		efi_part
 		vmlinuz
 		initrd.img
 	)
 fi
 
 # --sparse option isn't supported in apache-commons-compress
-tar czv -f images.tar.gz ${images[@]} vm_config.json
+tar czv -f images.tar.gz "${images[@]}" vm_config.json
diff --git a/build/debian/fai_config/scripts/AVF/10-systemd b/build/debian/fai_config/scripts/AVF/10-systemd
index a514299..94838bc 100755
--- a/build/debian/fai_config/scripts/AVF/10-systemd
+++ b/build/debian/fai_config/scripts/AVF/10-systemd
@@ -10,3 +10,5 @@
 ln -s /etc/systemd/system/forwarder_guest_launcher.service $target/etc/systemd/system/multi-user.target.wants/forwarder_guest_launcher.service
 ln -s /etc/systemd/system/virtiofs_internal.service $target/etc/systemd/system/multi-user.target.wants/virtiofs_internal.service
 ln -s /etc/systemd/system/backup_mount.service $target/etc/systemd/system/multi-user.target.wants/backup_mount.service
+
+sed -i 's/#LLMNR=yes/LLMNR=no/' $target/etc/systemd/resolved.conf
diff --git a/build/debian/vm_config.json.x86_64 b/build/debian/vm_config.json.x86_64
index d338080..4d31105 100644
--- a/build/debian/vm_config.json.x86_64
+++ b/build/debian/vm_config.json.x86_64
@@ -2,8 +2,26 @@
     "name": "debian",
     "disks": [
         {
-            "image": "$PAYLOAD_DIR/image.raw",
-            "partitions": [],
+            "partitions": [
+                {
+                    "label": "ROOT",
+                    "path": "$PAYLOAD_DIR/root_part",
+                    "writable": true,
+                    "guid": "{root_part_guid}"
+                },
+                {
+                    "label": "BIOS",
+                    "path": "$PAYLOAD_DIR/bios_part",
+                    "writable": true,
+                    "guid": "{root_part_guid}"
+                },
+                {
+                    "label": "EFI",
+                    "path": "$PAYLOAD_DIR/efi_part",
+                    "writable": false,
+                    "guid": "{efi_part_guid}"
+                }
+            ],
             "writable": true
         }
     ],
diff --git a/docs/custom_vm.md b/docs/custom_vm.md
index eaff2a4..7597131 100644
--- a/docs/custom_vm.md
+++ b/docs/custom_vm.md
@@ -42,4 +42,8 @@
 (or `localhost:6080` if port forwarding is enabled.)
 
 `weston` with VNC backend might be another option, but it isn't available in
-Debian package repository for bookworm.
\ No newline at end of file
+Debian package repository for bookworm.
+
+## Hardware accelration
+If the file `/sdcard/linux/virglrenderer` exists on the device, it enables VirGL for VM.
+This requires enabling ANGLE for the Terminal app. (https://chromium.googlesource.com/angle/angle.git/+/HEAD/doc/DevSetupAndroid.md)
diff --git a/guest/forwarder_guest_launcher/src/main.rs b/guest/forwarder_guest_launcher/src/main.rs
index 1da37b4..f6944d6 100644
--- a/guest/forwarder_guest_launcher/src/main.rs
+++ b/guest/forwarder_guest_launcher/src/main.rs
@@ -35,6 +35,7 @@
 }
 
 const NON_PREVILEGED_PORT_RANGE_START: i32 = 1024;
+const TTYD_PORT: i32 = 7681;
 const TCPSTATES_IP_4: i8 = 4;
 const TCPSTATES_STATE_CLOSE: &str = "CLOSE";
 const TCPSTATES_STATE_LISTEN: &str = "LISTEN";
@@ -108,6 +109,10 @@
     Ok(())
 }
 
+fn is_forwardable_port(port: i32) -> bool {
+    port >= NON_PREVILEGED_PORT_RANGE_START && port != TTYD_PORT
+}
+
 async fn report_active_ports(
     mut client: DebianServiceClient<Channel>,
 ) -> Result<(), Box<dyn std::error::Error>> {
@@ -130,7 +135,7 @@
         .map(|x| x.socket)
         .filter(|x| x.is_ipv4())
         .map(|x| x.port().into())
-        .filter(|x| *x >= NON_PREVILEGED_PORT_RANGE_START) // Ignore privileged ports
+        .filter(|x| is_forwardable_port(*x))
         .collect();
     send_active_ports_report(listening_ports.clone(), &mut client).await?;
 
@@ -140,7 +145,7 @@
         if row.ip != TCPSTATES_IP_4 {
             continue;
         }
-        if row.lport < NON_PREVILEGED_PORT_RANGE_START {
+        if !is_forwardable_port(row.lport) {
             continue;
         }
         if row.rport > 0 {
diff --git a/tests/Terminal/src/com/android/virtualization/terminal/TerminalAppTest.java b/tests/Terminal/src/com/android/virtualization/terminal/TerminalAppTest.java
index 3c0461d..42c31e3 100644
--- a/tests/Terminal/src/com/android/virtualization/terminal/TerminalAppTest.java
+++ b/tests/Terminal/src/com/android/virtualization/terminal/TerminalAppTest.java
@@ -33,6 +33,7 @@
 import org.junit.Test;
 import org.junit.runner.RunWith;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -88,7 +89,7 @@
     }
 
     @After
-    public void tearDown() {
-        InstallUtils.deleteInstallation(mTargetContext);
+    public void tearDown() throws IOException {
+        InstalledImage.getDefault(mTargetContext).uninstallFully();
     }
 }