Merge "Fix many accessibility issues." into main
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
index da6a6ed..8448349 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/MainActivity.java
@@ -41,6 +41,7 @@
import android.view.MenuItem;
import android.view.View;
import android.view.accessibility.AccessibilityManager;
+import android.view.accessibility.AccessibilityManager.AccessibilityStateChangeListener;
import android.webkit.ClientCertRequest;
import android.webkit.SslErrorHandler;
import android.webkit.WebChromeClient;
@@ -71,10 +72,8 @@
import java.security.cert.X509Certificate;
public class MainActivity extends BaseActivity
- implements VmLauncherServices.VmLauncherServiceCallback,
- AccessibilityManager.TouchExplorationStateChangeListener {
-
- private static final String TAG = "VmTerminalApp";
+ implements VmLauncherServices.VmLauncherServiceCallback, AccessibilityStateChangeListener {
+ static final String TAG = "VmTerminalApp";
private static final String VM_ADDR = "192.168.0.2";
private static final int TTYD_PORT = 7681;
private static final int REQUEST_CODE_INSTALLER = 0x33;
@@ -118,7 +117,7 @@
mWebView.setWebChromeClient(new WebChromeClient());
mAccessibilityManager = getSystemService(AccessibilityManager.class);
- mAccessibilityManager.addTouchExplorationStateChangeListener(this);
+ mAccessibilityManager.addAccessibilityStateChangeListener(this);
readClientCertificate();
connectToTerminalService();
@@ -170,7 +169,7 @@
+ "&fontWeightBold="
+ (FontStyle.FONT_WEIGHT_BOLD + config.fontWeightAdjustment)
+ "&screenReaderMode="
- + mAccessibilityManager.isTouchExplorationEnabled()
+ + mAccessibilityManager.isEnabled()
+ "&titleFixed="
+ getString(R.string.app_name);
@@ -366,7 +365,7 @@
@Override
protected void onDestroy() {
- getSystemService(AccessibilityManager.class).removeTouchExplorationStateChangeListener(this);
+ getSystemService(AccessibilityManager.class).removeAccessibilityStateChangeListener(this);
VmLauncherServices.stopVmLauncherService(this);
super.onDestroy();
}
@@ -411,7 +410,7 @@
}
@Override
- public void onTouchExplorationStateChanged(boolean enabled) {
+ public void onAccessibilityStateChanged(boolean enabled) {
connectToTerminalService();
}
diff --git a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
index d40ae8f..aabcae7 100644
--- a/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
+++ b/android/TerminalApp/java/com/android/virtualization/terminal/TerminalView.java
@@ -15,12 +15,250 @@
*/
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.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.webkit.WebView;
-public class TerminalView extends WebView {
+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 AccessibilityManager mA11yManager;
+
public TerminalView(Context context, AttributeSet attrs) {
super(context, attrs);
+
+ mA11yManager = context.getSystemService(AccessibilityManager.class);
+ mA11yManager.addTouchExplorationStateChangeListener(this);
+ mA11yManager.addAccessibilityStateChangeListener(this);
+ adjustToA11yStateChange();
+ }
+
+ @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));
+ }
+
+ // 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));
+ }
+ 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.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(null);
+ 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;
}
}
diff --git a/android/TerminalApp/res/values/strings.xml b/android/TerminalApp/res/values/strings.xml
index d498286..68551a0 100644
--- a/android/TerminalApp/res/values/strings.xml
+++ b/android/TerminalApp/res/values/strings.xml
@@ -20,6 +20,13 @@
<!-- Application name of this terminal app shown in the launcher. This app provides computer terminal to connect to virtual machine. [CHAR LIMIT=16] -->
<string name="app_name">Terminal</string>
+ <!-- Description of the entire terminal display showing texts. This is read by talkback. -->
+ <string name="terminal_display">Terminal display</string>
+ <!-- Description of the edit box accepting user input. This is read by talkback. -->
+ <string name="terminal_input">Cursor</string>
+ <!-- Description of an empty line in the terminal. This is read by talkback. -->
+ <string name="empty_line">Empty line</string>
+
<!-- Installer activity title [CHAR LIMIT=none] -->
<string name="installer_title_text">Install Linux terminal</string>
<!-- Installer activity description format [CHAR LIMIT=none] -->