Add tab support

Adding tab support to the terminal app with tablayout and custom tabitem
layout.

Test: Manually
Bug: 364150910
Change-Id: I527176273f7696322e8775bde71c1ff0ddf7036a
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/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>