| // Copyright 2017 The Cobalt Authors. All Rights Reserved. |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package dev.cobalt.coat; |
| |
| import android.graphics.Rect; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.Looper; |
| import android.view.View; |
| import android.view.accessibility.AccessibilityEvent; |
| import androidx.core.view.ViewCompat; |
| import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; |
| import androidx.customview.widget.ExploreByTouchHelper; |
| import java.util.BitSet; |
| import java.util.List; |
| |
| /** |
| * An ExploreByTouchHelper that create a virtual d-pad grid, so that Cobalt remains functional when |
| * the TalkBack screen reader is enabled (which otherwise intercepts d-pad events for most |
| * applications). |
| */ |
| class CobaltA11yHelper extends ExploreByTouchHelper { |
| // These are from starboard/key.h |
| private static final int SB_KEY_GAMEPAD_DPAD_UP = 0x800C; |
| private static final int SB_KEY_GAMEPAD_DPAD_DOWN = 0x800D; |
| private static final int SB_KEY_GAMEPAD_DPAD_LEFT = 0x800E; |
| private static final int SB_KEY_GAMEPAD_DPAD_RIGHT = 0x800F; |
| |
| // The fake dimensions for the nine virtual views. |
| // These values are arbitrary as long as the views stay on the screen. |
| private static final int FAKE_VIEW_HEIGHT = 10; |
| private static final int FAKE_VIEW_WIDTH = 10; |
| |
| private static final int CENTER_VIEW_ID = 5; |
| private static final int UP_VIEW_ID = 2; |
| private static final int DOWN_VIEW_ID = 8; |
| private static final int LEFT_VIEW_ID = 4; |
| private static final int RIGHT_VIEW_ID = 6; |
| |
| private static final int INPUT_FOCUS_CHANGE_DELAY = 1500; // milliseconds |
| |
| // This set tracks whether onPopulateNodeForVirtualView has been |
| // called for each virtual view id. |
| private final BitSet nodePopulatedSet = new BitSet(9); |
| private final Handler handler = new Handler(Looper.getMainLooper()); |
| private boolean unhandledInput; |
| private boolean hasInitialFocusBeenSet; |
| |
| public CobaltA11yHelper(View view) { |
| super(view); |
| ViewCompat.setAccessibilityDelegate(view, this); |
| } |
| |
| private static native void nativeInjectKeyEvent(int key); |
| |
| @Override |
| protected int getVirtualViewAt(float x, float y) { |
| // This method is only required for touch or mouse interfaces. |
| // Since we don't support either, we simply always return HOST_ID. |
| return HOST_ID; |
| } |
| |
| @Override |
| protected void getVisibleVirtualViews(List<Integer> virtualViewIds) { |
| if (!virtualViewIds.isEmpty()) { |
| throw new RuntimeException("Expected empty list"); |
| } |
| // We always have precisely 9 virtual views. |
| for (int i = 1; i <= 9; i++) { |
| virtualViewIds.add(i); |
| } |
| } |
| |
| private void focusOnCenter() { |
| // Setting Accessibility focus to CENTER_VIEW_ID will make TalkBack focus |
| // on CENTER_VIEW_ID immediately, but the actual mouse focus is either |
| // unchanged or return INVALID_ID. |
| handler.post( |
| new Runnable() { |
| @Override |
| public void run() { |
| sendEventForVirtualView( |
| CENTER_VIEW_ID, AccessibilityEvent.TYPE_VIEW_ACCESSIBILITY_FOCUSED); |
| } |
| }); |
| |
| // There is a known Android bug about setting focus too early |
| // taking no effect. The impact for Cobalt is that sometimes after |
| // we click on a video, TalkBack sees nothing in focus in the watch |
| // page if no user input happens. To avoid this bug we have to |
| // delay the focus long enough for all the TalkBack movements to settle |
| // down. More details here: https://stackoverflow.com/questions/28472985. |
| handler.postDelayed( |
| new Runnable() { |
| @Override |
| public void run() { |
| sendEventForVirtualView( |
| CENTER_VIEW_ID, AccessibilityEvent.TYPE_VIEW_FOCUSED); |
| } |
| }, INPUT_FOCUS_CHANGE_DELAY); |
| } |
| |
| private void maybeInjectEvent(int currentFocusedViewId) { |
| if (!unhandledInput) { |
| return; |
| } |
| switch (currentFocusedViewId) { |
| case CENTER_VIEW_ID: |
| // no move; |
| break; |
| case UP_VIEW_ID: |
| nativeInjectKeyEvent(SB_KEY_GAMEPAD_DPAD_UP); |
| break; |
| case LEFT_VIEW_ID: |
| nativeInjectKeyEvent(SB_KEY_GAMEPAD_DPAD_LEFT); |
| break; |
| case RIGHT_VIEW_ID: |
| nativeInjectKeyEvent(SB_KEY_GAMEPAD_DPAD_RIGHT); |
| break; |
| case DOWN_VIEW_ID: |
| nativeInjectKeyEvent(SB_KEY_GAMEPAD_DPAD_DOWN); |
| break; |
| default: |
| // TODO: Could support diagonal movements, although it's likely |
| // not possible to reach this. |
| break; |
| } |
| unhandledInput = false; |
| focusOnCenter(); |
| } |
| |
| /** |
| * <p>Fake number grid: |
| * |+-+-+-+ |
| * ||1|2|3| |
| * |+-+-+-| |
| * ||4|5|6| |
| * |+-+-+-| |
| * ||7|8|9| |
| * |+-+-+-+ |
| * |
| * <p>The focus always starts from the middle number 5. When user changes |
| * focus, the focus is then moved to either 2, 4, 6 or 8 and we can capture |
| * the movement this way. The focus is then quickly switched back to the |
| * center 5 to be ready for the next movement. |
| * |
| */ |
| @Override |
| protected void onPopulateNodeForVirtualView(int virtualViewId, AccessibilityNodeInfoCompat node) { |
| int focusedViewId = getAccessibilityFocusedVirtualViewId(); |
| |
| if (focusedViewId < 1 || focusedViewId > 9) { |
| // If this is not one of our nine-patch views, it's probably HOST_ID |
| // In any case, assume there is no focus change. |
| focusedViewId = CENTER_VIEW_ID; |
| } |
| |
| // onPopulateNodeForVirtualView() gets called at least once every |
| // time the focused view changes. So see if it's changed since the |
| // last time we've been called and inject an event if so. |
| if (focusedViewId != CENTER_VIEW_ID) { |
| maybeInjectEvent(focusedViewId); |
| } else { |
| unhandledInput = true; |
| } |
| |
| int x = (virtualViewId - 1) % 3; |
| int y = (virtualViewId - 1) / 3; |
| |
| // Note that the specific bounds here are arbitrary. The importance |
| // is the relative bounds to each other. |
| node.setBoundsInParent(new Rect( |
| x * FAKE_VIEW_WIDTH, |
| y * FAKE_VIEW_HEIGHT, |
| x * FAKE_VIEW_WIDTH + FAKE_VIEW_WIDTH, |
| y * FAKE_VIEW_HEIGHT + FAKE_VIEW_HEIGHT)); |
| node.setText(""); |
| |
| if (virtualViewId >= 1 || virtualViewId <= 9) { |
| nodePopulatedSet.set(virtualViewId - 1); |
| } |
| if (!hasInitialFocusBeenSet && nodePopulatedSet.cardinality() == 9) { |
| // Once the ExploreByTouchHelper knows about all of our virtual views, |
| // but not before, ask that the accessibility focus be moved from |
| // it's initial position on HOST_ID to the one we want to start with. |
| hasInitialFocusBeenSet = true; |
| focusOnCenter(); |
| } |
| } |
| |
| @Override |
| protected boolean onPerformActionForVirtualView(int virtualViewId, int action, Bundle arguments) { |
| return false; |
| } |
| } |