Add ModifierKeysController

ModifierKeysContoller abstracts the handling of the modifier keys.
This change also adds the support for single-line modifier keys which is
used when the height of the terminal is too short.

Bug: 385251193
Test: show keyboard in split screen landscape mode

Change-Id: I7eb7333a28cdc0453b1e30a64e5d215d05ceb62e
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
index 5e039d9..bf2f573 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.kt
@@ -40,7 +40,7 @@
 import android.view.Menu
 import android.view.MenuItem
 import android.view.View
-import android.view.WindowInsets
+import android.view.ViewGroup
 import android.view.accessibility.AccessibilityManager
 import android.webkit.ClientCertRequest
 import android.webkit.SslErrorHandler
@@ -81,10 +81,12 @@
     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
 
     override fun onCreate(savedInstanceState: Bundle?) {
         super.onCreate(savedInstanceState)
@@ -104,7 +106,9 @@
         terminalView.getSettings().setCacheMode(WebSettings.LOAD_NO_CACHE)
         terminalView.setWebChromeClient(WebChromeClient())
 
-        setupModifierKeys()
+        terminalContainer = terminalView.parent as ViewGroup
+
+        modifierKeysController = ModifierKeysController(this, terminalView, terminalContainer)
 
         accessibilityManager =
             getSystemService<AccessibilityManager>(AccessibilityManager::class.java)
@@ -117,11 +121,6 @@
                 StartActivityForResult(),
                 ActivityResultCallback { startVm() },
             )
-        window.decorView.rootView.setOnApplyWindowInsetsListener { _: View?, insets: WindowInsets ->
-            updateModifierKeysVisibility()
-            insets
-        }
-
         executorService =
             Executors.newSingleThreadExecutor(TerminalThreadFactory(applicationContext))
 
@@ -147,30 +146,7 @@
     override fun onConfigurationChanged(newConfig: Configuration) {
         super.onConfigurationChanged(newConfig)
         lockOrientationIfNecessary()
-        updateModifierKeysVisibility()
-    }
-
-    private fun setupModifierKeys() {
-        // Only ctrl key is special, it communicates with xtermjs to modify key event with ctrl key
-        findViewById<View>(R.id.btn_ctrl)
-            .setOnClickListener(
-                View.OnClickListener {
-                    terminalView.mapCtrlKey()
-                    terminalView.enableCtrlKey()
-                }
-            )
-
-        val modifierButtonClickListener =
-            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))
-                }
-            }
-
-        for (btn in BTN_KEY_CODE_MAP.keys) {
-            findViewById<View>(btn).setOnClickListener(modifierButtonClickListener)
-        }
+        modifierKeysController.update()
     }
 
     override fun dispatchKeyEvent(event: KeyEvent): Boolean {
@@ -276,10 +252,9 @@
                                 if (completedRequestId == requestId) {
                                     Trace.endAsyncSection("executeTerminal", 0)
                                     findViewById<View?>(R.id.boot_progress).visibility = View.GONE
-                                    findViewById<View?>(R.id.webview_container).visibility =
-                                        View.VISIBLE
+                                    terminalContainer.visibility = View.VISIBLE
                                     bootCompleted.open()
-                                    updateModifierKeysVisibility()
+                                    modifierKeysController.update()
                                     terminalView.mapTouchToMouseEvent()
                                 }
                             }
@@ -379,15 +354,6 @@
         connectToTerminalService()
     }
 
