• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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