Merge "virtmgr: check SELinux label of non-partition disk images" into main
diff --git a/android/TerminalApp/assets/js/disable_ctrl_key.js b/android/TerminalApp/assets/js/disable_ctrl_key.js
new file mode 100644
index 0000000..df261f0
--- /dev/null
+++ b/android/TerminalApp/assets/js/disable_ctrl_key.js
@@ -0,0 +1,19 @@
+/*
+ * Copyright (C) 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.
+ */
+
+(function() {
+window.ctrl = false;
+})();
\ No newline at end of file
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt
index 70bc5e4..229cdbb 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/BaseActivity.kt
@@ -30,10 +30,6 @@
                     TerminalExceptionHandler(applicationContext)
             }
         }
-    }
-
-    public override fun onResume() {
-        super.onResume()
 
         if (
             applicationContext.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) !=
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
index 0f18261..d35b106 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
@@ -21,11 +21,9 @@
 import android.content.Intent
 import android.content.pm.ActivityInfo
 import android.content.res.Configuration
-import android.graphics.Bitmap
 import android.graphics.drawable.Icon
 import android.graphics.fonts.FontStyle
 import android.net.Uri
-import android.net.http.SslError
 import android.net.nsd.NsdManager
 import android.net.nsd.NsdServiceInfo
 import android.os.Build
@@ -38,41 +36,32 @@
 import android.util.DisplayMetrics
 import android.util.Log
 import android.view.KeyEvent
-import android.view.Menu
-import android.view.MenuItem
 import android.view.View
 import android.view.ViewGroup
 import android.view.WindowManager
 import android.view.accessibility.AccessibilityManager
-import android.webkit.ClientCertRequest
-import android.webkit.SslErrorHandler
-import android.webkit.WebChromeClient
-import android.webkit.WebResourceError
-import android.webkit.WebResourceRequest
-import android.webkit.WebSettings
-import android.webkit.WebView
-import android.webkit.WebViewClient
+import android.widget.Button
+import android.widget.HorizontalScrollView
+import android.widget.RelativeLayout
 import androidx.activity.result.ActivityResult
 import androidx.activity.result.ActivityResultCallback
 import androidx.activity.result.ActivityResultLauncher
 import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
+import androidx.lifecycle.ViewModelProvider
+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.virtualization.terminal.CertificateUtils.createOrGetKey
-import com.android.virtualization.terminal.CertificateUtils.writeCertificateToFile
 import com.android.virtualization.terminal.ErrorActivity.Companion.start
 import com.android.virtualization.terminal.InstalledImage.Companion.getDefault
 import com.android.virtualization.terminal.VmLauncherService.Companion.run
 import com.android.virtualization.terminal.VmLauncherService.Companion.stop
 import com.android.virtualization.terminal.VmLauncherService.VmLauncherServiceCallback
-import com.google.android.material.appbar.MaterialToolbar
+import com.google.android.material.tabs.TabLayout
+import com.google.android.material.tabs.TabLayoutMediator
 import java.io.IOException
-import java.lang.Exception
 import java.net.MalformedURLException
 import java.net.URL
-import java.security.PrivateKey
-import java.security.cert.X509Certificate
 import java.util.concurrent.ExecutorService
 import java.util.concurrent.Executors
 
@@ -80,17 +69,21 @@
     BaseActivity(),
     VmLauncherServiceCallback,
     AccessibilityManager.AccessibilityStateChangeListener {
+    var displayMenu: Button? = null
+    var tabAddButton: Button? = null
+    val bootCompleted = ConditionVariable()
+    lateinit var modifierKeysController: ModifierKeysController
+    private lateinit var tabScrollView: HorizontalScrollView
     private lateinit var executorService: ExecutorService
     private lateinit var image: InstalledImage
-    private var certificates: Array<X509Certificate>? = null
-    private var privateKey: PrivateKey? = null
-    private lateinit var terminalContainer: ViewGroup
-    private lateinit var terminalView: TerminalView
     private lateinit var accessibilityManager: AccessibilityManager
-    private val bootCompleted = ConditionVariable()
     private lateinit var manageExternalStorageActivityResultLauncher: ActivityResultLauncher<Intent>
-    private lateinit var modifierKeysController: ModifierKeysController
-    private var displayMenu: MenuItem? = null
+    private var ipAddress: String? = null
+    private var port: Int? = null
+    private lateinit var terminalViewModel: TerminalViewModel
+    private lateinit var viewPager: ViewPager2
+    private lateinit var tabLayout: TabLayout
+    private lateinit var terminalTabAdapter: TerminalTabAdapter
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -100,26 +93,12 @@
 
         val launchInstaller = installIfNecessary()
 
-        setContentView(R.layout.activity_headless)
-
-        val toolbar = findViewById<MaterialToolbar>(R.id.toolbar)
-        setSupportActionBar(toolbar)
-        terminalView = findViewById<TerminalView>(R.id.webview)
-        terminalView.getSettings().setDomStorageEnabled(true)
-        terminalView.getSettings().setJavaScriptEnabled(true)
-        terminalView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE)
-        terminalView.setWebChromeClient(WebChromeClient())
-
-        terminalContainer = terminalView.parent as ViewGroup
-
-        modifierKeysController = ModifierKeysController(this, terminalView, terminalContainer)
+        initializeUi()
 
         accessibilityManager =
             getSystemService<AccessibilityManager>(AccessibilityManager::class.java)
         accessibilityManager.addAccessibilityStateChangeListener(this)
 
-        readClientCertificate()
-
         manageExternalStorageActivityResultLauncher =
             registerForActivityResult<Intent, ActivityResult>(
                 StartActivityForResult(),
@@ -138,6 +117,69 @@
         }
     }
 