-    private fun updateModifierKeysVisibility() {
-        val imeShown = window.decorView.rootWindowInsets.isVisible(WindowInsets.Type.ime())
-        val hasHwQwertyKeyboard = resources.configuration.keyboard == Configuration.KEYBOARD_QWERTY
-        val showModifierKeys = imeShown && !hasHwQwertyKeyboard
-
-        val modifierKeys = findViewById<View>(R.id.modifier_keys)
-        modifierKeys.visibility = if (showModifierKeys) View.VISIBLE else View.GONE
-    }
-
     private val installerLauncher =
         registerForActivityResult(StartActivityForResult()) { result ->
             val resultCode = result.resultCode
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/ModifierKeysController.kt b/android/TerminalApp/java/com/android/virtualization/terminal/ModifierKeysController.kt
new file mode 100644
index 0000000..f8f30f9
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/ModifierKeysController.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.app.Activity
+import android.content.res.Configuration
+import android.view.KeyEvent
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.view.WindowInsets
+
+class ModifierKeysController(
+    val activity: Activity,
+    val terminalView: TerminalView,
+    val parent: ViewGroup,
+) {
+    private val window = activity.window
+    private val keysSingleLine: View
+    private val keysDoubleLine: View
+
+    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.
+        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)
+
+        addClickListeners(keysSingleLine)
+        addClickListeners(keysDoubleLine)
+
+        keysSingleLine.visibility = View.GONE
+        keysDoubleLine.visibility = View.GONE
+
+        // Setup for the update to be called when needed
+        window.decorView.rootView.setOnApplyWindowInsetsListener { _: View?, insets: WindowInsets ->
+            update()
+            insets
+        }
+
+        terminalView.setOnFocusChangeListener { _: View, _: Boolean -> update() }
+    }
+
+    private fun addClickListeners(keys: View) {
+        // Only ctrl key is special, it communicates with xtermjs to modify key event with ctrl key
+        keys
+            .findViewById<View>(R.id.btn_ctrl)
+            .setOnClickListener({
+                terminalView.mapCtrlKey()
+                terminalView.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))
+                }
+            }
+
+        for (btn in BTN_KEY_CODE_MAP.keys) {
+            keys.findViewById<View>(btn).setOnClickListener(listener)
+        }
+    }
+
+    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)
+            }
+            keysInSingleLine = needSingleLine
+        }
+
+        // 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
+    }
+
+    // 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
+
+    companion object {
+        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,
+            )
+    }
+}
diff --git a/android/TerminalApp/res/layout/activity_headless.xml b/android/TerminalApp/res/layout/activity_headless.xml
index b4a65cc..e18aa5c 100644
--- a/android/TerminalApp/res/layout/activity_headless.xml
+++ b/android/TerminalApp/res/layout/activity_headless.xml
@@ -48,7 +48,6 @@
                 android:layout_height="wrap_content"/>
         </LinearLayout>
         <LinearLayout
-            android:id="@+id/webview_container"
             android:layout_width="match_parent"
             android:layout_height="match_parent"
             android:layout_marginBottom="5dp"
@@ -59,7 +58,6 @@
                 android:layout_width="match_parent"
                 android:layout_height="0dp"
                 android:layout_weight="1" />
-            <include layout="@layout/layout_modifier_keys" />
         </LinearLayout>
     </FrameLayout>
 
diff --git a/android/TerminalApp/res/layout/layout_modifier_keys.xml b/android/TerminalApp/res/layout/modifier_keys_doubleline.xml
similarity index 100%
rename from android/TerminalApp/res/layout/layout_modifier_keys.xml
rename to android/TerminalApp/res/layout/modifier_keys_doubleline.xml
diff --git a/android/TerminalApp/res/layout/modifier_keys_singleline.xml b/android/TerminalApp/res/layout/modifier_keys_singleline.xml
new file mode 100644
index 0000000..6ccf48f
--- /dev/null
+++ b/android/TerminalApp/res/layout/modifier_keys_singleline.xml
@@ -0,0 +1,84 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+ * Copyright (C) 2024 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.
+ -->
+<!--TODO(b/376813452): we might want tablet UI for that-->
+<LinearLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    android:id="@+id/modifier_keys"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="horizontal" >
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_esc"
+        android:textSize="10sp"
+        android:text="@string/btn_esc_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_tab"
+        android:textSize="10sp"
+        android:text="@string/btn_tab_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_ctrl"
+        android:textSize="10sp"
+        android:text="@string/btn_ctrl_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_alt"
+        android:textSize="10sp"
+        android:text="@string/btn_alt_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_home"
+        android:textSize="10sp"
+        android:text="@string/btn_home_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_end"
+        android:textSize="10sp"
+        android:text="@string/btn_end_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_left"
+        android:textSize="10sp"
+        android:text="@string/btn_left_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_down"
+        android:textSize="10sp"
+        android:text="@string/btn_down_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_up"
+        android:textSize="10sp"
+        android:text="@string/btn_up_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_right"
+        android:textSize="10sp"
+        android:text="@string/btn_right_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_pgdn"
+        android:textSize="10sp"
+        android:text="@string/btn_pgdn_text" />
+    <Button
+        style="@style/ModifierKeyStyle"
+        android:id="@+id/btn_pgup"
+        android:textSize="10sp"
+        android:text="@string/btn_pgup_text" />
+</LinearLayout>