Skip to content

Commit 18a3d45

Browse files
Support TV web apps in WebViews
Background: Within <xxx role=application>...</xxx>, screen readers should not consume DPAD (arrow key) events. Web apps or widgets with role=application have, per the WAI-ARIA spec's contract, their own JavaScript logic for moving focus [1]. [1] w3c/aria#1049, where we discussed this. Problem: TalkBack does not handle role=application so such web apps lose their 4-way (up/down/left/right) navigation. TalkBack only moves foward/backward which breaks authors' pre-defined TV UX. Solution: Whenever accessibility focus (the green rect) goes to a WebView with <body role=application> or anywhere within a role=application widget, we don't consume the DPAD events; we let them through. Testing done: From TalkBack's test app, open dpad_a11y.html which has <body role=application>. Notice: I. Once the WebView gets accessibilty focus, TalkBack won't consume DPAD key events. II. The key events reach the web page's key handlers in JavaScript. III: TalkBack describes HTML elements once they get focused.
1 parent fa67c12 commit 18a3d45

File tree

6 files changed

+217
-0
lines changed

6 files changed

+217
-0
lines changed

talkback/src/main/java/controller/TelevisionNavigationController.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,15 @@ public boolean processWhenServiceSuspended() {
343343
*/
344344
private boolean shouldIgnore(AccessibilityNodeInfoCompat node, KeyEvent event) {
345345
final int keyCode = event.getKeyCode();
346+
if (AccessibilityNodeInfoUtils.isWebApplication(node)) {
347+
// Web applications and web widgets with role=application have, per the
348+
// WAI-ARIA spec's contract, their own JavaScript logic for moving focus.
349+
// TalkBack should not consume key events when such an app has accessibility focus.
350+
// Debug tip: Forward DPAD events whenever the accessibility cursor is on,
351+
// or inside, a WebView: if (WebInterfaceUtils.supportsWebActions(node)) return true;
352+
return true;
353+
}
354+
346355
if (!mShouldProcessDPadKeyEvent
347356
&& (keyCode == KeyEvent.KEYCODE_DPAD_UP
348357
|| keyCode == KeyEvent.KEYCODE_DPAD_DOWN
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>Example TV web app</title>
5+
<style>
6+
div.outer {display: grid; grid-template-columns: 150px 150px;}
7+
div.inner {background: teal; margin: 20px; color: white; font-size: 30px; font-family: Sans-Serif; display: flex; align-items: center; justify-content: center;}
8+
div:focus {background: purple;}
9+
</style>
10+
</head>
11+
12+
<body role="application">
13+
<p tabindex="0">This web app allows navigation in 4 directions. Use your arrow keys (DPAD_UP / DPAD_DOWN / DPAD_LEFT / DPAD_RIGHT).</p>
14+
<p tabindex="0"><em>role=application</em> makes TalkBack forward DPAD events to Chromium.</p>
15+
<div role="grid">
16+
<div class="outer" role="row">
17+
<div role="gridcell" class="inner" tabindex="0" id="a" aria-label="Astrid">A</div><div role="gridcell" class="inner" tabindex="0" id="c" aria-label="Caroline">C</div>
18+
</div>
19+
<div class="outer" role="row">
20+
<div role="gridcell" class="inner" tabindex="0" id="b" aria-label="Bernie">B</div><div role="gridcell" class="inner" tabindex="0" id="d" aria-label="David">D</div>
21+
</div>
22+
</div>
23+
24+
<input type="radio" name="mechanics" id="usecustomjs" checked> <label for="usecustomjs">JavaScript moves web focus.</label>
25+
<input type="radio" name="mechanics" id="usechromium"> <label for="usechromium">Chromium's built-in key handling moves web focus.</label>
26+
</body>
27+
28+
<script>
29+
// These key handlers use preventDefault to stop Chromium's
30+
// built-in focus mechanics, called "Spatial Navigation", which
31+
// is enabled by default in Android WebViews.
32+
function moveFromA(event) {
33+
switch (event.code) {
34+
case 'ArrowUp':
35+
/* go nowhere */ event.preventDefault(); break;
36+
case 'ArrowLeft':
37+
case 'ArrowRight':
38+
c.focus(); event.preventDefault(); break;
39+
case 'ArrowDown':
40+
b.focus(); event.preventDefault(); break;
41+
}
42+
}
43+
44+
function moveFromC(event) {
45+
switch (event.code) {
46+
case 'ArrowUp':
47+
/* go nowhere */ event.preventDefault(); break;
48+
case 'ArrowLeft':
49+
case 'ArrowRight':
50+
a.focus(); event.preventDefault(); break;
51+
case 'ArrowDown':
52+
d.focus(); event.preventDefault(); break;
53+
}
54+
}
55+
56+
function moveFromB(event) {
57+
switch (event.code) {
58+
case 'ArrowUp':
59+
a.focus(); event.preventDefault(); break;
60+
case 'ArrowLeft':
61+
case 'ArrowRight':
62+
d.focus(); event.preventDefault(); break;
63+
case 'ArrowDown':
64+
usecustomjs.focus(); event.preventDefault(); break;
65+
}
66+
}
67+
68+
function moveFromD(event) {
69+
switch (event.code) {
70+
case 'ArrowUp':
71+
c.focus(); event.preventDefault(); break;
72+
case 'ArrowLeft':
73+
case 'ArrowRight':
74+
b.focus(); event.preventDefault(); break;
75+
case 'ArrowDown':
76+
usechromium.focus(); event.preventDefault(); break;
77+
}
78+
}
79+
80+
// Mimic a typical TV app.
81+
function enableCustomDpadNavigation() {
82+
a.addEventListener('keydown', moveFromA);
83+
b.addEventListener('keydown', moveFromB);
84+
c.addEventListener('keydown', moveFromC);
85+
d.addEventListener('keydown', moveFromD);
86+
}
87+
88+
function disableCustomDpadNavigation() {
89+
a.removeEventListener('keydown', moveFromA);
90+
b.removeEventListener('keydown', moveFromB);
91+
c.removeEventListener('keydown', moveFromC);
92+
d.removeEventListener('keydown', moveFromD);
93+
}
94+
95+
function toggleCustomDpad() {
96+
if (usecustomjs.checked)
97+
enableCustomDpadNavigation();
98+
else
99+
disableCustomDpadNavigation();
100+
}
101+
usecustomjs.addEventListener('change', toggleCustomDpad);
102+
usechromium.addEventListener('change', toggleCustomDpad);
103+
104+
enableCustomDpadNavigation();
105+
a.focus();
106+
</script>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* Copyright (C) 2019 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License"); you may not
5+
* use this file except in compliance with the License. You may obtain a copy of
6+
* 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, WITHOUT
12+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13+
* License for the specific language governing permissions and limitations under
14+
* the License.
15+
*/
16+
17+
package com.android.talkbacktests.testsession;
18+
19+
import android.content.Context;
20+
import android.view.KeyEvent;
21+
import android.view.LayoutInflater;
22+
import android.view.View;
23+
import android.view.ViewGroup;
24+
import android.webkit.WebView;
25+
26+
import com.android.talkbacktests.R;
27+
28+
public class WebViewDPADTest extends BaseTestContent {
29+
30+
public WebViewDPADTest(Context context, String subtitle, String description) {
31+
super(context, subtitle, description);
32+
}
33+
34+
@Override
35+
public View getView(final LayoutInflater inflater, ViewGroup container, Context context) {
36+
final View view = inflater.inflate(R.layout.test_web_view, container, false);
37+
final WebView webView = (WebView) view.findViewById(R.id.webview);
38+
webView.getSettings().setJavaScriptEnabled(true);
39+
webView.loadUrl("file:///android_asset/dpad_a11y.html");
40+
webView.setOnKeyListener(new View.OnKeyListener() {
41+
@Override
42+
public boolean onKey(View v, int keyCode, KeyEvent event) {
43+
if (keyCode == KeyEvent.KEYCODE_BACK) { // Exit the WebView
44+
View parent = (View) v.getParent().getParent().getParent();
45+
View nextButton = parent.findViewById(R.id.next);
46+
// Move Android focus to the native button.
47+
return nextButton != null && nextButton.requestFocus();
48+
}
49+
return false;
50+
}
51+
});
52+
return view;
53+
}
54+
}

testapp/app/src/main/res/raw/test.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,17 @@
153153
}
154154
]
155155
},
156+
{
157+
"title": "@string/dpad_web_view_session_title",
158+
"description": "@string/dpad_web_view_session_description",
159+
"content": [
160+
{
161+
"subtitle": "@string/dpad_web_view_test_subtitle",
162+
"description": "@string/dpad_web_view_test_description",
163+
"classname": "com.android.talkbacktests.testsession.WebViewDPADTest"
164+
}
165+
]
166+
},
156167
{
157168
"title": "@string/single_line_edit_Field_session_title",
158169
"description": "@string/single_line_edit_Field_session_description",

testapp/app/src/main/res/values/donottranslate_title_and_description.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@
6363
<string name="web_view_session_title">WebView</string>
6464
<string name="web_view_session_description">View with web content</string>
6565

66+
<string name="dpad_web_view_session_title">WebView TV app</string>
67+
<string name="dpad_web_view_session_description">A TV app where the DPAD moves web focus in four directions.</string>
68+
6669
<string name="single_line_edit_Field_session_title">Single Line Edit Field</string>
6770
<string name="single_line_edit_Field_session_description">Single-line editable text field</string>
6871

@@ -171,6 +174,9 @@
171174
<string name="web_view_test_subtitle">View with Web Content</string>
172175
<string name="web_view_test_description">With TalkBack: swipe between web elements, double-tap to activate, swipe up and down to select element type. Note: requires Chrome 50 or above to work.</string>
173176

177+
<string name="dpad_web_view_test_subtitle">WebView TV app</string>
178+
<string name="dpad_web_view_test_description">With TalkBack on Android TV: use the directional keys, the DPAD, to move between the four grid items. This test covers TV web apps that implement custom DPAD navigation, for example, TV apps that display grids of video clips.</string>
179+
174180
<string name="web_view_in_scrollable_view_test_subtitle">WebView Inside ScrollView</string>
175181
<string name="web_view_in_scrollable_view_test_description">With TalkBack: swipe between web elements until the view scrolls, double-tap to activate. Note: requires Chrome 50 or above to work.</string>
176182

utils/src/main/java/AccessibilityNodeInfoUtils.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,37 @@ public boolean accept(AccessibilityNodeInfoCompat node) {
148148
}
149149
};
150150

151+
public static boolean hasApplicationWebRole(AccessibilityNodeInfoCompat node) {
152+
return node != null && node.getExtras() != null
153+
&& node.getExtras().containsKey("AccessibilityNodeInfo.chromeRole")
154+
&& node.getExtras().get("AccessibilityNodeInfo.chromeRole").equals("application");
155+
}
156+
157+
private static final Filter<AccessibilityNodeInfoCompat> FILTER_IN_WEB_APPLICATION =
158+
new Filter<AccessibilityNodeInfoCompat>() {
159+
@Override
160+
public boolean accept(AccessibilityNodeInfoCompat node) {
161+
return hasApplicationWebRole(node);
162+
}
163+
};
164+
165+
/**
166+
* Returns true if |node| has role=application, i.e. |node| has JavaScript
167+
* that handles key events.
168+
*/
169+
public static boolean isWebApplication(AccessibilityNodeInfoCompat node) {
170+
// When a WebView has focus: Check Chromium's accessibility tree's first node.
171+
// If that node wants raw key event, instead of first "tabbing" the green
172+
// rect to it, skip ahead and let the web app directly decide where to go.
173+
boolean firstChromiumNodeWantsKeyEvents =
174+
Role.getRole(node) == Role.ROLE_WEB_VIEW
175+
&& node.getChildCount() > 0
176+
&& hasApplicationWebRole(node.getChild(0));
177+
178+
return firstChromiumNodeWantsKeyEvents
179+
|| AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(node, FILTER_IN_WEB_APPLICATION) != null;
180+
}
181+
151182
private AccessibilityNodeInfoUtils() {
152183
// This class is not instantiable.
153184
}

0 commit comments

Comments
 (0)