+    private fun initializeUi() {
+        terminalViewModel = ViewModelProvider(this)[TerminalViewModel::class.java]
+        setContentView(R.layout.activity_headless)
+        tabLayout = findViewById<TabLayout>(R.id.tab_layout)
+        displayMenu = findViewById<Button>(R.id.display_button)
+        tabAddButton = findViewById<Button>(R.id.tab_add_button)
+        tabScrollView = findViewById<HorizontalScrollView>(R.id.tab_scrollview)
+        val modifierKeysContainerView =
+            findViewById<RelativeLayout>(R.id.modifier_keys_container) as ViewGroup
+
+        findViewById<Button>(R.id.settings_button).setOnClickListener {
+            val intent = Intent(this, SettingsActivity::class.java)
+            this.startActivity(intent)
+        }
+        if (terminalGuiSupport()) {
+            displayMenu?.visibility = View.VISIBLE
+            displayMenu?.setEnabled(false)
+
+            displayMenu!!.setOnClickListener {
+                val intent = Intent(this, DisplayActivity::class.java)
+                intent.flags =
+                    intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
+                this.startActivity(intent)
+            }
+        }
+
+        modifierKeysController = ModifierKeysController(this, modifierKeysContainerView)
+
+        terminalTabAdapter = TerminalTabAdapter(this)
+        viewPager = findViewById(R.id.pager)
+        viewPager.adapter = terminalTabAdapter
+        viewPager.isUserInputEnabled = false
+        viewPager.offscreenPageLimit = 2
+
+        TabLayoutMediator(tabLayout, viewPager, false, false) { _: TabLayout.Tab?, _: Int -> }
+            .attach()
+
+        addTerminalTab()
+
+        tabAddButton?.setOnClickListener { addTerminalTab() }
+    }
+
+    private fun addTerminalTab() {
+        val tab = tabLayout.newTab()
+        tab.setCustomView(R.layout.tabitem_terminal)
+        viewPager.offscreenPageLimit += 1
+        terminalTabAdapter.addTab()
+        tab.customView!!
+            .findViewById<Button>(R.id.tab_close_button)
+            .setOnClickListener(
+                View.OnClickListener { _: View? ->
+                    if (terminalTabAdapter.tabs.size == 1) {
+                        finishAndRemoveTask()
+                    }
+                    viewPager.offscreenPageLimit -= 1
+                    terminalTabAdapter.deleteTab(tab.position)
+                    tabLayout.removeTab(tab)
+                }
+            )
+        // Add and select the tab
+        tabLayout.addTab(tab, true)
+    }
+
     private fun lockOrientationIfNecessary() {
         val hasHwQwertyKeyboard = resources.configuration.keyboard == Configuration.KEYBOARD_QWERTY
         if (hasHwQwertyKeyboard) {
@@ -175,7 +217,7 @@
 
     private fun getTerminalServiceUrl(ipAddress: String?, port: Int): URL? {
         val config = resources.configuration
-
+        // TODO: Always enable screenReaderMode (b/395845063)
         val query =
             ("?fontSize=" +
                 (config.fontScale * FONT_SIZE_DEFAULT).toInt() +
@@ -196,105 +238,12 @@
         }
     }
 
-    private fun readClientCertificate() {
-        val pke = createOrGetKey()
-        writeCertificateToFile(this, pke.certificate)
-        privateKey = pke.privateKey
-        certificates = arrayOf<X509Certificate>(pke.certificate as X509Certificate)
-    }
-
-    private fun connectToTerminalService() {
-        terminalView.setWebViewClient(
-            object : WebViewClient() {
-                private var loadFailed = false
-                private var requestId: Long = 0
-
-                override fun shouldOverrideUrlLoading(
-                    view: WebView?,
-                    request: WebResourceRequest?,
-                ): Boolean {
-                    val intent = Intent(Intent.ACTION_VIEW, request?.url)
-                    intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
-                    startActivity(intent)
-                    return true
-                }
-
-                override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
-                    loadFailed = false
-                }
-
-                override fun onReceivedError(
-                    view: WebView,
-                    request: WebResourceRequest,
-                    error: WebResourceError,
-                ) {
-                    loadFailed = true
-                    when (error.getErrorCode()) {
-                        ERROR_CONNECT,
-                        ERROR_HOST_LOOKUP,
-                        ERROR_FAILED_SSL_HANDSHAKE,
-                        ERROR_TIMEOUT -> {
-                            view.reload()
-                            return
-                        }
-
-                        else -> {
-                            val url: String? = request.getUrl().toString()
-                            val msg = error.getDescription()
-                            Log.e(TAG, "Failed to load $url: $msg")
-                        }
-                    }
-                }
-
-                override fun onPageFinished(view: WebView, url: String?) {
-                    if (loadFailed) {
-                        return
-                    }
-
-                    requestId++
-                    view.postVisualStateCallback(
-                        requestId,
-                        object : WebView.VisualStateCallback() {
-                            override fun onComplete(completedRequestId: Long) {
-                                if (completedRequestId == requestId) {
-                                    Trace.endAsyncSection("executeTerminal", 0)
-                                    findViewById<View?>(R.id.boot_progress).visibility = View.GONE
-                                    terminalContainer.visibility = View.VISIBLE
-                                    if (terminalGuiSupport()) {
-                                        displayMenu?.setVisible(true)
-                                        displayMenu?.setEnabled(true)
-                                    }
-                                    bootCompleted.open()
-                                    modifierKeysController.update()
-                                    terminalView.mapTouchToMouseEvent()
-                                }
-                            }
-                        },
-                    )
-                }
-
-                override fun onReceivedClientCertRequest(
-                    view: WebView?,
-                    request: ClientCertRequest,
-                ) {
-                    if (privateKey != null && certificates != null) {
-                        request.proceed(privateKey, certificates)
-                        return
-                    }
-                    super.onReceivedClientCertRequest(view, request)
-                }
-
-                override fun onReceivedSslError(
-                    view: WebView?,
-                    handler: SslErrorHandler,
-                    error: SslError?,
-                ) {
-                    // ttyd uses self-signed certificate
-                    handler.proceed()
-                }
-            }
-        )
-
+    fun connectToTerminalService(terminalView: TerminalView) {
+        if (ipAddress != null && port != null) {
+            val url = getTerminalServiceUrl(ipAddress, port!!)
+            terminalView.loadUrl(url.toString())
+            return
+        }
         // TODO: refactor this block as a method
         val nsdManager = getSystemService<NsdManager>(NsdManager::class.java)
         val info = NsdServiceInfo()
@@ -314,10 +263,10 @@
 
                 override fun onServiceUpdated(info: NsdServiceInfo) {
                     Log.i(TAG, "Service found: $info")
-                    val ipAddress = info.hostAddresses[0].hostAddress
-                    val port = info.port
-                    val url = getTerminalServiceUrl(ipAddress, port)
                     if (!loaded) {
+                        ipAddress = info.hostAddresses[0].hostAddress
+                        port = info.port
+                        val url = getTerminalServiceUrl(ipAddress, port!!)
                         loaded = true
                         nsdManager.unregisterServiceInfoCallback(this)
                         runOnUiThread(Runnable { terminalView.loadUrl(url.toString()) })
@@ -350,34 +299,10 @@
         start(this, Exception("onVmError"))
     }
 
-    override fun onCreateOptionsMenu(menu: Menu?): Boolean {
-        menuInflater.inflate(R.menu.main_menu, menu)
-        displayMenu =
-            menu?.findItem(R.id.menu_item_display).also {
-                it?.setVisible(terminalGuiSupport())
-                it?.setEnabled(false)
-            }
-        return true
-    }
-
-    override fun onOptionsItemSelected(item: MenuItem): Boolean {
-        val id = item.getItemId()
-        if (id == R.id.menu_item_settings) {
-            val intent = Intent(this, SettingsActivity::class.java)
-            this.startActivity(intent)
-            return true
-        } else if (id == R.id.menu_item_display) {
-            val intent = Intent(this, DisplayActivity::class.java)
-            intent.flags =
-                intent.flags or Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
-            this.startActivity(intent)
-            return true
-        }
-        return super.onOptionsItemSelected(item)
-    }
-
     override fun onAccessibilityStateChanged(enabled: Boolean) {
-        connectToTerminalService()
+        terminalViewModel.terminalViews.forEach { terminalView ->
+            connectToTerminalService(terminalView)
+        }
     }
 
     private val installerLauncher =
@@ -462,7 +387,6 @@
 
         Trace.beginAsyncSection("executeTerminal", 0)
         run(this, this, notification, getDisplayInfo())
-        connectToTerminalService()
     }
 
     @VisibleForTesting
@@ -499,21 +423,6 @@
                     20000 // 20 sec
                 }
         }
-
-        private val BTN_KEY_CODE_MAP =
-            mapOf(
-                R.id.btn_tab to KeyEvent.KEYCODE_TAB, // Alt key sends ESC keycode
-                R.id.btn_alt to KeyEvent.KEYCODE_ESCAPE,
-                R.id.btn_esc to KeyEvent.KEYCODE_ESCAPE,
-                R.id.btn_left to KeyEvent.KEYCODE_DPAD_LEFT,
-                R.id.btn_right to KeyEvent.KEYCODE_DPAD_RIGHT,
-                R.id.btn_up to KeyEvent.KEYCODE_DPAD_UP,
-                R.id.btn_down to KeyEvent.KEYCODE_DPAD_DOWN,
-                R.id.btn_home to KeyEvent.KEYCODE_MOVE_HOME,
-                R.id.btn_end to KeyEvent.KEYCODE_MOVE_END,
-                R.id.btn_pgup to KeyEvent.KEYCODE_PAGE_UP,
-                R.id.btn_pgdn to KeyEvent.KEYCODE_PAGE_DOWN,
-            )
     }
 
     fun getDisplayInfo(): DisplayInfo {
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ModifierKeysController.kt b/android/TerminalApp/java/com/android/virtualization/terminal/ModifierKeysController.kt
index f8f30f9..ed340d2 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/ModifierKeysController.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ModifierKeysController.kt
@@ -15,7 +15,6 @@
  */
 package com.android.virtualization.terminal
 
-import android.app.Activity
 import android.content.res.Configuration
 import android.view.KeyEvent
 import android.view.LayoutInflater
@@ -23,20 +22,16 @@
 import android.view.ViewGroup
 import android.view.WindowInsets
 
-class ModifierKeysController(
-    val activity: Activity,
-    val terminalView: TerminalView,
-    val parent: ViewGroup,
-) {
+class ModifierKeysController(val activity: MainActivity, val parent: ViewGroup) {
     private val window = activity.window
     private val keysSingleLine: View
     private val keysDoubleLine: View
-
+    private var activeTerminalView: TerminalView? = null
     private var keysInSingleLine: Boolean = false
 
     init {
-        // Prepare the two modifier keys layout, but don't add them yet because we don't know which
-        // layout will be needed.
+        // Prepare the two modifier keys layout, but only attach the double line one since the
+        // keysInSingleLine is set to true by default
         val layout = LayoutInflater.from(activity)
         keysSingleLine = layout.inflate(R.layout.modifier_keys_singleline, parent, false)
         keysDoubleLine = layout.inflate(R.layout.modifier_keys_doubleline, parent, false)
@@ -46,14 +41,25 @@
 
         keysSingleLine.visibility = View.GONE
         keysDoubleLine.visibility = View.GONE
+        parent.addView(keysDoubleLine)
 
         // Setup for the update to be called when needed
         window.decorView.rootView.setOnApplyWindowInsetsListener { _: View?, insets: WindowInsets ->
             update()
             insets
         }
+    }
 
-        terminalView.setOnFocusChangeListener { _: View, _: Boolean -> update() }
+    fun addTerminalView(terminalView: TerminalView) {
+        terminalView.setOnFocusChangeListener { _: View, onFocus: Boolean ->
+            if (onFocus) {
+                activeTerminalView = terminalView
+            } else {
+                activeTerminalView = null
+                terminalView.disableCtrlKey()
+            }
+            update()
+        }
     }
 
     private fun addClickListeners(keys: View) {
@@ -61,15 +67,15 @@
         keys
             .findViewById<View>(R.id.btn_ctrl)
             .setOnClickListener({
-                terminalView.mapCtrlKey()
-                terminalView.enableCtrlKey()
+                activeTerminalView!!.mapCtrlKey()
+                activeTerminalView!!.enableCtrlKey()
             })
 
         val listener =
             View.OnClickListener { v: View ->
                 BTN_KEY_CODE_MAP[v.id]?.also { keyCode ->
-                    terminalView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
-                    terminalView.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))
+                    activeTerminalView!!.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, keyCode))
+                    activeTerminalView!!.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, keyCode))
                 }
             }
 
