Terminal: Resize sparse disk when storage balloon is turned off

When the storage balloon feature is enabled via an aconfig flag,
the rootfs is converted to a sparse file, increasing its apparent
size. When the flag is disabled again afterwards (e.g. rollback
or local testing), the disk needs to be converted back to
non-sparse. Otherwise, the disk resize settings screen would
display its apparent size which is too big.

This CL addresses this issue by shrinking the disk size with
resize2fs if the rootfs is a sparse file while the storage balloon
feature is disabled

Bug: 382174138
Test: enable 'terminal_storage_balloon' and disable it. Check disk
      size setting.

Change-Id: Ifadaa709b8f74dc99b68c9088d6a4fda06805a98
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
index 23bf48d..a6765ba 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
@@ -83,7 +83,7 @@
     }
 
     @Throws(IOException::class)
-    fun getSize(): Long {
+    fun getApparentSize(): Long {
         return Files.size(rootPartition)
     }
 
@@ -118,7 +118,7 @@
     @Throws(IOException::class)
     fun resize(desiredSize: Long): Long {
         val roundedUpDesiredSize = roundUp(desiredSize)
-        val curSize = getSize()
+        val curSize = getApparentSize()
 
         if (roundedUpDesiredSize == curSize) {
             return roundedUpDesiredSize
@@ -129,7 +129,21 @@
             allocateSpace(rootPartition, roundedUpDesiredSize)
         }
         resizeFilesystem(rootPartition, roundedUpDesiredSize)
-        return getSize()
+        return getApparentSize()
+    }
+
+    @Throws(IOException::class)
+    fun shrinkToMinimumSize(): Long {
+        // Fix filesystem before resizing.
+        runE2fsck(rootPartition)
+
+        val p: String = rootPartition.toAbsolutePath().toString()
+        runCommand("/system/bin/resize2fs", "-M", p)
+        Log.d(TAG, "resize2fs -M completed: $rootPartition")
+
+        // resize2fs may result in an inconsistent filesystem state. Fix with e2fsck.
+        runE2fsck(rootPartition)
+        return getApparentSize()
     }
 
     @Throws(IOException::class)
@@ -212,7 +226,7 @@
             }
         }
 
-        private fun roundUp(bytes: Long): Long {
+        internal 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/MainActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
index f4306bf..ddb0dcd 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
@@ -50,6 +50,7 @@
 import com.android.internal.annotations.VisibleForTesting
 import com.android.microdroid.test.common.DeviceProperties
 import com.android.system.virtualmachine.flags.Flags
+import com.android.virtualization.terminal.ErrorActivity.Companion.start
 import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback
 import com.google.android.material.tabs.TabLayout
 import com.google.android.material.tabs.TabLayoutMediator
@@ -355,7 +356,7 @@
                 )
                 .build()
 
