package com.android.talkback.controller; import android.app.UiModeManager; import android.content.Context; import android.content.res.Configuration; import android.os.Build; import android.os.Message; import android.support.annotation.IntDef; import android.support.v4.os.BuildCompat; import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; import android.text.SpannableStringBuilder; import android.view.KeyEvent; import android.widget.AdapterView; import com.android.talkback.InputModeManager; import com.android.talkback.R; import com.android.talkback.SpeechController; import com.android.utils.AccessibilityNodeInfoUtils; import com.android.utils.ClassLoadingCache; import com.android.utils.NodeFilter; import com.android.utils.PerformActionUtils; import com.android.utils.Role; import com.android.utils.StringBuilderUtils; import com.android.utils.WeakReferenceHandler; import com.android.utils.WindowManager; import com.google.android.marvin.talkback.TalkBackService; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** * Implements directional-pad navigation specific to Android TV devices. */ public class TelevisionNavigationController implements TalkBackService.KeyEventListener { public static final int MIN_API_LEVEL = Build.VERSION_CODES.M; private static final NodeFilter FILTER_FOCUSED = new NodeFilter() { @Override public boolean accept(AccessibilityNodeInfoCompat node) { return node.isFocused(); } }; /** Filter for nodes to pass through D-pad up/down on Android M or earlier. */ private static final NodeFilter IGNORE_UP_DOWN_M = new NodeFilter() { @Override public boolean accept(AccessibilityNodeInfoCompat node) { // BUG; the ScrollAdapterView intercepts D-pad events on M; pass through // the up and down events so that the user can scroll these lists. // "com.android.tv.settings" - Android TV Settings app and SetupWraith (setup wizard) // "com.google.android.gsf.notouch" - Google Account setup in SetupWraith return ("com.android.tv.settings".equals(node.getPackageName()) || "com.google.android.gsf.notouch".equals(node.getPackageName())) && ClassLoadingCache.checkInstanceOf(node.getClassName(), AdapterView.class); } }; @IntDef({MODE_NAVIGATE, MODE_SEEK_CONTROL}) @Retention(RetentionPolicy.SOURCE) private @interface RemoteMode {} // The four arrow buttons move the focus and the select button performs a click. private static final int MODE_NAVIGATE = 0; // The four arrow buttons move the seek control and the select button exits seek control mode. private static final int MODE_SEEK_CONTROL = 1; private final TalkBackService mService; private final CursorController mCursorController; private boolean mPressedCenter = false; private @RemoteMode int mMode = MODE_NAVIGATE; private TelevisionKeyHandler mHandler = new TelevisionKeyHandler(this); public TelevisionNavigationController(TalkBackService service) { mService = service; mCursorController = mService.getCursorController(); } @Override public boolean onKeyEvent(KeyEvent event) { mService.getInputModeManager().setInputMode(InputModeManager.INPUT_MODE_TV_REMOTE); WindowManager windowManager = new WindowManager(mService.isScreenLayoutRTL()); windowManager.setWindows(mService.getWindows()); // Let the system handle keyboards. The keys are input-focusable so this works fine. if (windowManager.isInputWindowOnScreen()) { return false; } // Note: we getCursorOrInputCursor because we want to avoid getting the root if there // is no cursor; getCursor is defined as getting the root if there is no a11y focus. AccessibilityNodeInfoCompat cursor = mCursorController.getCursorOrInputCursor(); try { if (shouldIgnore(cursor, event)) { return false; } // TalkBack should always consume up/down/left/right on the d-pad. Otherwise, strange // things will happen when TalkBack cannot navigate further. // For example, TalkBack cannot control the gray-highlighted item in a ListView; the // view itself controls the highlighted item. So if the key event gets propagated to the // list view at the end of the list, the scrolling will jump to the highlighted item. if (event.getAction() == KeyEvent.ACTION_DOWN) { switch (event.getKeyCode()) { case KeyEvent.KEYCODE_DPAD_LEFT: case KeyEvent.KEYCODE_DPAD_RIGHT: case KeyEvent.KEYCODE_DPAD_UP: case KeyEvent.KEYCODE_DPAD_DOWN: // Directional navigation takes a non-trivial amount of time, so we should // post to the handler and return true immediately. mHandler.postDirectionalKeyEvent(event, cursor); return true; case KeyEvent.KEYCODE_DPAD_CENTER: // Can't post to handler because the return value might vary. mPressedCenter = onCenterKey(cursor); return mPressedCenter; } } else { // We need to cancel the corresponding up key action if we consumed the down action. switch (event.getKeyCode()) { case KeyEvent.KEYCODE_DPAD_LEFT: case KeyEvent.KEYCODE_DPAD_RIGHT: case KeyEvent.KEYCODE_DPAD_UP: case KeyEvent.KEYCODE_DPAD_DOWN: return true; case KeyEvent.KEYCODE_DPAD_CENTER: if (mPressedCenter) { mPressedCenter = false; return true; } } } } finally { AccessibilityNodeInfoUtils.recycleNodes(cursor); } return false; } private void onDirectionalKey(int keyCode, AccessibilityNodeInfoCompat cursor) { switch (mMode) { case MODE_NAVIGATE: { switch (keyCode) { case KeyEvent.KEYCODE_DPAD_LEFT: mCursorController.left( false /* wrap around */, true /* auto scroll */, true /* use input focus */, InputModeManager.INPUT_MODE_KEYBOARD); break; case KeyEvent.KEYCODE_DPAD_RIGHT: mCursorController.right( false /* wrap around */, true /* auto scroll */, true /* use input focus */, InputModeManager.INPUT_MODE_KEYBOARD); break; case KeyEvent.KEYCODE_DPAD_UP: mCursorController.up( false /* wrap around */, true /* auto scroll */, true /* use input focus */, InputModeManager.INPUT_MODE_KEYBOARD); break; case KeyEvent.KEYCODE_DPAD_DOWN: mCursorController.down( false /* wrap around */, true /* auto scroll */, true /* use input focus */, InputModeManager.INPUT_MODE_KEYBOARD); break; } } break; case MODE_SEEK_CONTROL: { if (Role.getRole(cursor) != Role.ROLE_SEEK_CONTROL) { setMode(MODE_NAVIGATE); } else { boolean isRtl = mService.isScreenLayoutRTL(); switch (keyCode) { case KeyEvent.KEYCODE_DPAD_UP: PerformActionUtils.performAction(cursor, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD); break; case KeyEvent.KEYCODE_DPAD_RIGHT: PerformActionUtils.performAction(cursor, isRtl ? AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD : AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD); break; case KeyEvent.KEYCODE_DPAD_DOWN: PerformActionUtils.performAction(cursor, AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD); break; case KeyEvent.KEYCODE_DPAD_LEFT: PerformActionUtils.performAction(cursor, isRtl ? AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD : AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD); break; } } } break; } } /** Returns true if TalkBack should consume the center key; otherwise returns false. */ private boolean onCenterKey(AccessibilityNodeInfoCompat cursor) { switch (mMode) { case MODE_NAVIGATE: { if (Role.getRole(cursor) == Role.ROLE_SEEK_CONTROL) { // Seek control, center key toggles seek control input mode instead of clicking. setMode(MODE_SEEK_CONTROL); return true; } else if (mCursorController.clickCurrentHierarchical()) { // We were able to find a clickable node in the hierarchy. return true; } else if (cursor == null || AccessibilityNodeInfoUtils .isOrHasMatchingAncestor(cursor, FILTER_FOCUSED)) { // Note: this branch is mainly for compatibility reasons. // The node with input focus is cursor or ancestor, so it's safe to pass the // d-pad key event through. // TODO: Re-evaluate whether this check is needed for N. return false; } else { // In all other cases, we must consume the key event instead of passing. return true; } } case MODE_SEEK_CONTROL: { setMode(MODE_NAVIGATE); return true; } } return false; } @Override public boolean processWhenServiceSuspended() { return false; } /** * Between TalkBack 4.4 and 4.3, the fundamental interaction model for TalkBack on Android TV * changed. On 4.3, TalkBack relies on the system to move the input focus, which meant that * only input-focusable objects could be selected. On 4.4, TalkBack does its own navigation, * which means that all accessibility-focusable objects could be selected, but some objects * that did not respond to the appropriate accessibility events started breaking. * * To mitigate this, we will (very conservatively) pass through the D-pad key events for * certain views, effectively restoring the TB 4.3 behavior for these views. */ private boolean shouldIgnore(AccessibilityNodeInfoCompat node, KeyEvent event) { if (!BuildCompat.isAtLeastN()) { int keyCode = event.getKeyCode(); if (keyCode == KeyEvent.KEYCODE_DPAD_UP || keyCode == KeyEvent.KEYCODE_DPAD_DOWN) { return AccessibilityNodeInfoUtils.isOrHasMatchingAncestor(node, IGNORE_UP_DOWN_M); } } return false; } public static boolean isContextTelevision(Context context) { if (context == null) { return false; } UiModeManager modeManager = (UiModeManager) context.getSystemService(Context.UI_MODE_SERVICE); return modeManager != null && modeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; } private void setMode(@RemoteMode int newMode) { if (newMode == mMode) { return; } int template; boolean hint; @RemoteMode int modeForFeedback; if (newMode == MODE_NAVIGATE) { // "XYZ mode ended". template = R.string.template_tv_remote_mode_ended; hint = false; modeForFeedback = mMode; // Speak the old mode name on exit. } else { // "XYZ mode started". template = R.string.template_tv_remote_mode_started; hint = true; modeForFeedback = newMode; // Speak the new mode name on enter. } SpannableStringBuilder builder = new SpannableStringBuilder(); switch (modeForFeedback) { case MODE_SEEK_CONTROL: StringBuilderUtils.appendWithSeparator(builder, mService.getString(template, mService.getString(R.string.value_tv_remote_mode_seek_control))); if (hint) { StringBuilderUtils.appendWithSeparator(builder, mService.getString(R.string.value_hint_tv_remote_mode_seek_control)); } break; } // Really critical that the user understands what mode the remote control is in. mService.getSpeechController().speak(builder, SpeechController.QUEUE_MODE_INTERRUPT, 0, null); mMode = newMode; } /** Silently resets the remote mode to navigate mode. */ public void resetToNavigateMode() { mMode = MODE_NAVIGATE; } private static class TelevisionKeyHandler extends WeakReferenceHandler<TelevisionNavigationController> { private static final int WHAT_DIRECTIONAL = 1; public TelevisionKeyHandler(TelevisionNavigationController parent) { super(parent); } @Override protected void handleMessage(Message msg, TelevisionNavigationController parent) { int keyCode = msg.arg1; AccessibilityNodeInfoCompat cursor = (AccessibilityNodeInfoCompat) msg.obj; switch (msg.what) { case WHAT_DIRECTIONAL: parent.onDirectionalKey(keyCode, cursor); break; } AccessibilityNodeInfoUtils.recycleNodes(cursor); } public void postDirectionalKeyEvent(KeyEvent event, AccessibilityNodeInfoCompat cursor) { AccessibilityNodeInfoCompat obtainedCursor; if (cursor == null) { obtainedCursor = null; } else { obtainedCursor = AccessibilityNodeInfoCompat.obtain(cursor); } Message msg = obtainMessage(WHAT_DIRECTIONAL, event.getKeyCode(), 0, obtainedCursor); sendMessageDelayed(msg, 0); } } }