Convert TerminalView to kotlin
Bug: 383243644
Test: check terminal view
Change-Id: Ia0894a8de502b58d23dcdd0aaeb7faf706142141
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
deleted file mode 100644
index 0ffc093..0000000
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
+++ /dev/null
@@ -1,313 +0,0 @@
-/*
- * 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.
- */
-package com.android.virtualization.terminal;
-
-import static com.android.virtualization.terminal.MainActivity.TAG;
-
-import android.content.Context;
-import android.graphics.Rect;
-import android.os.Bundle;
-import android.text.InputType;
-import android.text.TextUtils;
-import android.util.AttributeSet;
-import android.util.Log;
-import android.view.View;
-import android.view.View.AccessibilityDelegate;
-import android.view.ViewGroup;
-import android.view.accessibility.AccessibilityEvent;
-import android.view.accessibility.AccessibilityManager;
-import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
-import android.view.accessibility.AccessibilityManager.TouchExplorationStateChangeListener;
-import android.view.accessibility.AccessibilityNodeInfo;
-import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
-import android.view.accessibility.AccessibilityNodeProvider;
-import android.view.inputmethod.EditorInfo;
-import android.view.inputmethod.InputConnection;
-import android.webkit.WebView;
-
-import java.io.IOException;
-import java.io.InputStream;
-import java.util.List;
-
-public class TerminalView extends WebView
- implements AccessibilityStateChangeListener, TouchExplorationStateChangeListener {
- // Maximum length of texts the talk back announcements can be. This value is somewhat
- // arbitrarily set. We may want to adjust this in the future.
- private static final int TEXT_TOO_LONG_TO_ANNOUNCE = 200;
-
- private final String CTRL_KEY_HANDLER;
- private final String ENABLE_CTRL_KEY;
- private final String TOUCH_TO_MOUSE_HANDLER;
-
- private final AccessibilityManager mA11yManager;
-
- public TerminalView(Context context, AttributeSet attrs) {
- super(context, attrs);
-
- mA11yManager = context.getSystemService(AccessibilityManager.class);
- mA11yManager.addTouchExplorationStateChangeListener(this);
- mA11yManager.addAccessibilityStateChangeListener(this);
- adjustToA11yStateChange();
- try {
- CTRL_KEY_HANDLER = readAssetAsString(context, "js/ctrl_key_handler.js");
- ENABLE_CTRL_KEY = readAssetAsString(context, "js/enable_ctrl_key.js");
- TOUCH_TO_MOUSE_HANDLER = readAssetAsString(context, "js/touch_to_mouse_handler.js");
- } catch (IOException e) {
- // It cannot happen
- throw new IllegalArgumentException("cannot read code from asset", e);
- }
- }
-
- private String readAssetAsString(Context context, String filePath) throws IOException {
- try (InputStream is = context.getAssets().open(filePath)) {
- return new String(is.readAllBytes());
- }
- }
-
- public void mapTouchToMouseEvent() {
- this.evaluateJavascript(TOUCH_TO_MOUSE_HANDLER, null);
- }
-
- public void mapCtrlKey() {
- this.evaluateJavascript(CTRL_KEY_HANDLER, null);
- }
-
- public void enableCtrlKey() {
- this.evaluateJavascript(ENABLE_CTRL_KEY, null);
- }
-
- @Override
- public void onAccessibilityStateChanged(boolean enabled) {
- Log.d(TAG, "accessibility " + enabled);
- adjustToA11yStateChange();
- }
-
- @Override
- public void onTouchExplorationStateChanged(boolean enabled) {
- Log.d(TAG, "touch exploration " + enabled);
- adjustToA11yStateChange();
- }
-
- private void adjustToA11yStateChange() {
- if (!mA11yManager.isEnabled()) {
- setFocusable(true);
- return;
- }
-
- // When accessibility is on, the webview itself doesn't have to be focusable. The (virtual)
- // edittext will be focusable to accept inputs. However, the webview has to be focusable for
- // an accessibility purpose so that users can read the contents in it or scroll the view.
- setFocusable(false);
- setFocusableInTouchMode(true);
- }
-
- // AccessibilityEvents for WebView are sent directly from WebContentsAccessibilityImpl to the
- // parent of WebView, without going through WebView. So, there's no WebView methods we can
- // override to intercept the event handling process. To work around this, we attach an
- // AccessibilityDelegate to the parent view where the events are sent to. And to guarantee that
- // the parent view exists, wait until the WebView is attached to the window by when the parent
- // must exist.
- private final AccessibilityDelegate mA11yEventFilter =
- new AccessibilityDelegate() {
- @Override
- public boolean onRequestSendAccessibilityEvent(
- ViewGroup host, View child, AccessibilityEvent e) {
- // We filter only the a11y events from the WebView
- if (child != TerminalView.this) {
- return super.onRequestSendAccessibilityEvent(host, child, e);
- }
- final int eventType = e.getEventType();
- switch (e.getEventType()) {
- // Skip reading texts that are too long. Right now, ttyd emits entire
- // text on the terminal to the live region, which is very annoying to
- // screen reader users.
- case AccessibilityEvent.TYPE_ANNOUNCEMENT:
- CharSequence text = e.getText().get(0); // there always is a text
- if (text.length() >= TEXT_TOO_LONG_TO_ANNOUNCE) {
- Log.i(TAG, "Announcement skipped because it's too long: " + text);
- return false;
- }
- break;
- }
- return super.onRequestSendAccessibilityEvent(host, child, e);
- }
- };
-
- @Override
- protected void onAttachedToWindow() {
- super.onAttachedToWindow();
- if (mA11yManager.isEnabled()) {
- View parent = (View) getParent();
- parent.setAccessibilityDelegate(mA11yEventFilter);
- }
- }
-
- private final AccessibilityNodeProvider mA11yNodeProvider =
- new AccessibilityNodeProvider() {
-
- /** Returns the original NodeProvider that WebView implements. */
- private AccessibilityNodeProvider getParent() {
- return TerminalView.super.getAccessibilityNodeProvider();
- }
-
- /** Convenience method for reading a string resource. */
- private String getString(int resId) {
- return TerminalView.this.getContext().getResources().getString(resId);
- }
-
- /** Checks if NodeInfo renders an empty line in the terminal. */
- private boolean isEmptyLine(AccessibilityNodeInfo info) {
- final CharSequence text = info.getText();
- // Node with no text is not consiered a line. ttyd emits at least one character,
- // which usually is NBSP.
- if (text == null) {
- return false;
- }
- for (int i = 0; i < text.length(); i++) {
- char c = text.charAt(i);
- // Note: don't use Characters.isWhitespace as it doesn't recognize NBSP as a
- // whitespace.
- if (!TextUtils.isWhitespace(c)) {
- return false;
- }
- }
- return true;
- }
-
- @Override
- public AccessibilityNodeInfo createAccessibilityNodeInfo(int id) {
- AccessibilityNodeInfo info = getParent().createAccessibilityNodeInfo(id);
- if (info == null) {
- return null;
- }
-
- final String className = info.getClassName().toString();
-
- // By default all views except the cursor is not click-able. Other views are
- // read-only. This ensures that user is not navigated to non-clickable elements
- // when using switches.
- if (!"android.widget.EditText".equals(className)) {
- info.removeAction(AccessibilityAction.ACTION_CLICK);
- }
-
- switch (className) {
- case "android.webkit.WebView":
- // There are two NodeInfo objects of class name WebView. The one is the
- // real WebView whose ID is View.NO_ID as it's at the root of the
- // virtual view hierarchy. The second one is a virtual view for the
- // iframe. The latter one's text is set to the command that we give to
- // ttyd, which is "login -f droid ...". This is an impl detail which
- // doesn't have to be announced. Replace the text with "Terminal
- // display".
- if (id != View.NO_ID) {
- info.setText(null);
- info.setContentDescription(getString(R.string.terminal_display));
- // b/376827536
- info.setHintText(getString(R.string.double_tap_to_edit_text));
- }
-
- // These two lines below are to prevent this WebView element from being
- // fousable by the screen reader, while allowing any other element in
- // the WebView to be focusable by the reader. In our case, the EditText
- // is a117_focusable.
- info.setScreenReaderFocusable(false);
- info.addAction(AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS);
- break;
- case "android.view.View":
- // Empty line was announced as "space" (via the NBSP character).
- // Localize the spoken text.
- if (isEmptyLine(info)) {
- info.setContentDescription(getString(R.string.empty_line));
- // b/376827536
- info.setHintText(getString(R.string.double_tap_to_edit_text));
- }
- break;
- case "android.widget.TextView":
- // There are several TextViews in the terminal, and one of them is an
- // invisible TextView which seems to be from the <div
- // class="live-region"> tag. Interestingly, its text is often populated
- // with the entire text on the screen. Silence this by forcibly setting
- // the text to null. Note that this TextView is identified by having a
- // zero width. This certainly is not elegant, but I couldn't find other
- // options.
- Rect rect = new Rect();
- info.getBoundsInScreen(rect);
- if (rect.width() == 0) {
- info.setText(null);
- info.setContentDescription(getString(R.string.empty_line));
- }
- info.setScreenReaderFocusable(false);
- break;
- case "android.widget.EditText":
- // This EditText is for the <textarea> accepting user input; the cursor.
- // ttyd name it as "Terminal input" but it's not i18n'ed. Override it
- // here for better i18n.
- info.setText(null);
- info.setHintText(getString(R.string.double_tap_to_edit_text));
- info.setContentDescription(getString(R.string.terminal_input));
- info.setScreenReaderFocusable(true);
- info.addAction(AccessibilityAction.ACTION_FOCUS);
- break;
- }
- return info;
- }
-
- @Override
- public boolean performAction(int id, int action, Bundle arguments) {
- return getParent().performAction(id, action, arguments);
- }
-
- @Override
- public void addExtraDataToAccessibilityNodeInfo(
- int virtualViewId,
- AccessibilityNodeInfo info,
- String extraDataKey,
- Bundle arguments) {
- getParent()
- .addExtraDataToAccessibilityNodeInfo(
- virtualViewId, info, extraDataKey, arguments);
- }
-
- @Override
- public List<AccessibilityNodeInfo> findAccessibilityNodeInfosByText(
- String text, int virtualViewId) {
- return getParent().findAccessibilityNodeInfosByText(text, virtualViewId);
- }
-
- @Override
- public AccessibilityNodeInfo findFocus(int focus) {
- return getParent().findFocus(focus);
- }
- };
-
- @Override
- public AccessibilityNodeProvider getAccessibilityNodeProvider() {
- AccessibilityNodeProvider p = super.getAccessibilityNodeProvider();
- if (p != null && mA11yManager.isEnabled()) {
- return mA11yNodeProvider;
- }
- return p;
- }
-
- @Override
- public InputConnection onCreateInputConnection(EditorInfo outAttrs) {
- InputConnection inputConnection = super.onCreateInputConnection(outAttrs);
- if (outAttrs != null) {
- outAttrs.inputType |= InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
- }
- return inputConnection;
- }
-}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
new file mode 100644
index 0000000..18a39fa
--- /dev/null
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.kt
@@ -0,0 +1,283 @@
+/*
+ * 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.
+ */
+package com.android.virtualization.terminal
+
+import android.content.Context
+import android.graphics.Rect
+import android.os.Bundle
+import android.text.InputType
+import android.text.TextUtils
+import android.util.AttributeSet
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import android.view.accessibility.AccessibilityEvent
+import android.view.accessibility.AccessibilityManager
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityNodeProvider
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.webkit.WebView
+import com.android.virtualization.terminal.MainActivity.TAG
+import java.io.IOException
+
+class TerminalView(context: Context, attrs: AttributeSet?) :
+ WebView(context, attrs),
+ AccessibilityManager.AccessibilityStateChangeListener,
+ 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 touchToMouseHandler: String =
+ readAssetAsString(context, "js/touch_to_mouse_handler.js")
+ private val a11yManager =
+ context.getSystemService<AccessibilityManager>(AccessibilityManager::class.java).also {
+ it.addTouchExplorationStateChangeListener(this)
+ it.addAccessibilityStateChangeListener(this)
+ }
+
+ @Throws(IOException::class)
+ private fun readAssetAsString(context: Context, filePath: String): String {
+ return String(context.assets.open(filePath).readAllBytes())
+ }
+
+ fun mapTouchToMouseEvent() {
+ this.evaluateJavascript(touchToMouseHandler, null)
+ }
+
+ fun mapCtrlKey() {
+ this.evaluateJavascript(ctrlKeyHandler, null)
+ }
+
+ fun enableCtrlKey() {
+ this.evaluateJavascript(enableCtrlKey, null)
+ }
+
+ override fun onAccessibilityStateChanged(enabled: Boolean) {
+ Log.d(TAG, "accessibility $enabled")
+ adjustToA11yStateChange()
+ }
+
+ override fun onTouchExplorationStateChanged(enabled: Boolean) {
+ Log.d(TAG, "touch exploration $enabled")
+ adjustToA11yStateChange()
+ }
+
+ private fun adjustToA11yStateChange() {
+ if (!a11yManager.isEnabled) {
+ setFocusable(true)
+ return
+ }
+
+ // When accessibility is on, the webview itself doesn't have to be focusable. The (virtual)
+ // edittext will be focusable to accept inputs. However, the webview has to be focusable for
+ // an accessibility purpose so that users can read the contents in it or scroll the view.
+ setFocusable(false)
+ setFocusableInTouchMode(true)
+ }
+
+ // AccessibilityEvents for WebView are sent directly from WebContentsAccessibilityImpl to the
+ // parent of WebView, without going through WebView. So, there's no WebView methods we can
+ // override to intercept the event handling process. To work around this, we attach an
+ // AccessibilityDelegate to the parent view where the events are sent to. And to guarantee that
+ // the parent view exists, wait until the WebView is attached to the window by when the parent
+ // must exist.
+ private val mA11yEventFilter: AccessibilityDelegate =
+ object : AccessibilityDelegate() {
+ override fun onRequestSendAccessibilityEvent(
+ host: ViewGroup,
+ child: View,
+ e: AccessibilityEvent,
+ ): Boolean {
+ // We filter only the a11y events from the WebView
+ if (child !== this@TerminalView) {
+ return super.onRequestSendAccessibilityEvent(host, child, e)
+ }
+ when (e.eventType) {
+ AccessibilityEvent.TYPE_ANNOUNCEMENT -> {
+ val text = e.text[0] // there always is a text
+ if (text.length >= TEXT_TOO_LONG_TO_ANNOUNCE) {
+ Log.i(TAG, "Announcement skipped because it's too long: $text")
+ return false
+ }
+ }
+ }
+ return super.onRequestSendAccessibilityEvent(host, child, e)
+ }
+ }
+
+ override fun onAttachedToWindow() {
+ super.onAttachedToWindow()
+ if (a11yManager.isEnabled) {
+ val parent = getParent() as View
+ parent.setAccessibilityDelegate(mA11yEventFilter)
+ }
+ }
+
+ private val mA11yNodeProvider: AccessibilityNodeProvider =
+ object : AccessibilityNodeProvider() {
+ /** Returns the original NodeProvider that WebView implements. */
+ private fun getParent(): AccessibilityNodeProvider? {
+ return super@TerminalView.getAccessibilityNodeProvider()
+ }
+
+ /** Convenience method for reading a string resource. */
+ private fun getString(resId: Int): String {
+ return this@TerminalView.context.getResources().getString(resId)
+ }
+
+ /** Checks if NodeInfo renders an empty line in the terminal. */
+ private fun isEmptyLine(info: AccessibilityNodeInfo): Boolean {
+ // Node with no text is not considered a line. ttyd emits at least one character,
+ // which usually is NBSP.
+ // Note: don't use Characters.isWhitespace as it doesn't recognize NBSP as a
+ // whitespace.
+ return (info.getText()?.all { TextUtils.isWhitespace(it.code) }) == true
+ }
+
+ override fun createAccessibilityNodeInfo(id: Int): AccessibilityNodeInfo? {
+ val info: AccessibilityNodeInfo? = getParent()?.createAccessibilityNodeInfo(id)
+ if (info == null) {
+ return null
+ }
+
+ val className = info.className.toString()
+
+ // By default all views except the cursor is not click-able. Other views are
+ // read-only. This ensures that user is not navigated to non-clickable elements
+ // when using switches.
+ if ("android.widget.EditText" != className) {
+ info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK)
+ }
+
+ when (className) {
+ "android.webkit.WebView" -> {
+ // There are two NodeInfo objects of class name WebView. The one is the
+ // real WebView whose ID is View.NO_ID as it's at the root of the
+ // virtual view hierarchy. The second one is a virtual view for the
+ // iframe. The latter one's text is set to the command that we give to
+ // ttyd, which is "login -f droid ...". This is an impl detail which
+ // doesn't have to be announced. Replace the text with "Terminal
+ // display".
+ if (id != NO_ID) {
+ info.setText(null)
+ info.setContentDescription(getString(R.string.terminal_display))
+ // b/376827536
+ info.setHintText(getString(R.string.double_tap_to_edit_text))
+ }
+
+ // These two lines below are to prevent this WebView element from being
+ // focusable by the screen reader, while allowing any other element in
+ // the WebView to be focusable by the reader. In our case, the EditText
+ // is a117_focusable.
+ info.isScreenReaderFocusable = false
+ info.addAction(
+ AccessibilityNodeInfo.AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS
+ )
+ }
+
+ "android.view.View" ->
+ // Empty line was announced as "space" (via the NBSP character).
+ // Localize the spoken text.
+ if (isEmptyLine(info)) {
+ info.setContentDescription(getString(R.string.empty_line))
+ // b/376827536
+ info.setHintText(getString(R.string.double_tap_to_edit_text))
+ }
+
+ "android.widget.TextView" -> {
+ // There are several TextViews in the terminal, and one of them is an
+ // invisible TextView which seems to be from the <div
+ // class="live-region"> tag. Interestingly, its text is often populated
+ // with the entire text on the screen. Silence this by forcibly setting
+ // the text to null. Note that this TextView is identified by having a
+ // zero width. This certainly is not elegant, but I couldn't find other
+ // options.
+ val rect = Rect()
+ info.getBoundsInScreen(rect)
+ if (rect.width() == 0) {
+ info.setText(null)
+ info.setContentDescription(getString(R.string.empty_line))
+ }
+ info.isScreenReaderFocusable = false
+ }
+
+ "android.widget.EditText" -> {
+ // This EditText is for the <textarea> accepting user input; the cursor.
+ // ttyd name it as "Terminal input" but it's not i18n'ed. Override it
+ // here for better i18n.
+ info.setText(null)
+ info.setHintText(getString(R.string.double_tap_to_edit_text))
+ info.setContentDescription(getString(R.string.terminal_input))
+ info.isScreenReaderFocusable = true
+ info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_FOCUS)
+ }
+ }
+ return info
+ }
+
+ override fun performAction(id: Int, action: Int, arguments: Bundle?): Boolean {
+ return getParent()?.performAction(id, action, arguments) == true
+ }
+
+ override fun addExtraDataToAccessibilityNodeInfo(
+ virtualViewId: Int,
+ info: AccessibilityNodeInfo?,
+ extraDataKey: String?,
+ arguments: Bundle?,
+ ) {
+ getParent()
+ ?.addExtraDataToAccessibilityNodeInfo(
+ virtualViewId,
+ info,
+ extraDataKey,
+ arguments,
+ )
+ }
+
+ override fun findAccessibilityNodeInfosByText(
+ text: String?,
+ virtualViewId: Int,
+ ): MutableList<AccessibilityNodeInfo?>? {
+ return getParent()?.findAccessibilityNodeInfosByText(text, virtualViewId)
+ }
+
+ override fun findFocus(focus: Int): AccessibilityNodeInfo? {
+ return getParent()?.findFocus(focus)
+ }
+ }
+
+ override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider? {
+ val p = super.getAccessibilityNodeProvider()
+ if (p != null && a11yManager.isEnabled) {
+ return mA11yNodeProvider
+ }
+ return p
+ }
+
+ override fun onCreateInputConnection(outAttrs: EditorInfo?): InputConnection? {
+ val inputConnection = super.onCreateInputConnection(outAttrs)
+ if (outAttrs != null) {
+ outAttrs.inputType = outAttrs.inputType or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+ }
+ return inputConnection
+ }
+
+ companion object {
+ // Maximum length of texts the talk back announcements can be. This value is somewhat
+ // arbitrarily set. We may want to adjust this in the future.
+ private const val TEXT_TOO_LONG_TO_ANNOUNCE = 200
+ }
+}