TerminalApp: Use sparse file for debian rootfs if storage balloon enabled

When storage ballooning is enabled, we can use a larger sparse disk
for debian rootfs.

Bug: 382174138
Test: Run a VM with the flag enabled

Change-Id: I9d9ee79558ab0fd75f4b92b1248777de80d2d155
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
index 7acc5f3..23bf48d 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/InstalledImage.kt
@@ -25,9 +25,6 @@
 import java.io.FileReader
 import java.io.IOException
 import java.io.RandomAccessFile
-import java.lang.IllegalArgumentException
-import java.lang.NumberFormatException
-import java.lang.RuntimeException
 import java.nio.file.Files
 import java.nio.file.Path
 import java.nio.file.StandardCopyOption
@@ -91,6 +88,13 @@
     }
 
     @Throws(IOException::class)
+    fun getPhysicalSize(): Long {
+        val stat = RandomAccessFile(rootPartition.toFile(), "rw").use { raf -> Os.fstat(raf.fd) }
+        // The unit of st_blocks is 512 byte in Android.
+        return 512L * stat.st_blocks
+    }
+
+    @Throws(IOException::class)
     fun getSmallestSizePossible(): Long {
         runE2fsck(rootPartition)
         val p: String = rootPartition.toAbsolutePath().toString()
@@ -128,6 +132,17 @@
         return getSize()
     }
 
+    @Throws(IOException::class)
+    fun truncate(size: Long) {
+        try {
+            RandomAccessFile(rootPartition.toFile(), "rw").use { raf -> Os.ftruncate(raf.fd, size) }
+            Log.d(TAG, "Truncated space to: $size bytes")
+        } catch (e: ErrnoException) {
+            Log.e(TAG, "Failed to allocate space", e)
+            throw IOException("Failed to allocate space", e)
+        }
+    }
+
     companion object {
         private const val INSTALL_DIRNAME = "linux"
         private const val ROOTFS_FILENAME = "root_part"
@@ -147,10 +162,9 @@
         @Throws(IOException::class)
         private fun allocateSpace(path: Path, sizeInBytes: Long) {
             try {
-                val raf = RandomAccessFile(path.toFile(), "rw")
-                val fd = raf.fd
-                Os.posix_fallocate(fd, 0, sizeInBytes)
-                raf.close()
+                RandomAccessFile(path.toFile(), "rw").use { raf ->
+                    Os.posix_fallocate(raf.fd, 0, sizeInBytes)
+                }
                 Log.d(TAG, "Allocated space to: $sizeInBytes bytes")
             } catch (e: ErrnoException) {
                 Log.e(TAG, "Failed to allocate space", e)
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
index 487b7e8..f4306bf 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
@@ -49,7 +49,7 @@
 import androidx.viewpager2.widget.ViewPager2
 import com.android.internal.annotations.VisibleForTesting
 import com.android.microdroid.test.common.DeviceProperties
-import com.android.system.virtualmachine.flags.Flags.terminalGuiSupport
+import com.android.system.virtualmachine.flags.Flags
 import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback
 import com.google.android.material.tabs.TabLayout
 import com.google.android.material.tabs.TabLayoutMediator
@@ -126,9 +126,9 @@
         }
 
         displayMenu?.also {
-            it.visibility = if (terminalGuiSupport()) View.VISIBLE else View.GONE
+            it.visibility = if (Flags.terminalGuiSupport()) View.VISIBLE else View.GONE
             it.setEnabled(false)
-            if (terminalGuiSupport()) {
+            if (Flags.terminalGuiSupport()) {
                 it.setOnClickListener {
                     val intent = Intent(this, DisplayActivity::class.java)
                     intent.flags =
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
index 067d540..f426ce6 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -31,6 +31,7 @@
 import android.os.Parcel
 import android.os.Parcelable
 import android.os.ResultReceiver
+import android.os.StatFs
 import android.os.SystemProperties
 import android.system.virtualmachine.VirtualMachine
 import android.system.virtualmachine.VirtualMachineCustomImageConfig
@@ -139,6 +140,30 @@
         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.
+        val statFs = StatFs(filesDir.absolutePath)
+        val hostSize = statFs.totalBytes
+        val expectedSize = hostSize * GUEST_SPARSE_DISK_SIZE_PERCENTAGE / 100
+        Log.d(
+            TAG,
+            "rootfs apparent size=$curSize, physical size=$physicalSize, expectedSize=$expectedSize",
+        )
+
+        if (curSize != expectedSize) {
+            try {
+                image.truncate(expectedSize)
+            } catch (e: IOException) {
+                throw RuntimeException("Failed to truncate a disk", e)
+            }
+        }
+    }
+
     @WorkerThread
     private fun doStart(
         notification: Notification,
@@ -150,7 +175,15 @@
         val json = ConfigJson.from(this, image.configPath)
         val configBuilder = json.toConfigBuilder(this)
         val customImageConfigBuilder = json.toCustomImageConfigBuilder(this)
-        image.resize(diskSize)
+
+        if (Flags.terminalStorageBalloon()) {
+            // When storage ballooning flag is enabled, convert rootfs disk into a sparse file.
+            truncateDiskIfNecessary(image)
+        } else {
+            // 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)
+        }
 
         customImageConfigBuilder.setAudioConfig(
             AudioConfig.Builder().setUseSpeaker(true).setUseMicrophone(true).build()
@@ -445,6 +478,8 @@
         private const val KEY_TERMINAL_IPADDRESS = "address"
         private const val KEY_TERMINAL_PORT = "port"
 
+        private const val GUEST_SPARSE_DISK_SIZE_PERCENTAGE = 95
+
         private val VM_BOOT_TIMEOUT_SECONDS: Int =
             {
                 val deviceName = SystemProperties.get("ro.product.vendor.device", "")