@@ -79,38 +85,40 @@
     }
 
     fun update() {
-        // select single line or double line
-        val needSingleLine = needsKeysInSingleLine()
-        if (keysInSingleLine != needSingleLine) {
-            if (needSingleLine) {
-                parent.removeView(keysDoubleLine)
-                parent.addView(keysSingleLine)
-            } else {
-                parent.removeView(keysSingleLine)
-                parent.addView(keysDoubleLine)
+        // Pass if no TerminalView focused.
+        if (activeTerminalView == null) {
+            val keys = if (keysInSingleLine) keysSingleLine else keysDoubleLine
+            keys.visibility = View.GONE
+        } else {
+            // select single line or double line
+            val needSingleLine = needsKeysInSingleLine()
+            if (keysInSingleLine != needSingleLine) {
+                if (needSingleLine) {
+                    parent.removeView(keysDoubleLine)
+                    parent.addView(keysSingleLine)
+                } else {
+                    parent.removeView(keysSingleLine)
+                    parent.addView(keysDoubleLine)
+                }
+                keysInSingleLine = needSingleLine
             }
-            keysInSingleLine = needSingleLine
+            // set visibility
+            val needShow = needToShowKeys()
+            val keys = if (keysInSingleLine) keysSingleLine else keysDoubleLine
+            keys.visibility = if (needShow) View.VISIBLE else View.GONE
         }
-
-        // set visibility
-        val needShow = needToShowKeys()
-        val keys = if (keysInSingleLine) keysSingleLine else keysDoubleLine
-        keys.visibility = if (needShow) View.VISIBLE else View.GONE
     }
 
     // Modifier keys are required only when IME is shown and the HW qwerty keyboard is not present
-    private fun needToShowKeys(): Boolean {
-        val imeShown = activity.window.decorView.rootWindowInsets.isVisible(WindowInsets.Type.ime())
-        val hasFocus = terminalView.hasFocus()
-        val hasHwQwertyKeyboard =
-            activity.resources.configuration.keyboard == Configuration.KEYBOARD_QWERTY
-        return imeShown && hasFocus && !hasHwQwertyKeyboard
-    }
+    private fun needToShowKeys(): Boolean =
+        activity.window.decorView.rootWindowInsets.isVisible(WindowInsets.Type.ime()) &&
+            activeTerminalView!!.hasFocus() &&
+            !(activity.resources.configuration.keyboard == Configuration.KEYBOARD_QWERTY)
 
     // If terminal's height is less than 30% of the screen height, we need to show modifier keys in
     // a single line to save the vertical space
     private fun needsKeysInSingleLine(): Boolean =
-        (terminalView.height / activity.window.decorView.height.toFloat()) < 0.3f
+        activeTerminalView!!.height.div(activity.window.decorView.height.toFloat()) < 0.3f
 
     companion object {
         private val BTN_KEY_CODE_MAP =
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalTabAdapter.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalTabAdapter.kt
new file mode 100644
index 0000000..9c3cd12
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalTabAdapter.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (C) 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
+
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.FragmentActivity
+import androidx.viewpager2.adapter.FragmentStateAdapter
+import java.util.UUID
+
+class TabMetadata(val id: String)
+
+class TerminalTabAdapter(fragmentActivity: FragmentActivity) :
+    FragmentStateAdapter(fragmentActivity) {
+    val tabs = ArrayList<TabMetadata>()
+
+    override fun createFragment(position: Int): Fragment {
+        val terminalTabFragment = TerminalTabFragment()
+
+        terminalTabFragment.arguments = bundleOf("id" to tabs[position].id)
+        return terminalTabFragment
+    }
+
+    override fun getItemCount(): Int {
+        return tabs.size
+    }
+
+    override fun getItemId(position: Int): Long {
+        return tabs[position].id.hashCode().toLong()
+    }
+
+    override fun containsItem(itemId: Long): Boolean {
+        return tabs.any { it.id.hashCode().toLong() == itemId }
+    }
+
+    fun addTab(): String {
+        val id = UUID.randomUUID().toString()
+        tabs.add(TabMetadata(id))
+        return id
+    }
+
+    fun deleteTab(position: Int) {
+        if (position in 0 until tabs.size) {
+            tabs.removeAt(position)
+        }
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalTabFragment.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalTabFragment.kt
new file mode 100644
index 0000000..5c01ead
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalTabFragment.kt
@@ -0,0 +1,202 @@
+/*
+ * Copyright (C) 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
+
+import android.content.Intent
+import android.graphics.Bitmap
+import android.net.http.SslError
+import android.os.Bundle
+import android.os.Trace
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.ClientCertRequest
+import android.webkit.SslErrorHandler
+import android.webkit.WebChromeClient
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebSettings
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.fragment.app.Fragment
+import androidx.lifecycle.ViewModelProvider
+import com.android.system.virtualmachine.flags.Flags.terminalGuiSupport
+import com.android.virtualization.terminal.CertificateUtils.createOrGetKey
+import com.android.virtualization.terminal.CertificateUtils.writeCertificateToFile
+import java.security.PrivateKey
+import java.security.cert.X509Certificate
+
+class TerminalTabFragment() : Fragment() {
+    private lateinit var terminalView: TerminalView
+    private lateinit var bootProgressView: View
+    private lateinit var id: String
+    private var certificates: Array<X509Certificate>? = null
+    private var privateKey: PrivateKey? = null
+    private lateinit var terminalViewModel: TerminalViewModel
+
+    override fun onCreateView(
+        inflater: LayoutInflater,
+        container: ViewGroup?,
+        savedInstanceState: Bundle?,
+    ): View {
+        val view = inflater.inflate(R.layout.fragment_terminal_tab, container, false)
+        arguments?.let { id = it.getString("id")!! }
+        return view
+    }
+
+    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+        super.onViewCreated(view, savedInstanceState)
+        terminalViewModel = ViewModelProvider(this)[TerminalViewModel::class.java]
+        terminalView = view.findViewById(R.id.webview)
+        bootProgressView = view.findViewById(R.id.boot_progress)
+        initializeWebView()
+        readClientCertificate()
+
+        terminalView.webViewClient = TerminalWebViewClient()
+
+        if (savedInstanceState != null) {
+            terminalView.restoreState(savedInstanceState)
+        } else {
+            (activity as MainActivity).connectToTerminalService(terminalView)
+        }
+    }
+
+    override fun onSaveInstanceState(outState: Bundle) {
+        super.onSaveInstanceState(outState)
+        terminalView.saveState(outState)
+    }
+
+    private fun initializeWebView() {
+        terminalView.settings.databaseEnabled = true
+        terminalView.settings.domStorageEnabled = true
+        terminalView.settings.javaScriptEnabled = true
+        terminalView.settings.cacheMode = WebSettings.LOAD_DEFAULT
+
+        terminalView.webChromeClient = WebChromeClient()
+        terminalView.webViewClient = TerminalWebViewClient()
+
+        (activity as MainActivity).modifierKeysController.addTerminalView(terminalView)
+        terminalViewModel.terminalViews.add(terminalView)
+    }
+
+    private inner class TerminalWebViewClient : WebViewClient() {
+        private var loadFailed = false
+        private var requestId: Long = 0
+
+        override fun shouldOverrideUrlLoading(
+            view: WebView?,
+            request: WebResourceRequest?,
+        ): Boolean {
+            val intent = Intent(Intent.ACTION_VIEW, request?.url)
+            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+            startActivity(intent)
+            return true
+        }
+
+        override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+            loadFailed = false
+        }
+
+        override fun onReceivedError(
+            view: WebView,
+            request: WebResourceRequest,
+            error: WebResourceError,
+        ) {
+            loadFailed = true
+            when (error.getErrorCode()) {
+                ERROR_CONNECT,
+                ERROR_HOST_LOOKUP,
+                ERROR_FAILED_SSL_HANDSHAKE,
+                ERROR_TIMEOUT -> {
+                    view.reload()
+                    return
+                }
+
+                else -> {
+                    val url: String? = request.getUrl().toString()
+                    val msg = error.getDescription()
+                    Log.e(MainActivity.TAG, "Failed to load $url: $msg")
+                }
+            }
+        }
+
+        override fun onPageFinished(view: WebView, url: String?) {
+            if (loadFailed) {
+                return
+            }
+
+            requestId++
+            view.postVisualStateCallback(
+                requestId,
+                object : WebView.VisualStateCallback() {
+                    override fun onComplete(completedRequestId: Long) {
+                        if (completedRequestId == requestId) {
+                            Trace.endAsyncSection("executeTerminal", 0)
+                            bootProgressView.visibility = View.GONE
+                            terminalView.visibility = View.VISIBLE
+                            terminalView.mapTouchToMouseEvent()
+                            updateMainActivity()
+                        }
+                    }
+                },
+            )
+        }
+
+        override fun onReceivedClientCertRequest(view: WebView?, request: ClientCertRequest) {
+            if (privateKey != null && certificates != null) {
+                request.proceed(privateKey, certificates)
+                return
+            }
+            super.onReceivedClientCertRequest(view, request)
+        }
+
+        override fun onReceivedSslError(
+            view: WebView?,
+            handler: SslErrorHandler,
+            error: SslError?,
+        ) {
+            // ttyd uses self-signed certificate
+            handler.proceed()
+        }
+    }
+
+    private fun updateMainActivity() {
+        val mainActivity = (activity as MainActivity)
+        if (terminalGuiSupport()) {
+            mainActivity.displayMenu!!.visibility = View.VISIBLE
+            mainActivity.displayMenu!!.isEnabled = true
+        }
+        mainActivity.tabAddButton!!.isEnabled = true
+        mainActivity.bootCompleted.open()
+    }
+
+    private fun readClientCertificate() {
+        val pke = createOrGetKey()
+        writeCertificateToFile(activity!!, pke.certificate)
+        privateKey = pke.privateKey
+        certificates = arrayOf<X509Certificate>(pke.certificate as X509Certificate)
+    }
+
+    companion object {
+        const val TAG: String = "VmTerminalApp"
+    }
+
+    override fun onDestroy() {
+        terminalViewModel.terminalViews.remove(terminalView)
+        super.onDestroy()
+    }
+}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
index 4d9a89d..4b11c1d 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
@@ -40,6 +40,7 @@
     AccessibilityManager.TouchExplorationStateChangeListener {
     private val ctrlKeyHandler: String = readAssetAsString(context, "js/ctrl_key_handler.js")
     private val enableCtrlKey: String = readAssetAsString(context, "js/enable_ctrl_key.js")
+    private val disableCtrlKey: String = readAssetAsString(context, "js/disable_ctrl_key.js")
     private val touchToMouseHandler: String =
         readAssetAsString(context, "js/touch_to_mouse_handler.js")
     private val a11yManager =
@@ -65,6 +66,10 @@
         this.evaluateJavascript(enableCtrlKey, null)
     }
 
+    fun disableCtrlKey() {
+        this.evaluateJavascript(disableCtrlKey, null)
+    }
+
     override fun onAccessibilityStateChanged(enabled: Boolean) {
         Log.d(TAG, "accessibility $enabled")
         adjustToA11yStateChange()
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalViewModel.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalViewModel.kt
new file mode 100644
index 0000000..4a69f75
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalViewModel.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (C) 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
+
+import androidx.lifecycle.ViewModel
+
+class TerminalViewModel : ViewModel() {
+    val terminalViews: MutableSet<TerminalView> = mutableSetOf()
+}
diff --git a/android/TerminalApp/res/drawable/background_tabitem_selected.xml b/android/TerminalApp/res/drawable/background_tabitem_selected.xml
new file mode 100644
index 0000000..8784304
--- /dev/null
+++ b/android/TerminalApp/res/drawable/background_tabitem_selected.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--  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.
+ -->
+
+<shape xmlns:android="http://schemas.android.com/apk/res/android"
+    android:shape="rectangle">
+    <corners
+        android:topLeftRadius="10dp"
+        android:topRightRadius="10dp" />
+</shape>
diff --git a/android/TerminalApp/res/layout/activity_headless.xml b/android/TerminalApp/res/layout/activity_headless.xml
index e18aa5c..bf84833 100644
--- a/android/TerminalApp/res/layout/activity_headless.xml
+++ b/android/TerminalApp/res/layout/activity_headless.xml
@@ -14,51 +14,83 @@
      limitations under the License.
  -->
 
-<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
     xmlns:app="http://schemas.android.com/apk/res-auto"
     xmlns:tools="http://schemas.android.com/tools"
+    android:id="@+id/terminal_container"
     android:layout_width="match_parent"
     android:layout_height="match_parent"
-    android:orientation="vertical"
     android:fitsSystemWindows="true"
     tools:context=".MainActivity">
-    <com.google.android.material.appbar.MaterialToolbar
-        android:id="@+id/toolbar"
+
+    <HorizontalScrollView
+        android:id="@+id/tab_scrollview"
         android:layout_width="match_parent"
-        android:layout_height="?attr/actionBarSize"
-        app:layout_constraintTop_toTopOf="parent"/>
-    <FrameLayout
-        android:layout_width="match_parent"
-        android:layout_height="match_parent">
+        android:layout_height="wrap_content"
+        android:layout_alignParentStart="true"
+        android:layout_alignParentTop="true"
+        android:layout_toStartOf="@id/settings_button"
+        android:scrollbars="none">
+
         <LinearLayout
-            android:id="@+id/boot_progress"
-            android:orientation="vertical"
-            android:gravity="center"
-            android:layout_gravity="center"
             android:layout_width="wrap_content"
-            android:layout_height="wrap_content">
-            <com.google.android.material.textview.MaterialTextView
-                android:text="@string/vm_creation_message"
+            android:layout_height="wrap_content"
+            android:orientation="horizontal">
+
+            <com.google.android.material.tabs.TabLayout
+                android:id="@+id/tab_layout"
                 android:layout_width="wrap_content"
                 android:layout_height="wrap_content"
-                android:layout_marginBottom="5dp"/>
-            <com.google.android.material.progressindicator.CircularProgressIndicator
-                android:indeterminate="true"
-                android:layout_width="wrap_content"
-                android:layout_height="wrap_content"/>
-        </LinearLayout>
-        <LinearLayout
-            android:layout_width="match_parent"
-            android:layout_height="match_parent"
-            android:layout_marginBottom="5dp"
-            android:orientation="vertical"
-            android:visibility="gone" >
-            <com.android.virtualization.terminal.TerminalView
-                android:id="@+id/webview"
-                android:layout_width="match_parent"
-                android:layout_height="0dp"
-                android:layout_weight="1" />
-        </LinearLayout>
-    </FrameLayout>
+                android:elevation="0dp"
+                app:tabIndicator="@drawable/background_tabitem_selected"
+                app:tabIndicatorColor="@color/material_on_surface_stroke"
+                app:tabRippleColor="@null"
+                app:tabIndicatorHeight="48dp"
+                app:tabPaddingStart="0dp"
+                app:tabPaddingEnd="0dp"/>
 
-</LinearLayout>
+            <Button
+                android:id="@+id/tab_add_button"
+                style="?attr/materialIconButtonStyle"
+                android:layout_width="wrap_content"
+                android:layout_height="wrap_content"
+                android:enabled="false"
+                app:icon="@drawable/ic_add" />
+        </LinearLayout>
+    </HorizontalScrollView>
+
+    <Button
+        android:id="@+id/settings_button"
+        style="?attr/materialIconButtonStyle"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentTop="true"
+        android:layout_toStartOf="@id/display_button"
+        app:icon="@drawable/ic_settings" />
+
+    <Button
+        android:id="@+id/display_button"
+        style="?attr/materialIconButtonStyle"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content"
+        android:layout_alignParentEnd="true"
+        android:layout_alignParentTop="true"
+        app:icon="@drawable/ic_display" />
+
+
+    <androidx.viewpager2.widget.ViewPager2
+        android:id="@+id/pager"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_alignParentStart="true"
+        android:layout_above="@+id/modifier_keys_container"
+        android:layout_below="@id/settings_button"/>
+
+    <LinearLayout
+        android:id="@+id/modifier_keys_container"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_alignParentBottom="true"
+        android:layout_alignParentStart="true"
+        android:orientation="vertical"/>
+</RelativeLayout>
diff --git a/android/TerminalApp/res/layout/fragment_terminal_tab.xml b/android/TerminalApp/res/layout/fragment_terminal_tab.xml
new file mode 100644
index 0000000..bdf3d83
--- /dev/null
+++ b/android/TerminalApp/res/layout/fragment_terminal_tab.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--  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.
+ -->
+
+<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+    <LinearLayout
+        android:id="@+id/boot_progress"
+        android:orientation="vertical"
+        android:gravity="center"
+        android:layout_gravity="center"
+        android:layout_width="wrap_content"
+        android:layout_height="wrap_content">
+        <com.google.android.material.textview.MaterialTextView
+            android:text="@string/vm_creation_message"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginBottom="5dp"/>
+        <com.google.android.material.progressindicator.CircularProgressIndicator
+            android:indeterminate="true"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"/>
+    </LinearLayout>
+    <com.android.virtualization.terminal.TerminalView
+        android:id="@+id/webview"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:visibility="gone"
+        android:layout_marginBottom="5dp"/>
+</FrameLayout>
\ No newline at end of file
diff --git a/android/TerminalApp/res/layout/tabitem_terminal.xml b/android/TerminalApp/res/layout/tabitem_terminal.xml
new file mode 100644
index 0000000..92e3802
--- /dev/null
+++ b/android/TerminalApp/res/layout/tabitem_terminal.xml
@@ -0,0 +1,43 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--  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.
+ -->
+<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    android:layout_width="200dp"
+    android:layout_height="48dp">
+
+  <TextView
+      android:id="@+id/tab_title"
+      android:layout_width="match_parent"
+      android:layout_height="match_parent"
+      android:layout_alignParentStart="true"
+      android:layout_alignParentTop="true"
+      android:layout_toStartOf="@id/tab_close_button"
+      android:gravity="center"
+      android:padding="8dp"
+      android:text="@string/tab_default_title"/>
+
+  <Button
+      style="?attr/materialIconButtonStyle"
+      android:id="@+id/tab_close_button"
+      android:layout_width="wrap_content"
+      android:layout_height="wrap_content"
+      android:layout_alignParentTop="true"
+      android:layout_alignParentEnd="true"
+      app:icon="@drawable/ic_close"
+      android:clickable="true"
+      android:focusable="true"/>
+
+</RelativeLayout>
\ No newline at end of file
diff --git a/android/TerminalApp/res/values/strings.xml b/android/TerminalApp/res/values/strings.xml
index 44009c3..fc8d036 100644
--- a/android/TerminalApp/res/values/strings.xml
+++ b/android/TerminalApp/res/values/strings.xml
@@ -181,4 +181,7 @@
 
     <!-- This is the name of the notification channel for system events [CHAR LIMIT=none] -->
     <string name="notification_channel_system_events_name">System events</string>
+
+    <!-- Default title of a terminal tab [CHAR LIMIT=10] -->
+    <string name="tab_default_title">Tab</string>
 </resources>
diff --git a/android/composd/aidl/android/system/composd/IIsolatedCompilationService.aidl b/android/composd/aidl/android/system/composd/IIsolatedCompilationService.aidl
index 3748899..d7757db 100644
--- a/android/composd/aidl/android/system/composd/IIsolatedCompilationService.aidl
+++ b/android/composd/aidl/android/system/composd/IIsolatedCompilationService.aidl
@@ -35,7 +35,7 @@
      * callback, unless the returned ICompilationTask is cancelled. The caller should maintain
      * a reference to the ICompilationTask until compilation completes or is cancelled.
      */
-    ICompilationTask startStagedApexCompile(ICompilationTaskCallback callback);
+    ICompilationTask startStagedApexCompile(ICompilationTaskCallback callback, String os);
 
     /**
      * Run odrefresh in a test instance of CompOS until completed or failed.
diff --git a/android/composd/src/instance_manager.rs b/android/composd/src/instance_manager.rs
index d1b0b99..a7154ec 100644
--- a/android/composd/src/instance_manager.rs
+++ b/android/composd/src/instance_manager.rs
@@ -39,10 +39,11 @@
         Self { service, state: Default::default() }
     }
 
-    pub fn start_current_instance(&self) -> Result<CompOsInstance> {
+    pub fn start_current_instance(&self, os: &str) -> Result<CompOsInstance> {
         let mut vm_parameters = new_vm_parameters()?;
         vm_parameters.name = String::from("Composd");
         vm_parameters.prefer_staged = true;
+        vm_parameters.os = os.to_owned();
         self.start_instance(CURRENT_INSTANCE_DIR, vm_parameters)
     }
 
diff --git a/android/composd/src/service.rs b/android/composd/src/service.rs
index 3cc40af..1e38eee 100644
--- a/android/composd/src/service.rs
+++ b/android/composd/src/service.rs
@@ -51,9 +51,10 @@
     fn startStagedApexCompile(
         &self,
         callback: &Strong<dyn ICompilationTaskCallback>,
+        os: &str,
     ) -> binder::Result<Strong<dyn ICompilationTask>> {
         check_permissions()?;
-        to_binder_result(self.do_start_staged_apex_compile(callback))
+        to_binder_result(self.do_start_staged_apex_compile(callback, os))
     }
 
     fn startTestCompile(
@@ -76,8 +77,10 @@
     fn do_start_staged_apex_compile(
         &self,
         callback: &Strong<dyn ICompilationTaskCallback>,
+        os: &str,
     ) -> Result<Strong<dyn ICompilationTask>> {
-        let comp_os = self.instance_manager.start_current_instance().context("Starting CompOS")?;
+        let comp_os =
+            self.instance_manager.start_current_instance(os).context("Starting CompOS")?;
 
         let target_dir_name = PENDING_ARTIFACTS_SUBDIR.to_owned();
         let task = OdrefreshTask::start(
diff --git a/android/composd_cmd/composd_cmd.rs b/android/composd_cmd/composd_cmd.rs
index 6281bd0..c944c17 100644
--- a/android/composd_cmd/composd_cmd.rs
+++ b/android/composd_cmd/composd_cmd.rs
@@ -39,7 +39,11 @@
 #[derive(Parser)]
 enum Actions {
     /// Compile classpath for real. Output can be used after a reboot.
-    StagedApexCompile {},
+    StagedApexCompile {
+        /// OS for the VM.
+        #[clap(long, default_value = "microdroid")]
+        os: String,
+    },
 
     /// Compile classpath in a debugging VM. Output is ignored.
     TestCompile {
@@ -59,7 +63,7 @@
     ProcessState::start_thread_pool();
 
     match action {
-        Actions::StagedApexCompile {} => run_staged_apex_compile()?,
+        Actions::StagedApexCompile { os } => run_staged_apex_compile(&os)?,
         Actions::TestCompile { prefer_staged, os } => run_test_compile(prefer_staged, &os)?,
     }
 
@@ -116,8 +120,8 @@
     }
 }
 
-fn run_staged_apex_compile() -> Result<()> {
-    run_async_compilation(|service, callback| service.startStagedApexCompile(callback))
+fn run_staged_apex_compile(os: &str) -> Result<()> {
+    run_async_compilation(|service, callback| service.startStagedApexCompile(callback, os))
 }
 
 fn run_test_compile(prefer_staged: bool, os: &str) -> Result<()> {
diff --git a/android/virtmgr/src/aidl.rs b/android/virtmgr/src/aidl.rs
index cc6141e..1a263bd 100644
--- a/android/virtmgr/src/aidl.rs
+++ b/android/virtmgr/src/aidl.rs
@@ -523,17 +523,7 @@
                     .or_service_specific_exception(-1)
             }
         };
-        let expected_exe_path = Path::new(&early_vm.path);
-        if expected_exe_path != calling_exe_path
-            && Path::new("/system").join(expected_exe_path) != calling_exe_path
-        {
-            return Err(anyhow!(
-                "VM '{name}' in partition '{calling_partition}' must be created with '{}', not '{}'",
-                &early_vm.path,
-                calling_exe_path.display()
-            ))
-            .or_service_specific_exception(-1);
-        }
+        early_vm.check_exe_paths_match(calling_exe_path)?;
 
         let cid = early_vm.cid as Cid;
         let temp_dir = PathBuf::from(format!("/mnt/vm/early/{cid}"));
@@ -2321,6 +2311,29 @@
     early_vm: Vec<EarlyVm>,
 }
 
+impl EarlyVm {
+    /// Verifies that the provided executable path matches the expected path stored in the XML
+    /// configuration.
+    /// If the provided path starts with `/system`, it will be stripped before comparison.
+    fn check_exe_paths_match<P: AsRef<Path>>(&self, calling_exe_path: P) -> binder::Result<()> {
+        let actual_path = calling_exe_path.as_ref();
+        if Path::new(&self.path)
+            == Path::new("/").join(actual_path.strip_prefix("/system").unwrap_or(actual_path))
+        {
+            return Ok(());
+        }
+        Err(Status::new_service_specific_error_str(
+            -1,
+            Some(format!(
+                "Early VM '{}' executable paths do not match. Expected: {}. Found: {:?}.",
+                self.name,
+                self.path,
+                actual_path.display()
+            )),
+        ))
+    }
+}
+
 static EARLY_VMS_CACHE: LazyLock<Mutex<HashMap<CallingPartition, Vec<EarlyVm>>>> =
     LazyLock::new(|| Mutex::new(HashMap::new()));
 
@@ -2749,6 +2762,39 @@
     }
 
     #[test]
+    fn early_vm_exe_paths_match_succeeds_with_same_paths() {
+        let early_vm = EarlyVm {
+            name: "vm_demo_native_early".to_owned(),
+            cid: 123,
+            path: "/system_ext/bin/vm_demo_native_early".to_owned(),
+        };
+        let calling_exe_path = "/system_ext/bin/vm_demo_native_early";
+        assert!(early_vm.check_exe_paths_match(calling_exe_path).is_ok())
+    }
+
+    #[test]
+    fn early_vm_exe_paths_match_succeeds_with_calling_exe_path_from_system() {
+        let early_vm = EarlyVm {
+            name: "vm_demo_native_early".to_owned(),
+            cid: 123,
+            path: "/system_ext/bin/vm_demo_native_early".to_owned(),
+        };
+        let calling_exe_path = "/system/system_ext/bin/vm_demo_native_early";
+        assert!(early_vm.check_exe_paths_match(calling_exe_path).is_ok())
+    }
+
+    #[test]
+    fn early_vm_exe_paths_match_fails_with_unmatched_paths() {
+        let early_vm = EarlyVm {
+            name: "vm_demo_native_early".to_owned(),
+            cid: 123,
+            path: "/system_ext/bin/vm_demo_native_early".to_owned(),
+        };
+        let calling_exe_path = "/system/etc/system_ext/bin/vm_demo_native_early";
+        assert!(early_vm.check_exe_paths_match(calling_exe_path).is_err())
+    }
+
+    #[test]
     fn test_duplicated_early_vms() -> Result<()> {
         let tmp_dir = tempfile::TempDir::new()?;
         let tmp_dir_path = tmp_dir.path().to_owned();
diff --git a/android/virtmgr/src/crosvm.rs b/android/virtmgr/src/crosvm.rs
index 8500421..77710c3 100644
--- a/android/virtmgr/src/crosvm.rs
+++ b/android/virtmgr/src/crosvm.rs
@@ -21,7 +21,7 @@
 use binder::ParcelFileDescriptor;
 use command_fds::CommandFdExt;
 use libc::{sysconf, _SC_CLK_TCK};
-use log::{debug, error, info};
+use log::{debug, error, info, warn};
 use semver::{Version, VersionReq};
 use nix::{fcntl::OFlag, unistd::pipe2, unistd::Uid, unistd::User};
 use regex::{Captures, Regex};
@@ -628,6 +628,7 @@
 
     fn monitor_vm_status(&self, child: Arc<SharedChild>) {
         let pid = child.id();
+        let mut metric_countdown = 0;
 
         loop {
             {
@@ -637,23 +638,34 @@
                     break;
                 }
 
-                let mut vm_metric = self.vm_metric.lock().unwrap();
+                if metric_countdown > 0 {
+                    metric_countdown -= 1;
+                } else {
+                    metric_countdown = 10;
+                    let mut vm_metric = self.vm_metric.lock().unwrap();
 
-                // Get CPU Information
-                match get_guest_time(pid) {
-                    Ok(guest_time) => vm_metric.cpu_guest_time = Some(guest_time),
-                    Err(e) => error!("Failed to get guest CPU time: {e:?}"),
-                }
-
-                // Get Memory Information
-                match get_rss(pid) {
-                    Ok(rss) => {
-                        vm_metric.rss = match &vm_metric.rss {
-                            Some(x) => Some(Rss::extract_max(x, &rss)),
-                            None => Some(rss),
+                    // Get CPU Information
+                    match get_guest_time(pid) {
+                        Ok(guest_time) => vm_metric.cpu_guest_time = Some(guest_time),
+                        Err(e) => {
+                            metric_countdown = 0;
+                            warn!("Failed to get guest CPU time: {}", e);
                         }
                     }
-                    Err(e) => error!("Failed to get guest RSS: {}", e),
+
+                    // Get Memory Information
+                    match get_rss(pid) {
+                        Ok(rss) => {
+                            vm_metric.rss = match &vm_metric.rss {
+                                Some(x) => Some(Rss::extract_max(x, &rss)),
+                                None => Some(rss),
+                            }
+                        }
+                        Err(e) => {
+                            metric_countdown = 0;
+                            warn!("Failed to get guest RSS: {}", e);
+                        }
+                    }
                 }
             }
 
@@ -845,6 +857,9 @@
     }
 
     let guest_time_ticks = data_list[42].parse::<i64>()?;
+    if guest_time_ticks == 0 {
+        bail!("zero value is measured on elapsed CPU guest_time");
+    }
     // SAFETY: It just returns an integer about CPU tick information.
     let ticks_per_sec = unsafe { sysconf(_SC_CLK_TCK) };
     Ok(guest_time_ticks * MILLIS_PER_SEC / ticks_per_sec)
@@ -875,7 +890,12 @@
             rss_crosvm_total += rss;
         }
     }
-
+    if rss_crosvm_total == 0 {
+        bail!("zero value is measured on RSS of crosvm");
+    }
+    if rss_vm_total == 0 {
+        bail!("zero value is measured on RSS of VM");
+    }
     Ok(Rss { vm: rss_vm_total, crosvm: rss_crosvm_total })
 }
 
diff --git a/build/debian/build.sh b/build/debian/build.sh
index 6facfcf..616dd00 100755
--- a/build/debian/build.sh
+++ b/build/debian/build.sh
@@ -269,7 +269,7 @@
 	            --extract "${dsc_file}"
 	pushd "linux-${debian_kver%-*}" > /dev/null
 
-	local kpatches_src="$SCRIPT_DIR/kernel_patches"
+	local kpatches_src="$SCRIPT_DIR/kernel/patches"
 	cp -r "${kpatches_src}/avf" debian/patches/
 	cat "${kpatches_src}/series" >> debian/patches/series
 	./debian/rules orig
@@ -282,9 +282,8 @@
 
 	# 2. Define our custom flavour and regenerate control file
 	# NOTE: Our flavour extends Debian's `cloud` config on the `none` featureset.
-	cat > debian/config/${debian_arch}/config.${debarch_flavour} <<EOF
-# TODO: Add our custom kernel config to this file
-EOF
+	cp "$SCRIPT_DIR/kernel/config" \
+	   debian/config/${debian_arch}/config.${debarch_flavour}
 
 	sed -z "s;\[base\]\nflavours:;[base]\nflavours:\n ${debarch_flavour};" \
 	    -i debian/config/${debian_arch}/none/defines
diff --git a/build/debian/fai_config/files/usr/local/bin/enable_display/AVF b/build/debian/fai_config/files/usr/local/bin/enable_display/AVF
index 69dce6a..76f9f97 100644
--- a/build/debian/fai_config/files/usr/local/bin/enable_display/AVF
+++ b/build/debian/fai_config/files/usr/local/bin/enable_display/AVF
@@ -1,4 +1,6 @@
 #!/bin/bash
 sudo systemd-run --collect -E XDG_SESSION_TYPE=wayland --uid=1000 -p PAMName=login -p TTYPath=/dev/tty7 sleep 1d
 systemctl --user start weston
-export DISPLAY=:0
\ No newline at end of file
+export DISPLAY=:0
+export MESA_LOADER_DRIVER_OVERRIDE=zink
+export LIBGL_ALWAYS_SOFTWARE=1
\ No newline at end of file
diff --git a/build/debian/fai_config/scripts/AVF/20-useradd b/build/debian/fai_config/scripts/AVF/20-useradd
index 1c93772..b92648a 100755
--- a/build/debian/fai_config/scripts/AVF/20-useradd
+++ b/build/debian/fai_config/scripts/AVF/20-useradd
@@ -1,4 +1,4 @@
 #!/bin/bash
 
-$ROOTCMD useradd -m -u 1000 -N -G sudo -s /usr/bin/bash droid
+$ROOTCMD useradd -m -u 1000 -N -G sudo,video,render -s /usr/bin/bash droid
 $ROOTCMD echo 'droid ALL=(ALL) NOPASSWD:ALL' >> $target/etc/sudoers
diff --git a/build/debian/kernel/config b/build/debian/kernel/config
new file mode 100644
index 0000000..1ba603c
--- /dev/null
+++ b/build/debian/kernel/config
@@ -0,0 +1 @@
+CONFIG_DRM=m
diff --git a/build/debian/kernel_patches/avf/arm64-balloon.patch b/build/debian/kernel/patches/avf/arm64-balloon.patch
similarity index 100%
rename from build/debian/kernel_patches/avf/arm64-balloon.patch
rename to build/debian/kernel/patches/avf/arm64-balloon.patch
diff --git a/build/debian/kernel_patches/series b/build/debian/kernel/patches/series
similarity index 100%
rename from build/debian/kernel_patches/series
rename to build/debian/kernel/patches/series
diff --git a/build/debian/vm_config.json.aarch64 b/build/debian/vm_config.json.aarch64
index 96254f8..463583f 100644
--- a/build/debian/vm_config.json.aarch64
+++ b/build/debian/vm_config.json.aarch64
@@ -35,5 +35,8 @@
     "console_out": true,
     "console_input_device": "ttyS0",
     "network": true,
-    "auto_memory_balloon": true
+    "auto_memory_balloon": true,
+    "gpu": {
+        "backend": "2d"
+    }
 }
diff --git a/build/debian/vm_config.json.x86_64 b/build/debian/vm_config.json.x86_64
index c34a0f2..bc4e00a 100644
--- a/build/debian/vm_config.json.x86_64
+++ b/build/debian/vm_config.json.x86_64
@@ -44,5 +44,8 @@
     "console_out": true,
     "console_input_device": "ttyS0",
     "network": true,
-    "auto_memory_balloon": true
+    "auto_memory_balloon": true,
+    "gpu": {
+        "backend": "2d"
+    }
 }
diff --git a/guest/pvmfw/README.md b/guest/pvmfw/README.md
index 766a923..652ca90 100644
--- a/guest/pvmfw/README.md
+++ b/guest/pvmfw/README.md
@@ -461,7 +461,12 @@
   - `secretkeeper_protection`: pvmfw defers rollback protection to the guest
   - `supports_uefi_boot`: pvmfw boots the VM as a EFI payload (experimental)
   - `trusty_security_vm`: pvmfw skips rollback protection
-- `"com.android.virt.page_size"`: the guest page size in KiB (optional, defaults to 4)
+- `"com.android.virt.page_size"`: (optional) the guest page size in KiB, defaults to 4
+- `"com.android.virt.name"`: (optional) VM name, used as the
+  [`component_name`][dice-comp-name] (defaults to `"vm_entry"`) in the guest
+  DICE certificate and to identify special VMs
+
+[dice-comp-name]: https://cs.android.com/android/platform/superproject/main/+/main:external/open-dice/docs/android.md;l=81;drc=6d511e9533eac05d64d47fcd78ac5d881e72c3de
 
 ## Development
 
diff --git a/guest/pvmfw/avb/Android.bp b/guest/pvmfw/avb/Android.bp
index 141c1d2..0d55d7c 100644
--- a/guest/pvmfw/avb/Android.bp
+++ b/guest/pvmfw/avb/Android.bp
@@ -37,6 +37,7 @@
         ":test_image_with_one_hashdesc",
         ":test_image_with_non_initrd_hashdesc",
         ":test_image_with_initrd_and_non_initrd_desc",
+        ":test_image_with_name",
         ":test_image_with_invalid_page_size",
         ":test_image_with_negative_page_size",
         ":test_image_with_overflow_page_size",
@@ -123,6 +124,20 @@
 }
 
 avb_add_hash_footer {
+    name: "test_image_with_name",
+    src: ":unsigned_test_image",
+    partition_name: "boot",
+    private_key: ":pvmfw_sign_key",
+    salt: "2134",
+    props: [
+        {
+            name: "com.android.virt.name",
+            value: "test_vm_name",
+        },
+    ],
+}
+
+avb_add_hash_footer {
     name: "test_image_with_invalid_page_size",
     src: ":unsigned_test_image",
     partition_name: "boot",
diff --git a/guest/pvmfw/avb/src/error.rs b/guest/pvmfw/avb/src/error.rs
index 1307e15..eb82837 100644
--- a/guest/pvmfw/avb/src/error.rs
+++ b/guest/pvmfw/avb/src/error.rs
@@ -30,6 +30,8 @@
     UnknownVbmetaProperty,
     /// VBMeta has invalid page_size property.
     InvalidPageSize,
+    /// VBMeta has invalid VM name property.
+    InvalidVmName,
 }
 
 impl From<SlotVerifyError<'_>> for PvmfwVerifyError {
@@ -54,6 +56,7 @@
             }
             Self::UnknownVbmetaProperty => write!(f, "Unknown vbmeta property"),
             Self::InvalidPageSize => write!(f, "Invalid page_size property"),
+            Self::InvalidVmName => write!(f, "Invalid name property"),
         }
     }
 }
diff --git a/guest/pvmfw/avb/src/verify.rs b/guest/pvmfw/avb/src/verify.rs
index 8810696..6a3d7de 100644
--- a/guest/pvmfw/avb/src/verify.rs
+++ b/guest/pvmfw/avb/src/verify.rs
@@ -17,7 +17,7 @@
 use crate::ops::{Ops, Payload};
 use crate::partition::PartitionName;
 use crate::PvmfwVerifyError;
-use alloc::vec::Vec;
+use alloc::{string::String, vec::Vec};
 use avb::{
     Descriptor, DescriptorError, DescriptorResult, HashDescriptor, PartitionData, SlotVerifyError,
     SlotVerifyNoDataResult, VbmetaData,
@@ -47,6 +47,8 @@
     pub rollback_index: u64,
     /// Page size of kernel, if present.
     pub page_size: Option<usize>,
+    /// Name of the guest payload, if present.
+    pub name: Option<String>,
 }
 
 impl VerifiedBootData<'_> {
@@ -238,6 +240,18 @@
     Ok(Some(size))
 }
 
+/// Returns the indicated payload name, if present.
+fn read_name(vbmeta_data: &VbmetaData) -> Result<Option<String>, PvmfwVerifyError> {
+    let Some(property) = vbmeta_data.get_property_value("com.android.virt.name") else {
+        return Ok(None);
+    };
+    let name = str::from_utf8(property).map_err(|_| PvmfwVerifyError::InvalidVmName)?;
+    if name.is_empty() {
+        return Err(PvmfwVerifyError::InvalidVmName);
+    }
+    Ok(Some(name.into()))
+}
+
 /// Verifies the given initrd partition, and checks that the resulting contents looks like expected.
 fn verify_initrd(
     ops: &mut Ops,
@@ -275,6 +289,7 @@
     let hash_descriptors = HashDescriptors::get(&descriptors)?;
     let capabilities = Capability::get_capabilities(vbmeta_image)?;
     let page_size = read_page_size(vbmeta_image)?;
+    let name = read_name(vbmeta_image)?;
 
     if initrd.is_none() {
         hash_descriptors.verify_no_initrd()?;
@@ -286,6 +301,7 @@
             capabilities,
             rollback_index,
             page_size,
+            name,
         });
     }
 
@@ -307,5 +323,6 @@
         capabilities,
         rollback_index,
         page_size,
+        name,
     })
 }
diff --git a/guest/pvmfw/avb/tests/api_test.rs b/guest/pvmfw/avb/tests/api_test.rs
index 3027c47..b3899d9 100644
--- a/guest/pvmfw/avb/tests/api_test.rs
+++ b/guest/pvmfw/avb/tests/api_test.rs
@@ -27,6 +27,7 @@
 use utils::*;
 
 const TEST_IMG_WITH_ONE_HASHDESC_PATH: &str = "test_image_with_one_hashdesc.img";
+const TEST_IMG_WITH_NAME_PATH: &str = "test_image_with_name.img";
 const TEST_IMG_WITH_INVALID_PAGE_SIZE_PATH: &str = "test_image_with_invalid_page_size.img";
 const TEST_IMG_WITH_NEGATIVE_PAGE_SIZE_PATH: &str = "test_image_with_negative_page_size.img";
 const TEST_IMG_WITH_OVERFLOW_PAGE_SIZE_PATH: &str = "test_image_with_overflow_page_size.img";
@@ -102,6 +103,7 @@
         capabilities: vec![],
         rollback_index: 0,
         page_size: None,
+        name: None,
     };
     assert_eq!(expected_boot_data, verified_boot_data);
 
@@ -147,6 +149,7 @@
         capabilities: vec![Capability::RemoteAttest],
         rollback_index: 0,
         page_size: None,
+        name: None,
     };
     assert_eq!(expected_boot_data, verified_boot_data);
 
@@ -247,6 +250,18 @@
 }
 
 #[test]
+fn kernel_has_expected_valid_name() {
+    let kernel = fs::read(TEST_IMG_WITH_NAME_PATH).unwrap();
+    assert_eq!(read_name(&kernel), Ok(Some("test_vm_name".to_owned())));
+}
+
+#[test]
+fn kernel_has_expected_missing_name() {
+    let kernel = fs::read(TEST_IMG_WITH_ONE_HASHDESC_PATH).unwrap();
+    assert_eq!(read_name(&kernel), Ok(None));
+}
+
+#[test]
 fn kernel_has_expected_page_size_invalid() {
     let kernel = fs::read(TEST_IMG_WITH_INVALID_PAGE_SIZE_PATH).unwrap();
     assert_eq!(read_page_size(&kernel), Err(PvmfwVerifyError::InvalidPageSize));
@@ -483,6 +498,7 @@
         capabilities: vec![],
         rollback_index: 5,
         page_size: None,
+        name: None,
     };
     assert_eq!(expected_boot_data, verified_boot_data);
     Ok(())
diff --git a/guest/pvmfw/avb/tests/utils.rs b/guest/pvmfw/avb/tests/utils.rs
index 7282f3e..227daa2 100644
--- a/guest/pvmfw/avb/tests/utils.rs
+++ b/guest/pvmfw/avb/tests/utils.rs
@@ -28,6 +28,7 @@
 use std::{
     fs,
     mem::{size_of, transmute, MaybeUninit},
+    string::String,
 };
 
 const MICRODROID_KERNEL_IMG_PATH: &str = "microdroid_kernel";
@@ -134,6 +135,7 @@
         capabilities,
         rollback_index: 1,
         page_size,
+        name: None,
     };
     assert_eq!(expected_boot_data, verified_boot_data);
 
@@ -166,12 +168,23 @@
         capabilities,
         rollback_index: expected_rollback_index,
         page_size,
+        name: None,
     };
     assert_eq!(expected_boot_data, verified_boot_data);
 
     Ok(())
 }
 
+pub fn read_name(kernel: &[u8]) -> Result<Option<String>, PvmfwVerifyError> {
+    let public_key = load_trusted_public_key().unwrap();
+    let verified_boot_data = verify_payload(
+        kernel,
+        None, // initrd
+        &public_key,
+    )?;
+    Ok(verified_boot_data.name)
+}
+
 pub fn read_page_size(kernel: &[u8]) -> Result<Option<usize>, PvmfwVerifyError> {
     let public_key = load_trusted_public_key().unwrap();
     let verified_boot_data = verify_payload(
diff --git a/guest/pvmfw/src/dice.rs b/guest/pvmfw/src/dice.rs
index f49fedb..49a3807 100644
--- a/guest/pvmfw/src/dice.rs
+++ b/guest/pvmfw/src/dice.rs
@@ -16,6 +16,7 @@
 extern crate alloc;
 
 use alloc::format;
+use alloc::string::String;
 use alloc::vec::Vec;
 use ciborium::cbor;
 use ciborium::Value;
@@ -83,6 +84,7 @@
     pub mode: DiceMode,
     pub security_version: u64,
     pub rkp_vm_marker: bool,
+    component_name: String,
 }
 
 impl PartialInputs {
@@ -90,12 +92,13 @@
         let code_hash = to_dice_hash(data)?;
         let auth_hash = hash(data.public_key)?;
         let mode = to_dice_mode(data.debug_level);
+        let component_name = data.name.clone().unwrap_or(String::from("vm_entry"));
         // We use rollback_index from vbmeta as the security_version field in dice certificate.
         let security_version = data.rollback_index;
         let rkp_vm_marker = data.has_capability(Capability::RemoteAttest)
             || data.has_capability(Capability::TrustySecurityVm);
 
-        Ok(Self { code_hash, auth_hash, mode, security_version, rkp_vm_marker })
+        Ok(Self { code_hash, auth_hash, mode, security_version, rkp_vm_marker, component_name })
     }
 
     pub fn write_next_bcc(
@@ -156,7 +159,7 @@
 
     fn generate_config_descriptor(&self, instance_hash: Option<Hash>) -> Result<Vec<u8>> {
         let mut config = Vec::with_capacity(4);
-        config.push((cbor!(COMPONENT_NAME_KEY)?, cbor!("vm_entry")?));
+        config.push((cbor!(COMPONENT_NAME_KEY)?, cbor!(self.component_name.as_str())?));
         config.push((cbor!(SECURITY_VERSION_KEY)?, cbor!(self.security_version)?));
         if self.rkp_vm_marker {
             config.push((cbor!(RKP_VM_MARKER_KEY)?, Value::Null))
@@ -200,6 +203,7 @@
         kernel_digest: [1u8; size_of::<Digest>()],
         initrd_digest: Some([2u8; size_of::<Digest>()]),
         public_key: b"public key",
+        name: None,
         capabilities: vec![],
         rollback_index: 42,
         page_size: None,
@@ -249,12 +253,13 @@
     }
 
     #[test]
-    fn rkp_vm_config_descriptor_has_rkp_vm_marker() {
+    fn rkp_vm_config_descriptor_has_rkp_vm_marker_and_component_name() {
         let vb_data =
             VerifiedBootData { capabilities: vec![Capability::RemoteAttest], ..BASE_VB_DATA };
         let inputs = PartialInputs::new(&vb_data).unwrap();
         let config_map = decode_config_descriptor(&inputs, Some(HASH));
 
+        assert_eq!(config_map.get(&COMPONENT_NAME_KEY).unwrap().as_text().unwrap(), "vm_entry");
         assert!(config_map.get(&RKP_VM_MARKER_KEY).unwrap().is_null());
     }
 
diff --git a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachine.java b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachine.java
index 5f634ef..af313a1 100644
--- a/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachine.java
+++ b/libs/framework-virtualization/src/android/system/virtualmachine/VirtualMachine.java
@@ -276,28 +276,16 @@
 
         @Override
         public void onTrimMemory(int level) {
-            int percent;
+            /* Treat level < TRIM_MEMORY_UI_HIDDEN as generic low-memory warnings */
+            int percent = 10;
 
-            switch (level) {
-                case ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL:
-                    percent = 50;
-                    break;
-                case ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW:
-                    percent = 30;
-                    break;
-                case ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE:
-                    percent = 10;
-                    break;
-                case ComponentCallbacks2.TRIM_MEMORY_BACKGROUND:
-                case ComponentCallbacks2.TRIM_MEMORY_MODERATE:
-                case ComponentCallbacks2.TRIM_MEMORY_COMPLETE:
-                    /* Release as much memory as we can. The app is on the LMKD LRU kill list. */
-                    percent = 50;
-                    break;
-                default:
-                    /* Treat unrecognised messages as generic low-memory warnings. */
-                    percent = 30;
-                    break;
+            if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) {
+                percent = 30;
+            }
+
+            if (level >= ComponentCallbacks2.TRIM_MEMORY_BACKGROUND) {
+                /* Release as much memory as we can. The app is on the LMKD LRU kill list. */
+                percent = 50;
             }
 
             synchronized (mLock) {
diff --git a/libs/service-compos/java/com/android/server/compos/IsolatedCompilationJobService.java b/libs/service-compos/java/com/android/server/compos/IsolatedCompilationJobService.java
index adc0300..3033991 100644
--- a/libs/service-compos/java/com/android/server/compos/IsolatedCompilationJobService.java
+++ b/libs/service-compos/java/com/android/server/compos/IsolatedCompilationJobService.java
@@ -174,7 +174,7 @@
             }
 
             try {
-                ICompilationTask composTask = composd.startStagedApexCompile(this);
+                ICompilationTask composTask = composd.startStagedApexCompile(this, "microdroid");
                 mMetrics.onCompilationStarted();
                 mTask.set(composTask);
                 composTask.asBinder().linkToDeath(this, 0);
diff --git a/tests/benchmark_hostside/java/android/avf/test/AVFHostTestCase.java b/tests/benchmark_hostside/java/android/avf/test/AVFHostTestCase.java
index e2956f2..003c3f0 100644
--- a/tests/benchmark_hostside/java/android/avf/test/AVFHostTestCase.java
+++ b/tests/benchmark_hostside/java/android/avf/test/AVFHostTestCase.java
@@ -19,6 +19,7 @@
 import static com.android.tradefed.device.TestDevice.MicrodroidBuilder;
 import static com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestMetrics;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 import static com.google.common.truth.TruthJUnit.assume;
 
@@ -113,16 +114,78 @@
 
     @Test
     public void testBootWithCompOS() throws Exception {
-        composTestHelper(true);
+        composTestHelper(true, "microdroid");
+    }
+
+    @Test
+    public void testBootWithCompOS_os_android15_66() throws Exception {
+        composTestHelper(true, "android15_66");
+    }
+
+    @Test
+    public void testBootWithCompOS_os_microdroid_16k() throws Exception {
+        composTestHelper(true, "microdroid_16k");
     }
 
     @Test
     public void testBootWithoutCompOS() throws Exception {
-        composTestHelper(false);
+        composTestHelper(false, null);
     }
 
     @Test
     public void testNoLongHypSections() throws Exception {
+        noLongHypSectionsHelper("microdroid");
+    }
+
+    @Test
+    public void testNoLongHypSections_os_android15_66() throws Exception {
+        noLongHypSectionsHelper("android15_66");
+    }
+
+    @Test
+    public void testNoLongHypSections_os_microdroid_16k() throws Exception {
+        noLongHypSectionsHelper("microdroid_16k");
+    }
+
+    @Test
+    public void testPsciMemProtect() throws Exception {
+        psciMemProtectHelper("microdroid");
+    }
+
+    @Test
+    public void testPsciMemProtect_os_android15_66() throws Exception {
+        psciMemProtectHelper("android15_66");
+    }
+
+    @Test
+    public void testPsciMemProtect_os_microdroid_16k() throws Exception {
+        psciMemProtectHelper("microdroid_16k");
+    }
+
+    @Test
+    public void testCameraAppStartupTime() throws Exception {
+        String[] launchIntentPackages = {
+            "com.android.camera2",
+            "com.google.android.GoogleCamera/com.android.camera.CameraLauncher"
+        };
+        String launchIntentPackage = findSupportedPackage(launchIntentPackages);
+        assume().withMessage("No supported camera package").that(launchIntentPackage).isNotNull();
+        appStartupHelper(launchIntentPackage);
+    }
+
+    @Test
+    public void testSettingsAppStartupTime() throws Exception {
+        String[] launchIntentPackages = {"com.android.settings"};
+        String launchIntentPackage = findSupportedPackage(launchIntentPackages);
+        assume().withMessage("No supported settings package").that(launchIntentPackage).isNotNull();
+        appStartupHelper(launchIntentPackage);
+    }
+
+    private void noLongHypSectionsHelper(String osKey) throws Exception {
+        assumeKernelSupported(osKey);
+        assumeVmTypeSupported(osKey, true);
+        String os = SUPPORTED_OSES.get(osKey);
+
         String[] hypEvents = {"hyp_enter", "hyp_exit"};
 
         assumeTrue(
@@ -130,7 +193,7 @@
                 KvmHypTracer.isSupported(getDevice(), hypEvents));
 
         KvmHypTracer tracer = new KvmHypTracer(getDevice(), hypEvents);
-        String result = tracer.run(COMPOSD_CMD_BIN + " test-compile");
+        String result = tracer.run(COMPOSD_CMD_BIN + " test-compile --os " + os);
         assertWithMessage("Failed to test compilation VM.")
                 .that(result)
                 .ignoringCase()
@@ -141,8 +204,11 @@
         CLog.i("Hypervisor traces parsed successfully.");
     }
 
-    @Test
-    public void testPsciMemProtect() throws Exception {
+    public void psciMemProtectHelper(String osKey) throws Exception {
+        assumeKernelSupported(osKey);
+        assumeVmTypeSupported(osKey, true);
+        String os = SUPPORTED_OSES.get(osKey);
+
         String[] hypEvents = {"psci_mem_protect"};
 
         assumeTrue(
@@ -151,7 +217,12 @@
         KvmHypTracer tracer = new KvmHypTracer(getDevice(), hypEvents);
 
         /* We need to wait for crosvm to die so all the VM pages are reclaimed */
-        String result = tracer.run(COMPOSD_CMD_BIN + " test-compile && killall -w crosvm || true");
+        String result =
+                tracer.run(
+                        COMPOSD_CMD_BIN
+                                + " test-compile --os "
+                                + os
+                                + " && killall -w crosvm || true");
         assertWithMessage("Failed to test compilation VM.")
                 .that(result)
                 .ignoringCase()
@@ -176,25 +247,6 @@
                 .isGreaterThan(0);
     }
 
-    @Test
-    public void testCameraAppStartupTime() throws Exception {
-        String[] launchIntentPackages = {
-            "com.android.camera2",
-            "com.google.android.GoogleCamera/com.android.camera.CameraLauncher"
-        };
-        String launchIntentPackage = findSupportedPackage(launchIntentPackages);
-        assume().withMessage("No supported camera package").that(launchIntentPackage).isNotNull();
-        appStartupHelper(launchIntentPackage);
-    }
-
-    @Test
-    public void testSettingsAppStartupTime() throws Exception {
-        String[] launchIntentPackages = {"com.android.settings"};
-        String launchIntentPackage = findSupportedPackage(launchIntentPackages);
-        assume().withMessage("No supported settings package").that(launchIntentPackage).isNotNull();
-        appStartupHelper(launchIntentPackage);
-    }
-
     private void appStartupHelper(String launchIntentPackage) throws Exception {
         assumeTrue(
                 "Skip on non-protected VMs",
@@ -471,8 +523,14 @@
         throw new IllegalArgumentException("Failed to get boot time info.");
     }
 
-    private void composTestHelper(boolean isWithCompos) throws Exception {
+    private void composTestHelper(boolean isWithCompos, String osKey) throws Exception {
         assumeFalse("Skip on CF; too slow", isCuttlefish());
+        if (isWithCompos) {
+            assumeKernelSupported(osKey);
+            assumeVmTypeSupported(osKey, true);
+        } else {
+            assertThat(osKey).isNull();
+        }
 
         List<Double> bootDmesgTime = new ArrayList<>(ROUND_COUNT);
 
@@ -480,7 +538,8 @@
             reInstallApex(REINSTALL_APEX_TIMEOUT_SEC);
             try {
                 if (isWithCompos) {
-                    compileStagedApex(COMPILE_STAGED_APEX_TIMEOUT_SEC);
+                    String os = SUPPORTED_OSES.get(osKey);
+                    compileStagedApex(COMPILE_STAGED_APEX_TIMEOUT_SEC, os);
                 }
             } finally {
                 // If compilation fails, we still have a staged APEX, and we need to reboot to
@@ -518,7 +577,7 @@
         getDevice().enableAdbRoot();
     }
 
-    private void compileStagedApex(int timeoutSec) throws Exception {
+    private void compileStagedApex(int timeoutSec, String os) throws Exception {
 
         long timeStart = System.currentTimeMillis();
         long timeEnd = timeStart + timeoutSec * 1000L;
@@ -530,7 +589,7 @@
 
                 String result =
                         android.runWithTimeout(
-                                3 * 60 * 1000, COMPOSD_CMD_BIN + " staged-apex-compile");
+                                3 * 60 * 1000, COMPOSD_CMD_BIN + " staged-apex-compile --os " + os);
                 assertWithMessage("Failed to compile staged APEX. Reason: " + result)
                         .that(result)
                         .ignoringCase()
diff --git a/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java b/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
index 67249b4..18ed7b6 100644
--- a/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
+++ b/tests/helper/src/java/com/android/microdroid/test/device/MicrodroidDeviceTestBase.java
@@ -58,9 +58,10 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStreamReader;
-import java.util.Arrays;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashSet;
+import java.util.List;
 import java.util.OptionalLong;
 import java.util.Set;
 import java.util.concurrent.CompletableFuture;
@@ -73,13 +74,21 @@
     private final String MAX_PERFORMANCE_TASK_PROFILE = "CPUSET_SP_TOP_APP";
 
     protected static final String KERNEL_VERSION = SystemProperties.get("ro.kernel.version");
+
+    private static final List<String> getSupportedOSes() {
+        List<String> ret = new ArrayList<>();
+        ret.add("microdroid");
+        if (Build.VERSION.SDK_INT >= 35) {
+            ret.add("microdroid_gki-android15-6.6");
+        }
+        if (Build.VERSION.SDK_INT >= 36) {
+            ret.add("microdroid_16k");
+        }
+        return ret;
+    }
+
     protected static final Set<String> SUPPORTED_OSES =
-            Collections.unmodifiableSet(
-                    new HashSet<>(
-                            Arrays.asList(
-                                    "microdroid",
-                                    "microdroid_16k",
-                                    "microdroid_gki-android15-6.6")));
+            Collections.unmodifiableSet(new HashSet<>(getSupportedOSes()));
 
     private static final long ONE_MEBI = 1024 * 1024;
     private static final long MIN_MEM_ARM64 = 170 * ONE_MEBI;
@@ -155,10 +164,12 @@
     }
 
     public VirtualMachineConfig.Builder newVmConfigBuilderWithPayloadConfig(String configPath) {
-        return new VirtualMachineConfig.Builder(mCtx)
-                .setProtectedVm(mProtectedVm)
-                .setOs(os())
-                .setPayloadConfigPath(configPath);
+        VirtualMachineConfig.Builder builder = new VirtualMachineConfig.Builder(mCtx);
+        builder.setProtectedVm(mProtectedVm).setPayloadConfigPath(configPath);
+        if (Build.VERSION.SDK_INT >= 35) {
+            builder.setOs(os());
+        }
+        return builder;
     }
 
     public VirtualMachineConfig.Builder newVmConfigBuilderWithPayloadBinary(String binaryPath) {
@@ -288,15 +299,18 @@
     }
 
     protected boolean isUpdatableVmSupported() throws VirtualMachineException {
-        return getVirtualMachineManager().isUpdatableVmSupported();
+        // Pre-36 OS doesn't have VirtualMachineManager#isUpdatableVmSupported.
+        if (Build.VERSION.SDK_INT >= 35) {
+            return getVirtualMachineManager().isUpdatableVmSupported();
+        }
+        return false;
     }
 
     protected void ensureVmAttestationSupported() throws Exception {
         // The first vendor API level is checked because VM attestation requires the VM DICE chain
         // to be ROM-rooted.
         int firstVendorApiLevel = getFirstVendorApiLevel();
-        boolean isRemoteAttestationSupported =
-                getVirtualMachineManager().isRemoteAttestationSupported();
+        boolean isRemoteAttestationSupported = isRemoteAttestationSupported();
         if (firstVendorApiLevel >= 202504) {
             assertWithMessage(
                             "First vendor API '"
@@ -309,6 +323,14 @@
         }
     }
 
+    protected boolean isRemoteAttestationSupported() throws VirtualMachineException {
+        // Pre-36 OS doesn't have VirtualMachineManager#isRemoteAttestionSupported
+        if (Build.VERSION.SDK_INT >= 35) {
+            return getVirtualMachineManager().isRemoteAttestationSupported();
+        }
+        return false;
+    }
+
     public abstract static class VmEventListener implements VirtualMachineCallback {
         private ExecutorService mExecutorService = Executors.newSingleThreadExecutor();
         private OptionalLong mVcpuStartedNanoTime = OptionalLong.empty();
diff --git a/tests/hostside/helper/java/com/android/microdroid/test/host/MicrodroidHostTestCaseBase.java b/tests/hostside/helper/java/com/android/microdroid/test/host/MicrodroidHostTestCaseBase.java
index ad37dda..fcef19a 100644
--- a/tests/hostside/helper/java/com/android/microdroid/test/host/MicrodroidHostTestCaseBase.java
+++ b/tests/hostside/helper/java/com/android/microdroid/test/host/MicrodroidHostTestCaseBase.java
@@ -18,6 +18,7 @@
 
 import static com.android.tradefed.testtype.DeviceJUnit4ClassRunner.TestLogData;
 
+import static com.google.common.truth.Truth.assertThat;
 import static com.google.common.truth.Truth.assertWithMessage;
 
 import static org.junit.Assume.assumeFalse;
@@ -267,4 +268,27 @@
     protected boolean isPkvmHypervisor() throws DeviceNotAvailableException {
         return "kvm.arm-protected".equals(getDevice().getProperty("ro.boot.hypervisor.version"));
     }
+
+    protected TestDevice getAndroidDevice() {
+        TestDevice androidDevice = (TestDevice) getDevice();
+        assertThat(androidDevice).isNotNull();
+        return androidDevice;
+    }
+
+    protected void assumeKernelSupported(String osKey) throws Exception {
+        String os = SUPPORTED_OSES.get(osKey);
+        assumeTrue(
+                "Skipping test as OS \"" + os + "\" is not supported",
+                getSupportedOSList().contains(os));
+    }
+
+    protected void assumeVmTypeSupported(String os, boolean protectedVm) throws Exception {
+        // TODO(b/376870129): remove this check
+        if (protectedVm) {
+            assumeFalse("pVMs with 16k kernel are not supported yet :(", os.endsWith("_16k"));
+        }
+        assumeTrue(
+                "Microdroid is not supported for specific VM protection type",
+                getAndroidDevice().supportsMicrodroid(protectedVm));
+    }
 }
diff --git a/tests/hostside/java/com/android/microdroid/test/MicrodroidHostTests.java b/tests/hostside/java/com/android/microdroid/test/MicrodroidHostTests.java
index 7864f3f..59a57f1 100644
--- a/tests/hostside/java/com/android/microdroid/test/MicrodroidHostTests.java
+++ b/tests/hostside/java/com/android/microdroid/test/MicrodroidHostTests.java
@@ -1474,12 +1474,6 @@
         }
     }
 
-    private TestDevice getAndroidDevice() {
-        TestDevice androidDevice = (TestDevice) getDevice();
-        assertThat(androidDevice).isNotNull();
-        return androidDevice;
-    }
-
     // The TradeFed Dockerfile sets LD_LIBRARY_PATH to a directory with an older libc++.so, which
     // breaks binaries that are linked against a newer libc++.so. Binaries commonly use DT_RUNPATH
     // to find an adjacent libc++.so (e.g. `$ORIGIN/../lib64`), but LD_LIBRARY_PATH overrides
@@ -1490,23 +1484,6 @@
         return runUtil;
     }
 
-    private void assumeKernelSupported(String osKey) throws Exception {
-        String os = SUPPORTED_OSES.get(osKey);
-        assumeTrue(
-                "Skipping test as OS \"" + os + "\" is not supported",
-                getSupportedOSList().contains(os));
-    }
-
-    private void assumeVmTypeSupported(String os, boolean protectedVm) throws Exception {
-        // TODO(b/376870129): remove this check
-        if (protectedVm) {
-            assumeFalse("pVMs with 16k kernel are not supported yet :(", os.endsWith("_16k"));
-        }
-        assumeTrue(
-                "Microdroid is not supported for specific VM protection type",
-                getAndroidDevice().supportsMicrodroid(protectedVm));
-    }
-
     private void assumeArm64Supported() throws Exception {
         CommandRunner android = new CommandRunner(getDevice());
         String abi = android.run("getprop", "ro.product.cpu.abi");
diff --git a/tests/testapk/Android.bp b/tests/testapk/Android.bp
index d9f74dc..cc0015d 100644
--- a/tests/testapk/Android.bp
+++ b/tests/testapk/Android.bp
@@ -28,6 +28,9 @@
     use_embedded_native_libs: true,
     // We only support 64-bit ABI, but CTS demands all APKs to be multi-ABI.
     compile_multilib: "both",
+    lint: {
+        error_checks: ["NewApi"],
+    },
 }
 
 java_defaults {
diff --git a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
index e4a3ff6..639224d 100644
--- a/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
+++ b/tests/testapk/src/java/com/android/microdroid/test/MicrodroidTests.java
@@ -257,7 +257,7 @@
         assumeProtectedVM();
         assume().withMessage(
                         "This test does not apply to a device that supports Remote Attestation")
-                .that(getVirtualMachineManager().isRemoteAttestationSupported())
+                .that(isRemoteAttestationSupported())
                 .isFalse();
         VirtualMachineConfig config =
                 newVmConfigBuilderWithPayloadBinary(VM_ATTESTATION_PAYLOAD_PATH)
@@ -285,7 +285,7 @@
         // pVM remote attestation is only supported on protected VMs.
         assumeProtectedVM();
         assume().withMessage("Test needs Remote Attestation support")
-                .that(getVirtualMachineManager().isRemoteAttestationSupported())
+                .that(isRemoteAttestationSupported())
                 .isTrue();
         File vendorDiskImage = new File("/vendor/etc/avf/microdroid/microdroid_vendor.img");
         assumeTrue("Microdroid vendor image doesn't exist, skip", vendorDiskImage.exists());