-        val diskSize = intent.getLongExtra(EXTRA_DISK_SIZE, image.getSize())
+        val diskSize = intent.getLongExtra(EXTRA_DISK_SIZE, image.getApparentSize())
 
         val intent =
             VmLauncherService.getIntentForStart(
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
index af1ae95..144db40 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/SettingsDiskResizeActivity.kt
@@ -71,7 +71,7 @@
         diskSizeStepMb = 1L shl resources.getInteger(R.integer.disk_size_round_up_step_size_in_mb)
 
         val image = InstalledImage.getDefault(this)
-        diskSizeMb = bytesToMb(image.getSize())
+        diskSizeMb = bytesToMb(image.getApparentSize())
         val minDiskSizeMb = bytesToMb(image.getSmallestSizePossible()).coerceAtMost(diskSizeMb)
         val usableSpaceMb =
             bytesToMb(Environment.getDataDirectory().getUsableSpace()) and
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
index f426ce6..d43a8d1 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -41,6 +41,7 @@
 import android.widget.Toast
 import androidx.annotation.WorkerThread
 import com.android.system.virtualmachine.flags.Flags
+import com.android.virtualization.terminal.InstalledImage.Companion.roundUp
 import com.android.virtualization.terminal.MainActivity.Companion.PREFIX
 import com.android.virtualization.terminal.MainActivity.Companion.TAG
 import io.grpc.Grpc
@@ -120,7 +121,7 @@
                 // Note: this doesn't always do the resizing. If the current image size is the same
                 // as the requested size which is rounded up to the page alignment, resizing is not
                 // done.
-                val diskSize = intent.getLongExtra(EXTRA_DISK_SIZE, image.getSize())
+                val diskSize = intent.getLongExtra(EXTRA_DISK_SIZE, image.getApparentSize())
 
                 mainWorkerThread.submit({
                     doStart(notification, displayInfo, diskSize, resultReceiver)
@@ -140,16 +141,18 @@
         return START_NOT_STICKY
     }
 
-    private fun truncateDiskIfNecessary(image: InstalledImage) {
-        val curSize = image.getSize()
-        val physicalSize = image.getPhysicalSize()
-
-        // Change the rootfs disk's apparent size to GUEST_SPARSE_DISK_SIZE_PERCENTAGE of the total
-        // disk size.
-        // Note that the physical size is not changed.
+    private fun calculateSparseDiskSize(): Long {
+        // With storage ballooning enabled, we create a sparse file with 95% of the total size.
         val statFs = StatFs(filesDir.absolutePath)
         val hostSize = statFs.totalBytes
-        val expectedSize = hostSize * GUEST_SPARSE_DISK_SIZE_PERCENTAGE / 100
+        return roundUp(hostSize * GUEST_SPARSE_DISK_SIZE_PERCENTAGE / 100)
+    }
+
+    private fun truncateDiskIfNecessary(image: InstalledImage) {
+        val curSize = image.getApparentSize()
+        val physicalSize = image.getPhysicalSize()
+
+        val expectedSize = calculateSparseDiskSize()
         Log.d(
             TAG,
             "rootfs apparent size=$curSize, physical size=$physicalSize, expectedSize=$expectedSize",
@@ -164,6 +167,38 @@
         }
     }
 
+    // Convert the rootfs disk to a non-sparse file.
+    private fun convertToNonSparseDiskIfNecessary(image: InstalledImage) {
+        try {
+            val curApparentSize = image.getApparentSize()
+            val curPhysicalSize = image.getPhysicalSize()
+            Log.d(TAG, "Current disk size: apparent=$curApparentSize, physical=$curPhysicalSize")
+
+            // If storage ballooning was enabled via Flags.terminalStorageBalloon() before but it's
+            // now disabled, the disk is still a sparse file whose apparent size is too large.
+            // We need to shrink it to the minimum size.
+            //
+            // The disk file is considered sparse if its apparent disk size matches the expected
+            // sparse disk size.
+            // In addition, we consider it sparse if the physical size is clearly smaller than its
+            // apparent size. This additional condition is a fallback for cases
+            // where the logic of calculating the expected sparse disk size since the disk is
+            // created.
+            if (
+                curApparentSize == calculateSparseDiskSize() ||
+                    curPhysicalSize <
+                        curApparentSize * EXPECTED_PHYSICAL_SIZE_PERCENTAGE_FOR_NON_SPARSE / 100
+            ) {
+                Log.d(TAG, "A sparse disk is detected. Shrink it to the minimum size.")
+                val newSize = image.shrinkToMinimumSize()
+                Log.d(TAG, "Shrink the disk image: $curApparentSize -> $newSize")
+            }
+        } catch (e: IOException) {
+            throw RuntimeException("Failed to shrink rootfs disk", e)
+            return
+        }
+    }
+
     @WorkerThread
     private fun doStart(
         notification: Notification,
@@ -180,6 +215,10 @@
             // When storage ballooning flag is enabled, convert rootfs disk into a sparse file.
             truncateDiskIfNecessary(image)
         } else {
+            // Convert rootfs disk into a sparse file if storage ballooning flag had been enabled
+            // and then disabled.
+            convertToNonSparseDiskIfNecessary(image)
+
             // Note: this doesn't always do the resizing. If the current image size is the same as
             // the requested size which is rounded up to the page alignment, resizing is not done.
             image.resize(diskSize)
@@ -479,6 +518,7 @@
         private const val KEY_TERMINAL_PORT = "port"
 
         private const val GUEST_SPARSE_DISK_SIZE_PERCENTAGE = 95
+        private const val EXPECTED_PHYSICAL_SIZE_PERCENTAGE_FOR_NON_SPARSE = 90
 
         private val VM_BOOT_TIMEOUT_SECONDS: Int =
             {