Add InstalledImage

... and move resize functions to the class.

Bug: N/A
Test: N/A
Change-Id: Ic6affe75733b219a186613700cd7b966fcc74e11
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..deb0c14
--- /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 backupFile() {
+        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/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
index ded186f..6740778 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,16 +59,12 @@
 
 import com.google.android.material.appbar.MaterialToolbar;
 
-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;
 import java.net.UnknownHostException;
-import java.nio.file.Files;
-import java.nio.file.Path;
 import java.security.KeyStore;
 import java.security.PrivateKey;
 import java.security.cert.X509Certificate;
@@ -84,6 +78,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;
@@ -91,7 +86,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),
@@ -111,6 +105,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 =
@@ -124,8 +120,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);
@@ -335,78 +329,6 @@
                 .start();
     }
 
-    private void diskResize(Path path, long sizeInBytes) throws IOException {
-        try {
-            if (sizeInBytes == 0) {
-                return;
-            }
-            Log.d(TAG, "Disk-resize in progress for partition: " + path);
-
-            long currentSize = Files.size(path);
-            runE2fsck(path);
-            if (sizeInBytes > currentSize) {
-                allocateSpace(path, sizeInBytes);
-            }
-
-            resizeFilesystem(path, sizeInBytes);
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to resize disk", e);
-            throw e;
-        }
-    }
-
-    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 {
-        try {
-            String p = path.toAbsolutePath().toString();
-            runCommand("/system/bin/e2fsck", "-y", "-f", p);
-            Log.d(TAG, "e2fsck completed: " + path);
-        } catch (IOException e) {
-            Log.e(TAG, "Failed to run e2fsck", e);
-            throw e;
-        }
-    }
-
-    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";
-        try {
-            String p = path.toAbsolutePath().toString();
-            runCommand("/system/bin/resize2fs", p, sizeArg);
-            Log.d(TAG, "resize2fs completed: " + path + ", 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 {
@@ -505,7 +427,7 @@
     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;
@@ -514,11 +436,12 @@
     }
 
     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);
@@ -579,53 +502,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(Path path) throws IOException, NumberFormatException {
-        try {
-            runE2fsck(path);
-            String p = path.toAbsolutePath().toString();
-            String result = runCommand("/system/bin/resize2fs", "-P", p);
-            // 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(Path fsPath) throws ErrnoException {
-        return Os.stat(fsPath.toAbsolutePath().toString()).st_size;
-    }
-
-    private void resizeDiskIfNecessary() {
         try {
-            Path 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
+}