Merge "Implement memory ballooning in Terminal App" into main
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/Application.kt b/android/TerminalApp/java/com/android/virtualization/terminal/Application.kt
index efe651e..9f4909d 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/Application.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/Application.kt
@@ -18,12 +18,21 @@
 import android.app.Application as AndroidApplication
 import android.app.NotificationChannel
 import android.app.NotificationManager
+import android.content.ComponentName
 import android.content.Context
+import android.content.Intent
+import android.content.ServiceConnection
+import android.os.IBinder
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.ProcessLifecycleOwner
 
 public class Application : AndroidApplication() {
     override fun onCreate() {
         super.onCreate()
         setupNotificationChannels()
+        val lifecycleObserver = ApplicationLifecycleObserver()
+        ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
     }
 
     private fun setupNotificationChannels() {
@@ -52,4 +61,53 @@
 
         fun getInstance(c: Context): Application = c.getApplicationContext() as Application
     }
+
+    /**
+     * Observes application lifecycle events and interacts with the VmLauncherService to manage
+     * virtual machine state based on application lifecycle transitions. This class binds to the
+     * VmLauncherService and notifies it of application lifecycle events (onStart, onStop), allowing
+     * the service to manage the VM accordingly.
+     */
+    inner class ApplicationLifecycleObserver() : DefaultLifecycleObserver {
+        private var vmLauncherService: VmLauncherService? = null
+        private val connection =
+            object : ServiceConnection {
+                override fun onServiceConnected(className: ComponentName, service: IBinder) {
+                    val binder = service as VmLauncherService.VmLauncherServiceBinder
+                    vmLauncherService = binder.getService()
+                }
+
+                override fun onServiceDisconnected(arg0: ComponentName) {
+                    vmLauncherService = null
+                }
+            }
+
+        override fun onCreate(owner: LifecycleOwner) {
+            super.onCreate(owner)
+            bindToVmLauncherService()
+        }
+
+        override fun onStart(owner: LifecycleOwner) {
+            super.onStart(owner)
+            vmLauncherService?.processAppLifeCycleEvent(ApplicationLifeCycleEvent.APP_ON_START)
+        }
+
+        override fun onStop(owner: LifecycleOwner) {
+            vmLauncherService?.processAppLifeCycleEvent(ApplicationLifeCycleEvent.APP_ON_STOP)
+            super.onStop(owner)
+        }
+
+        override fun onDestroy(owner: LifecycleOwner) {
+            if (vmLauncherService != null) {
+                this@Application.unbindService(connection)
+                vmLauncherService = null
+            }
+            super.onDestroy(owner)
+        }
+
+        fun bindToVmLauncherService() {
+            val intent = Intent(this@Application, VmLauncherService::class.java)
+            this@Application.bindService(intent, connection, 0) // No BIND_AUTO_CREATE
+        }
+    }
 }
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ApplicationLifeCycleEvent.kt b/android/TerminalApp/java/com/android/virtualization/terminal/ApplicationLifeCycleEvent.kt
new file mode 100644
index 0000000..4e26c3c
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ApplicationLifeCycleEvent.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright 2025 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
+
+enum class ApplicationLifeCycleEvent {
+    APP_ON_START,
+    APP_ON_STOP,
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
index 6301da4..4bfad62 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/VmLauncherService.kt
@@ -62,6 +62,12 @@
 import java.util.concurrent.Executors
 
 class VmLauncherService : Service() {
+    inner class VmLauncherServiceBinder : android.os.Binder() {
+        fun getService(): VmLauncherService = this@VmLauncherService
+    }
+
+    private val binder = VmLauncherServiceBinder()
+
     // TODO: using lateinit for some fields to avoid null
     private var executorService: ExecutorService? = null
     private var virtualMachine: VirtualMachine? = null
@@ -79,7 +85,32 @@
     }
 
     override fun onBind(intent: Intent?): IBinder? {
-        return null
+        return binder
+    }
+
+    /**
+     * Processes application lifecycle events and adjusts the virtual machine's memory balloon
+     * accordingly.
+     *
+     * @param event The application lifecycle event.
+     */
+    fun processAppLifeCycleEvent(event: ApplicationLifeCycleEvent) {
+        when (event) {
+            // When the app starts, reset the memory balloon to 0%.
+            // This gives the app maximum available memory.
+            ApplicationLifeCycleEvent.APP_ON_START -> {
+                virtualMachine?.setMemoryBalloonByPercent(0)
+            }
+            ApplicationLifeCycleEvent.APP_ON_STOP -> {
+                // When the app stops, inflate the memory balloon to 10%.
+                // This allows the system to reclaim memory while the app is in the background.
+                // TODO(b/400590341) Inflate the balloon while the application remains Stop status.
+                virtualMachine?.setMemoryBalloonByPercent(10)
+            }
+            else -> {
+                Log.e(TAG, "unrecognized lifecycle event: $event")
+            }
+        }
     }
 
     override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
diff --git a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachine.java b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachine.java
index 0445fcb..40050c0 100644
--- a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachine.java
+++ b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachine.java
@@ -288,17 +288,7 @@
                 percent = 50;
             }
 
-            synchronized (mLock) {
-                try {
-                    if (mVirtualMachine != null) {
-                        long bytes = mConfig.getMemoryBytes();
-                        mVirtualMachine.setMemoryBalloon(bytes * percent / 100);
-                    }
-                } catch (Exception e) {
-                    /* Caller doesn't want our exceptions. Log them instead. */
-                    Log.w(TAG, "TrimMemory failed: ", e);
-                }
-            }
+            setMemoryBalloonByPercent(percent);
         }
     }
 
@@ -1392,6 +1382,24 @@
         }
     }
 
+    /** @hide */
+    public void setMemoryBalloonByPercent(int percent) {
+        if (percent < 0 || percent > 100) {
+            Log.e(TAG, String.format("Invalid percent value: %d", percent));
+            return;
+        }
+        synchronized (mLock) {
+            try {
+                if (mVirtualMachine != null && mVirtualMachine.isMemoryBalloonEnabled()) {
+                    long bytes = mConfig.getMemoryBytes();
+                    mVirtualMachine.setMemoryBalloon(bytes * percent / 100);
+                }
+            } catch (RemoteException | ServiceSpecificException e) {
+                Log.w(TAG, "Cannot setMemoryBalloon", e);
+            }
+        }
+    }
+
     private boolean writeEventsToSock(ParcelFileDescriptor sock, List<InputEvent> evtList) {
         ByteBuffer byteBuffer =
                 ByteBuffer.allocate(8 /* (type: u16 + code: u16 + value: i32) */ * evtList.size());