1 /* 2 * Copyright (C) 2024 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.virtualization.terminal 17 18 import android.content.Context 19 import android.graphics.Rect 20 import android.os.Bundle 21 import android.text.InputType 22 import android.text.TextUtils 23 import android.util.AttributeSet 24 import android.util.Log 25 import android.view.View 26 import android.view.ViewGroup 27 import android.view.accessibility.AccessibilityEvent 28 import android.view.accessibility.AccessibilityManager 29 import android.view.accessibility.AccessibilityNodeInfo 30 import android.view.accessibility.AccessibilityNodeProvider 31 import android.view.inputmethod.EditorInfo 32 import android.view.inputmethod.InputConnection 33 import android.webkit.WebView 34 import com.android.virtualization.terminal.MainActivity.Companion.TAG 35 import java.io.IOException 36 37 class TerminalView(context: Context, attrs: AttributeSet?) : 38 WebView(context, attrs), 39 AccessibilityManager.AccessibilityStateChangeListener, 40 AccessibilityManager.TouchExplorationStateChangeListener { 41 private val ctrlKeyHandler: String = readAssetAsString(context, "js/ctrl_key_handler.js") 42 private val enableCtrlKey: String = readAssetAsString(context, "js/enable_ctrl_key.js") 43 private val disableCtrlKey: String = readAssetAsString(context, "js/disable_ctrl_key.js") 44 private val terminalDisconnectCallback: String = 45 readAssetAsString(context, "js/terminal_disconnect.js") 46 private val terminalClose: String = readAssetAsString(context, "js/terminal_close.js") 47 private val touchToMouseHandler: String = 48 readAssetAsString(context, "js/touch_to_mouse_handler.js") 49 private val a11yManager = <lambda>null50 context.getSystemService<AccessibilityManager>(AccessibilityManager::class.java).also { 51 it.addTouchExplorationStateChangeListener(this) 52 it.addAccessibilityStateChangeListener(this) 53 } 54 55 @Throws(IOException::class) readAssetAsStringnull56 private fun readAssetAsString(context: Context, filePath: String): String { 57 return String(context.assets.open(filePath).readAllBytes()) 58 } 59 mapTouchToMouseEventnull60 fun mapTouchToMouseEvent() { 61 this.evaluateJavascript(touchToMouseHandler, null) 62 } 63 mapCtrlKeynull64 fun mapCtrlKey() { 65 this.evaluateJavascript(ctrlKeyHandler, null) 66 } 67 enableCtrlKeynull68 fun enableCtrlKey() { 69 this.evaluateJavascript(enableCtrlKey, null) 70 } 71 disableCtrlKeynull72 fun disableCtrlKey() { 73 this.evaluateJavascript(disableCtrlKey, null) 74 } 75 applyTerminalDisconnectCallbacknull76 fun applyTerminalDisconnectCallback() { 77 this.evaluateJavascript(terminalDisconnectCallback, null) 78 } 79 terminalClosenull80 fun terminalClose() { 81 this.evaluateJavascript(terminalClose, null) 82 } 83 onAccessibilityStateChangednull84 override fun onAccessibilityStateChanged(enabled: Boolean) { 85 Log.d(TAG, "accessibility $enabled") 86 adjustToA11yStateChange() 87 } 88 onTouchExplorationStateChangednull89 override fun onTouchExplorationStateChanged(enabled: Boolean) { 90 Log.d(TAG, "touch exploration $enabled") 91 adjustToA11yStateChange() 92 } 93 adjustToA11yStateChangenull94 private fun adjustToA11yStateChange() { 95 if (!a11yManager.isEnabled) { 96 setFocusable(true) 97 return 98 } 99 100 // When accessibility is on, the webview itself doesn't have to be focusable. The (virtual) 101 // edittext will be focusable to accept inputs. However, the webview has to be focusable for 102 // an accessibility purpose so that users can read the contents in it or scroll the view. 103 setFocusable(false) 104 setFocusableInTouchMode(true) 105 } 106 107 // AccessibilityEvents for WebView are sent directly from WebContentsAccessibilityImpl to the 108 // parent of WebView, without going through WebView. So, there's no WebView methods we can 109 // override to intercept the event handling process. To work around this, we attach an 110 // AccessibilityDelegate to the parent view where the events are sent to. And to guarantee that 111 // the parent view exists, wait until the WebView is attached to the window by when the parent 112 // must exist. 113 private val a11yEventFilter: AccessibilityDelegate = 114 object : AccessibilityDelegate() { onRequestSendAccessibilityEventnull115 override fun onRequestSendAccessibilityEvent( 116 host: ViewGroup, 117 child: View, 118 e: AccessibilityEvent, 119 ): Boolean { 120 // We filter only the a11y events from the WebView 121 if (child !== this@TerminalView) { 122 return super.onRequestSendAccessibilityEvent(host, child, e) 123 } 124 when (e.eventType) { 125 AccessibilityEvent.TYPE_ANNOUNCEMENT -> { 126 val text = e.text[0] // there always is a text 127 if (text.length >= TEXT_TOO_LONG_TO_ANNOUNCE) { 128 Log.i(TAG, "Announcement skipped because it's too long: $text") 129 return false 130 } 131 } 132 } 133 return super.onRequestSendAccessibilityEvent(host, child, e) 134 } 135 } 136 onAttachedToWindownull137 override fun onAttachedToWindow() { 138 super.onAttachedToWindow() 139 if (a11yManager.isEnabled) { 140 val parent = getParent() as View 141 parent.setAccessibilityDelegate(a11yEventFilter) 142 } 143 } 144 145 private val a11yNodeProvider: AccessibilityNodeProvider = 146 object : AccessibilityNodeProvider() { 147 /** Returns the original NodeProvider that WebView implements. */ getParentnull148 private fun getParent(): AccessibilityNodeProvider? { 149 return super@TerminalView.getAccessibilityNodeProvider() 150 } 151 152 /** Convenience method for reading a string resource. */ getStringnull153 private fun getString(resId: Int): String { 154 return this@TerminalView.context.getResources().getString(resId) 155 } 156 157 /** Checks if NodeInfo renders an empty line in the terminal. */ isEmptyLinenull158 private fun isEmptyLine(info: AccessibilityNodeInfo): Boolean { 159 // Node with no text is not considered a line. ttyd emits at least one character, 160 // which usually is NBSP. 161 // Note: don't use Characters.isWhitespace as it doesn't recognize NBSP as a 162 // whitespace. 163 return (info.getText()?.all { TextUtils.isWhitespace(it.code) }) == true 164 } 165 createAccessibilityNodeInfonull166 override fun createAccessibilityNodeInfo(id: Int): AccessibilityNodeInfo? { 167 val info: AccessibilityNodeInfo? = getParent()?.createAccessibilityNodeInfo(id) 168 if (info == null) { 169 return null 170 } 171 172 val className = info.className.toString() 173 174 // By default all views except the cursor is not click-able. Other views are 175 // read-only. This ensures that user is not navigated to non-clickable elements 176 // when using switches. 177 if ("android.widget.EditText" != className) { 178 info.removeAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_CLICK) 179 } 180 181 when (className) { 182 "android.webkit.WebView" -> { 183 // There are two NodeInfo objects of class name WebView. The one is the 184 // real WebView whose ID is View.NO_ID as it's at the root of the 185 // virtual view hierarchy. The second one is a virtual view for the 186 // iframe. The latter one's text is set to the command that we give to 187 // ttyd, which is "login -f droid ...". This is an impl detail which 188 // doesn't have to be announced. Replace the text with "Terminal 189 // display". 190 if (id != NO_ID) { 191 info.setText(null) 192 info.setContentDescription(getString(R.string.terminal_display)) 193 // b/376827536 194 info.setHintText(getString(R.string.double_tap_to_edit_text)) 195 } 196 197 // These two lines below are to prevent this WebView element from being 198 // focusable by the screen reader, while allowing any other element in 199 // the WebView to be focusable by the reader. In our case, the EditText 200 // is a117_focusable. 201 info.isScreenReaderFocusable = false 202 info.addAction( 203 AccessibilityNodeInfo.AccessibilityAction.ACTION_ACCESSIBILITY_FOCUS 204 ) 205 } 206 207 "android.view.View" -> 208 // Empty line was announced as "space" (via the NBSP character). 209 // Localize the spoken text. 210 if (isEmptyLine(info)) { 211 info.setContentDescription(getString(R.string.empty_line)) 212 // b/376827536 213 info.setHintText(getString(R.string.double_tap_to_edit_text)) 214 } 215 216 "android.widget.TextView" -> { 217 // There are several TextViews in the terminal, and one of them is an 218 // invisible TextView which seems to be from the <div 219 // class="live-region"> tag. Interestingly, its text is often populated 220 // with the entire text on the screen. Silence this by forcibly setting 221 // the text to null. Note that this TextView is identified by having a 222 // zero width. This certainly is not elegant, but I couldn't find other 223 // options. 224 val rect = Rect() 225 info.getBoundsInScreen(rect) 226 if (rect.width() == 0) { 227 info.setText(null) 228 info.setContentDescription(getString(R.string.empty_line)) 229 } 230 info.isScreenReaderFocusable = false 231 } 232 233 "android.widget.EditText" -> { 234 // This EditText is for the <textarea> accepting user input; the cursor. 235 // ttyd name it as "Terminal input" but it's not i18n'ed. Override it 236 // here for better i18n. 237 info.setText(null) 238 info.setHintText(getString(R.string.double_tap_to_edit_text)) 239 info.setContentDescription(getString(R.string.terminal_input)) 240 info.isScreenReaderFocusable = true 241 info.addAction(AccessibilityNodeInfo.AccessibilityAction.ACTION_FOCUS) 242 } 243 } 244 return info 245 } 246 performActionnull247 override fun performAction(id: Int, action: Int, arguments: Bundle?): Boolean { 248 return getParent()?.performAction(id, action, arguments) == true 249 } 250 addExtraDataToAccessibilityNodeInfonull251 override fun addExtraDataToAccessibilityNodeInfo( 252 virtualViewId: Int, 253 info: AccessibilityNodeInfo?, 254 extraDataKey: String?, 255 arguments: Bundle?, 256 ) { 257 getParent() 258 ?.addExtraDataToAccessibilityNodeInfo( 259 virtualViewId, 260 info, 261 extraDataKey, 262 arguments, 263 ) 264 } 265 findAccessibilityNodeInfosByTextnull266 override fun findAccessibilityNodeInfosByText( 267 text: String?, 268 virtualViewId: Int, 269 ): MutableList<AccessibilityNodeInfo?>? { 270 return getParent()?.findAccessibilityNodeInfosByText(text, virtualViewId) 271 } 272 findFocusnull273 override fun findFocus(focus: Int): AccessibilityNodeInfo? { 274 return getParent()?.findFocus(focus) 275 } 276 } 277 getAccessibilityNodeProvidernull278 override fun getAccessibilityNodeProvider(): AccessibilityNodeProvider? { 279 val p = super.getAccessibilityNodeProvider() 280 if (p != null && a11yManager.isEnabled) { 281 return a11yNodeProvider 282 } 283 return p 284 } 285 onCreateInputConnectionnull286 override fun onCreateInputConnection(outAttrs: EditorInfo?): InputConnection? { 287 val inputConnection = super.onCreateInputConnection(outAttrs) 288 if (outAttrs != null) { 289 outAttrs.inputType = 290 InputType.TYPE_CLASS_TEXT or 291 InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS or 292 InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD 293 outAttrs.imeOptions = EditorInfo.IME_FLAG_FORCE_ASCII 294 } 295 return inputConnection 296 } 297 298 companion object { 299 // Maximum length of texts the talk back announcements can be. This value is somewhat 300 // arbitrarily set. We may want to adjust this in the future. 301 private const val TEXT_TOO_LONG_TO_ANNOUNCE = 200 302 } 303 } 304