1 /*
2  * Copyright (C) 2021 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 
17 package com.android.car.ui;
18 
19 import static android.view.View.FOCUS_DOWN;
20 import static android.view.View.FOCUS_LEFT;
21 import static android.view.View.FOCUS_RIGHT;
22 import static android.view.View.FOCUS_UP;
23 import static android.view.View.LAYOUT_DIRECTION_RTL;
24 import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
25 
26 import static com.android.car.ui.utils.RotaryConstants.ACTION_NUDGE_SHORTCUT;
27 import static com.android.car.ui.utils.RotaryConstants.ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA;
28 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_BOTTOM_BOUND_OFFSET;
29 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_LEFT_BOUND_OFFSET;
30 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_RIGHT_BOUND_OFFSET;
31 import static com.android.car.ui.utils.RotaryConstants.FOCUS_AREA_TOP_BOUND_OFFSET;
32 import static com.android.car.ui.utils.RotaryConstants.NUDGE_DIRECTION;
33 
34 import android.content.Context;
35 import android.content.res.Resources;
36 import android.content.res.TypedArray;
37 import android.graphics.Canvas;
38 import android.graphics.drawable.Drawable;
39 import android.os.Bundle;
40 import android.os.SystemClock;
41 import android.util.AttributeSet;
42 import android.util.Log;
43 import android.util.SparseArray;
44 import android.util.SparseIntArray;
45 import android.view.View;
46 import android.view.ViewGroup;
47 import android.view.ViewTreeObserver.OnGlobalFocusChangeListener;
48 import android.view.accessibility.AccessibilityNodeInfo;
49 
50 import androidx.annotation.NonNull;
51 import androidx.annotation.Nullable;
52 import androidx.annotation.VisibleForTesting;
53 
54 import com.android.car.ui.utils.ViewUtils;
55 
56 import java.util.Arrays;
57 import java.util.List;
58 
59 /** A helper class used by {@link IFocusArea} implementation classes such as {@link FocusArea}. */
60 class FocusAreaHelper {
61 
62     private static final String TAG = "FocusAreaHelper";
63 
64     private static final int INVALID_DIMEN = -1;
65 
66     private static final int INVALID_DIRECTION = -1;
67 
68     private static final List<Integer> NUDGE_DIRECTIONS =
69             Arrays.asList(FOCUS_LEFT, FOCUS_RIGHT, FOCUS_UP, FOCUS_DOWN);
70 
71     private static final List<Integer> FOCUS_AREA_ACTIONS =
72             Arrays.asList(ACTION_FOCUS, ACTION_NUDGE_SHORTCUT, ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA);
73 
74     @NonNull
75     private final ViewGroup mFocusArea;
76 
77     /**
78      * Whether one of {@link #mFocusArea}'s descendant is focused (the {@link #mFocusArea} itself
79      * is not focusable).
80      */
81     private boolean mHasFocus;
82 
83     /**
84      * Whether to draw {@link #mForegroundHighlight} when one of {@link #mFocusArea}'s descendants
85      * is focused and it's not in touch mode.
86      */
87     private boolean mEnableForegroundHighlight;
88 
89     /**
90      * Whether to draw {@link #mBackgroundHighlight} when one of {@link #mFocusArea}'s descendants
91      * is focused and it's not in touch mode.
92      */
93     private boolean mEnableBackgroundHighlight;
94 
95     /**
96      * Highlight (typically outline of the focus area) drawn on top of {@link #mFocusArea} and its
97      * descendants.
98      */
99     private Drawable mForegroundHighlight;
100 
101     /**
102      * Highlight (typically a solid or gradient shape) drawn on top of {@link #mFocusArea} but
103      * behind its descendants.
104      */
105     private Drawable mBackgroundHighlight;
106 
107     /** The padding (in pixels) of the focus area highlight. */
108     private int mPaddingLeft;
109     private int mPaddingRight;
110     private int mPaddingTop;
111     private int mPaddingBottom;
112 
113     /** The offset (in pixels) of {@link #mFocusArea}'s bounds. */
114     private int mLeftOffset;
115     private int mRightOffset;
116     private int mTopOffset;
117     private int mBottomOffset;
118 
119     /** Whether the {@link #mFocusArea}'s layout direction is {@link View#LAYOUT_DIRECTION_RTL}. */
120     private boolean mRtl;
121 
122     /** The ID of the view specified in {@link #mFocusArea}'s {@code app:defaultFocus}. */
123     private int mDefaultFocusId;
124     /** The view specified in {@link #mFocusArea}'s {@code app:defaultFocus}. */
125     @Nullable
126     private View mDefaultFocusView;
127 
128     /**
129      * Whether to focus on the default focus view when nudging to {@link #mFocusArea}, even if
130      * there was another view in {@link #mFocusArea} focused before.
131      */
132     private boolean mDefaultFocusOverridesHistory;
133 
134     /**
135      * Map from direction to nudge shortcut IDs specified in {@code app:nudgeLeftShortcut},
136      * {@code app:nudgRightShortcut}, {@code app:nudgeUpShortcut}, and {@code app
137      * :nudgeDownShortcut}.
138      */
139     private final SparseIntArray mSpecifiedNudgeShortcutIdMap = new SparseIntArray();
140 
141     /** Map from direction to specified nudge shortcut views. */
142     private SparseArray<View> mSpecifiedNudgeShortcutMap;
143 
144     /**
145      * Map from direction to nudge target focus area IDs specified in {@link #mFocusArea}'s
146      * {@code app:nudgeLeft}, {@code app:nudgRight}, {@code app:nudgeUp}, and {@code app:nudgeDown}.
147      */
148     private final SparseIntArray mSpecifiedNudgeIdMap = new SparseIntArray();
149 
150     /** Map from direction to specified nudge target focus areas. */
151     private SparseArray<IFocusArea> mSpecifiedNudgeFocusAreaMap;
152 
153     /** Whether wrap-around is enabled for {@link #mFocusArea}. */
154     private boolean mWrapAround;
155 
156     /**
157      * Cache of focus history and nudge history of the rotary controller.
158      * <p>
159      * For focus history, the previously focused view and a timestamp will be saved when the
160      * focused view has changed.
161      * <p>
162      * For nudge history, the target focus area, direction, and a timestamp will be saved when the
163      * focus has moved from another focus area to {@link #mFocusArea}. There are two cases:
164      * <ul>
165      *     <li>The focus is moved to another focus area because {@link #mFocusArea} has called
166      *         {@link #nudgeToAnotherFocusArea}. In this case, the target focus area and direction
167      *         are trivial to {@link #mFocusArea}.
168      *     <li>The focus is moved to {@link #mFocusArea} because RotaryService has performed {@link
169      *         AccessibilityNodeInfo#ACTION_FOCUS} on {@link #mFocusArea}. In this case,
170      *         {@link #mFocusArea} can get the source focus area through the {@link
171      *         android.view.ViewTreeObserver.OnGlobalFocusChangeListener} registered, and can get
172      *         the direction when handling the action. Since the listener is triggered before
173      *         {@link View#requestFocus} returns (which is called when handling the action), the
174      *         source focus area is revealed earlier than the direction, so the nudge history should
175      *         be saved when the direction is revealed.
176      * </ul>
177      */
178     private RotaryCache mRotaryCache;
179 
180     /** Whether to clear focus area history when the user rotates the rotary controller. */
181     private boolean mClearFocusAreaHistoryWhenRotating;
182 
183     /** The focus area that had focus before {@link #mFocusArea}, if any. */
184     private IFocusArea mPreviousFocusArea;
185 
186     /** The focused view in {@link #mFocusArea}, if any. */
187     private View mFocusedView;
188 
189     private final OnGlobalFocusChangeListener mFocusChangeListener;
190 
191     /**
192      * Whether to restore focus when Android frameworks want to focus inside {@link #mFocusArea}.
193      * This should be false if {@link #mFocusArea} is in a {@link com.android.wm.shell.TaskView}.
194      * The default value is true.
195      */
196     private boolean mShouldRestoreFocus = true;
197 
FocusAreaHelper(@onNull ViewGroup viewGroup, @Nullable AttributeSet attrs)198     FocusAreaHelper(@NonNull ViewGroup viewGroup, @Nullable AttributeSet attrs) {
199         mFocusArea = viewGroup;
200 
201         mFocusChangeListener =
202                 (oldFocus, newFocus) -> {
203                     boolean hasFocus = mFocusArea.hasFocus();
204                     saveFocusHistory(hasFocus);
205                     maybeUpdatePreviousFocusArea(hasFocus, oldFocus);
206                     maybeClearFocusAreaHistory(hasFocus, oldFocus);
207                     maybeUpdateFocusAreaHighlight(hasFocus);
208                     mHasFocus = hasFocus;
209                 };
210 
211         Context context = mFocusArea.getContext();
212         Resources resources = context.getResources();
213         mEnableForegroundHighlight = resources.getBoolean(
214                 R.bool.car_ui_enable_focus_area_foreground_highlight);
215         mEnableBackgroundHighlight = resources.getBoolean(
216                 R.bool.car_ui_enable_focus_area_background_highlight);
217         mForegroundHighlight = resources.getDrawable(
218                 R.drawable.car_ui_focus_area_foreground_highlight, context.getTheme());
219         mBackgroundHighlight = resources.getDrawable(
220                 R.drawable.car_ui_focus_area_background_highlight, context.getTheme());
221 
222         mClearFocusAreaHistoryWhenRotating = resources.getBoolean(
223                 R.bool.car_ui_clear_focus_area_history_when_rotating);
224 
225         @RotaryCache.CacheType
226         int focusHistoryCacheType = resources.getInteger(R.integer.car_ui_focus_history_cache_type);
227         int focusHistoryExpirationPeriodMs =
228                 resources.getInteger(R.integer.car_ui_focus_history_expiration_period_ms);
229         @RotaryCache.CacheType
230         int focusAreaHistoryCacheType = resources.getInteger(
231                 R.integer.car_ui_focus_area_history_cache_type);
232         int focusAreaHistoryExpirationPeriodMs =
233                 resources.getInteger(R.integer.car_ui_focus_area_history_expiration_period_ms);
234         mRotaryCache = new RotaryCache(focusHistoryCacheType, focusHistoryExpirationPeriodMs,
235                 focusAreaHistoryCacheType, focusAreaHistoryExpirationPeriodMs);
236 
237         // Ensure that an AccessibilityNodeInfo is created for mFocusArea.
238         mFocusArea.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
239 
240         // By default all ViewGroup subclasses do not call their draw() and onDraw() methods. We
241         // should enable it since we override these methods.
242         mFocusArea.setWillNotDraw(false);
243 
244         initAttrs(context, attrs);
245     }
246 
saveFocusHistory(boolean hasFocus)247     private void saveFocusHistory(boolean hasFocus) {
248         // Save focus history and clear mFocusedView if focus is leaving mFocusArea.
249         if (!hasFocus) {
250             if (mHasFocus) {
251                 mRotaryCache.saveFocusedView(mFocusedView, SystemClock.uptimeMillis());
252                 mFocusedView = null;
253             }
254             return;
255         }
256 
257         // Update mFocusedView if a descendant of mFocusArea is focused.
258         View v = mFocusArea.getFocusedChild();
259         while (v != null) {
260             if (v.isFocused()) {
261                 break;
262             }
263             v = v instanceof ViewGroup ? ((ViewGroup) v).getFocusedChild() : null;
264         }
265         mFocusedView = v;
266     }
267 
268     /**
269      * Updates {@link #mPreviousFocusArea} when the focus has moved from another focus area to
270      * {@link #mFocusArea}, and sets it to {@code null} in any other cases.
271      */
maybeUpdatePreviousFocusArea(boolean hasFocus, View oldFocus)272     private void maybeUpdatePreviousFocusArea(boolean hasFocus, View oldFocus) {
273         if (mHasFocus || !hasFocus || oldFocus == null || oldFocus instanceof FocusParkingView) {
274             mPreviousFocusArea = null;
275             return;
276         }
277         mPreviousFocusArea = ViewUtils.getAncestorFocusArea(oldFocus);
278         if (mPreviousFocusArea == null) {
279             Log.w(TAG, "No ancestor focus area for " + oldFocus);
280         }
281     }
282 
283     /**
284      * Clears focus area nudge history when the user rotates the controller to move focus within
285      * {@link #mFocusArea}.
286      */
maybeClearFocusAreaHistory(boolean hasFocus, View oldFocus)287     private void maybeClearFocusAreaHistory(boolean hasFocus, View oldFocus) {
288         if (!mClearFocusAreaHistoryWhenRotating) {
289             return;
290         }
291         if (!hasFocus || oldFocus == null) {
292             return;
293         }
294         IFocusArea oldFocusArea = ViewUtils.getAncestorFocusArea(oldFocus);
295         if (oldFocusArea != mFocusArea) {
296             return;
297         }
298         mRotaryCache.clearFocusAreaHistory();
299     }
300 
301     /** Updates highlight of {@link #mFocusArea} if it has gained or lost focus. */
maybeUpdateFocusAreaHighlight(boolean hasFocus)302     private void maybeUpdateFocusAreaHighlight(boolean hasFocus) {
303         if (!mEnableBackgroundHighlight && !mEnableForegroundHighlight) {
304             return;
305         }
306         if (mHasFocus != hasFocus) {
307             mFocusArea.invalidate();
308         }
309     }
310 
initAttrs(Context context, @Nullable AttributeSet attrs)311     private void initAttrs(Context context, @Nullable AttributeSet attrs) {
312         if (attrs == null) {
313             return;
314         }
315         TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.IFocusArea);
316         try {
317             mDefaultFocusId = a.getResourceId(R.styleable.IFocusArea_defaultFocus, View.NO_ID);
318 
319             // Initialize the highlight padding. The padding, for example, left padding, is set in
320             // the following order:
321             // 1. if highlightPaddingStart (or highlightPaddingEnd in RTL layout) specified, use it
322             // 2. otherwise, if highlightPaddingHorizontal is specified, use it
323             // 3. otherwise use 0
324 
325             int paddingStart = a.getDimensionPixelSize(
326                     R.styleable.IFocusArea_highlightPaddingStart, INVALID_DIMEN);
327             if (paddingStart == INVALID_DIMEN) {
328                 paddingStart = a.getDimensionPixelSize(
329                         R.styleable.IFocusArea_highlightPaddingHorizontal, 0);
330             }
331 
332             int paddingEnd = a.getDimensionPixelSize(
333                     R.styleable.IFocusArea_highlightPaddingEnd, INVALID_DIMEN);
334             if (paddingEnd == INVALID_DIMEN) {
335                 paddingEnd = a.getDimensionPixelSize(
336                         R.styleable.IFocusArea_highlightPaddingHorizontal, 0);
337             }
338 
339             mRtl = mFocusArea.getLayoutDirection() == LAYOUT_DIRECTION_RTL;
340             mPaddingLeft = mRtl ? paddingEnd : paddingStart;
341             mPaddingRight = mRtl ? paddingStart : paddingEnd;
342 
343             mPaddingTop = a.getDimensionPixelSize(
344                     R.styleable.IFocusArea_highlightPaddingTop, INVALID_DIMEN);
345             if (mPaddingTop == INVALID_DIMEN) {
346                 mPaddingTop = a.getDimensionPixelSize(
347                         R.styleable.IFocusArea_highlightPaddingVertical, 0);
348             }
349 
350             mPaddingBottom = a.getDimensionPixelSize(
351                     R.styleable.IFocusArea_highlightPaddingBottom, INVALID_DIMEN);
352             if (mPaddingBottom == INVALID_DIMEN) {
353                 mPaddingBottom = a.getDimensionPixelSize(
354                         R.styleable.IFocusArea_highlightPaddingVertical, 0);
355             }
356 
357             // Initialize the offset of mFocusArea's bounds. The offset, for example, left
358             // offset, is set in the following order:
359             // 1. if startBoundOffset (or endBoundOffset in RTL layout) specified, use it
360             // 2. otherwise, if horizontalBoundOffset is specified, use it
361             // 3. otherwise use mPaddingLeft
362 
363             int startOffset = a.getDimensionPixelSize(
364                     R.styleable.IFocusArea_startBoundOffset, INVALID_DIMEN);
365             if (startOffset == INVALID_DIMEN) {
366                 startOffset = a.getDimensionPixelSize(
367                         R.styleable.IFocusArea_horizontalBoundOffset, paddingStart);
368             }
369 
370             int endOffset = a.getDimensionPixelSize(
371                     R.styleable.IFocusArea_endBoundOffset, INVALID_DIMEN);
372             if (endOffset == INVALID_DIMEN) {
373                 endOffset = a.getDimensionPixelSize(
374                         R.styleable.IFocusArea_horizontalBoundOffset, paddingEnd);
375             }
376 
377             mLeftOffset = mRtl ? endOffset : startOffset;
378             mRightOffset = mRtl ? startOffset : endOffset;
379 
380             mTopOffset = a.getDimensionPixelSize(
381                     R.styleable.IFocusArea_topBoundOffset, INVALID_DIMEN);
382             if (mTopOffset == INVALID_DIMEN) {
383                 mTopOffset = a.getDimensionPixelSize(
384                         R.styleable.IFocusArea_verticalBoundOffset, mPaddingTop);
385             }
386 
387             mBottomOffset = a.getDimensionPixelSize(
388                     R.styleable.IFocusArea_bottomBoundOffset, INVALID_DIMEN);
389             if (mBottomOffset == INVALID_DIMEN) {
390                 mBottomOffset = a.getDimensionPixelSize(
391                         R.styleable.IFocusArea_verticalBoundOffset, mPaddingBottom);
392             }
393 
394             // Handle new nudge shortcut attributes.
395             if (a.hasValue(R.styleable.IFocusArea_nudgeLeftShortcut)) {
396                 mSpecifiedNudgeShortcutIdMap.put(FOCUS_LEFT,
397                         a.getResourceId(R.styleable.IFocusArea_nudgeLeftShortcut, View.NO_ID));
398             }
399             if (a.hasValue(R.styleable.IFocusArea_nudgeRightShortcut)) {
400                 mSpecifiedNudgeShortcutIdMap.put(FOCUS_RIGHT,
401                         a.getResourceId(R.styleable.IFocusArea_nudgeRightShortcut, View.NO_ID));
402             }
403             if (a.hasValue(R.styleable.IFocusArea_nudgeUpShortcut)) {
404                 mSpecifiedNudgeShortcutIdMap.put(FOCUS_UP,
405                         a.getResourceId(R.styleable.IFocusArea_nudgeUpShortcut, View.NO_ID));
406             }
407             if (a.hasValue(R.styleable.IFocusArea_nudgeDownShortcut)) {
408                 mSpecifiedNudgeShortcutIdMap.put(FOCUS_DOWN,
409                         a.getResourceId(R.styleable.IFocusArea_nudgeDownShortcut, View.NO_ID));
410             }
411 
412             // Handle legacy nudge shortcut attributes.
413             int nudgeShortcutId = a.getResourceId(R.styleable.IFocusArea_nudgeShortcut, View.NO_ID);
414             int nudgeShortcutDirection = a.getInt(
415                     R.styleable.IFocusArea_nudgeShortcutDirection, INVALID_DIRECTION);
416             if ((nudgeShortcutId == View.NO_ID) ^ (nudgeShortcutDirection == INVALID_DIRECTION)) {
417                 throw new IllegalStateException("nudgeShortcut and nudgeShortcutDirection must "
418                         + "be specified together");
419             }
420             if (nudgeShortcutId != View.NO_ID) {
421                 if (mSpecifiedNudgeShortcutIdMap.size() > 0) {
422                     throw new IllegalStateException(
423                             "Don't use nudgeShortcut/nudgeShortcutDirection and nudge*Shortcut in "
424                                     + "the same focus area. Use nudge*Shortcut only.");
425                 }
426                 mSpecifiedNudgeShortcutIdMap.put(nudgeShortcutDirection, nudgeShortcutId);
427             }
428 
429             // Handle nudge targets.
430             if (a.hasValue(R.styleable.IFocusArea_nudgeLeft)) {
431                 mSpecifiedNudgeIdMap.put(FOCUS_LEFT,
432                         a.getResourceId(R.styleable.IFocusArea_nudgeLeft, View.NO_ID));
433             }
434             if (a.hasValue(R.styleable.IFocusArea_nudgeRight)) {
435                 mSpecifiedNudgeIdMap.put(FOCUS_RIGHT,
436                         a.getResourceId(R.styleable.IFocusArea_nudgeRight, View.NO_ID));
437             }
438             if (a.hasValue(R.styleable.IFocusArea_nudgeUp)) {
439                 mSpecifiedNudgeIdMap.put(FOCUS_UP,
440                         a.getResourceId(R.styleable.IFocusArea_nudgeUp, View.NO_ID));
441             }
442             if (a.hasValue(R.styleable.IFocusArea_nudgeDown)) {
443                 mSpecifiedNudgeIdMap.put(FOCUS_DOWN,
444                         a.getResourceId(R.styleable.IFocusArea_nudgeDown, View.NO_ID));
445             }
446 
447             mDefaultFocusOverridesHistory = a.getBoolean(
448                     R.styleable.IFocusArea_defaultFocusOverridesHistory, false);
449 
450             mWrapAround = a.getBoolean(R.styleable.IFocusArea_wrapAround, false);
451         } finally {
452             a.recycle();
453         }
454     }
455 
onFinishInflate()456     void onFinishInflate() {
457         if (mDefaultFocusId != View.NO_ID) {
458             mDefaultFocusView = mFocusArea.requireViewById(mDefaultFocusId);
459         }
460     }
461 
onLayout()462     void onLayout() {
463         boolean rtl = mFocusArea.getLayoutDirection() == LAYOUT_DIRECTION_RTL;
464         if (mRtl != rtl) {
465             mRtl = rtl;
466 
467             int temp = mPaddingLeft;
468             mPaddingLeft = mPaddingRight;
469             mPaddingRight = temp;
470 
471             temp = mLeftOffset;
472             mLeftOffset = mRightOffset;
473             mRightOffset = temp;
474         }
475     }
476 
onAttachedToWindow()477     void onAttachedToWindow() {
478         mFocusArea.getViewTreeObserver().addOnGlobalFocusChangeListener(mFocusChangeListener);
479 
480         // Disable restore focus behavior if mFocusArea is in a TaskView.
481         if (mShouldRestoreFocus && ViewUtils.isInMultiWindowMode(mFocusArea)) {
482             mShouldRestoreFocus = false;
483         }
484     }
485 
onDetachedFromWindow()486     void onDetachedFromWindow() {
487         mFocusArea.getViewTreeObserver().removeOnGlobalFocusChangeListener(mFocusChangeListener);
488     }
489 
onWindowFocusChanged(boolean hasWindowFocus)490     boolean onWindowFocusChanged(boolean hasWindowFocus) {
491         // TODO(b/201700195): sometimes onWindowFocusChanged() won't be called when window focus
492         //  has changed, so add the log for debugging.
493         Log.d(TAG, "The window of Activity ["
494                 + ViewUtils.findActivity(mFocusArea.getContext())
495                 + (hasWindowFocus ? "] gained" : "] lost") + " focus");
496         // To ensure the focus is initialized properly in rotary mode when there is a window focus
497         // change, mFocusArea will grab the focus if nothing is focused or the currently
498         // focused view's FocusLevel is lower than REGULAR_FOCUS.
499         if (hasWindowFocus && mShouldRestoreFocus && !mFocusArea.isInTouchMode()) {
500             maybeInitFocus();
501             return true;
502         }
503         return false;
504     }
505 
506     /**
507      * Focuses on another view in {@link #mFocusArea} if nothing is focused or the currently focused
508      * view's FocusLevel is lower than REGULAR_FOCUS.
509      */
maybeInitFocus()510     private boolean maybeInitFocus() {
511         View root = mFocusArea.getRootView();
512         View focus = root.findFocus();
513         return ViewUtils.initFocus(root, focus);
514     }
515 
516     /**
517      * Focuses on a view in {@link #mFocusArea} if the view is a better focus candidate than the
518      * currently focused view.
519      */
maybeAdjustFocus()520     private boolean maybeAdjustFocus() {
521         View root = mFocusArea.getRootView();
522         View focus = root.findFocus();
523         return ViewUtils.adjustFocus(root, focus);
524     }
525 
526     /** Whether the given {@code action} is custom action for {@link IFocusArea} subclasses. */
isFocusAreaAction(int action)527     boolean isFocusAreaAction(int action) {
528         return FOCUS_AREA_ACTIONS.contains(action);
529     }
530 
performAccessibilityAction(int action, Bundle arguments)531     boolean performAccessibilityAction(int action, Bundle arguments) {
532         switch (action) {
533             case ACTION_FOCUS:
534                 // Repurpose ACTION_FOCUS to focus on mFocusArea's descendant. We can do this
535                 // because mFocusArea is not focusable and it didn't consume
536                 // ACTION_FOCUS previously.
537                 boolean success = focusOnDescendant();
538                 if (success && mPreviousFocusArea != null) {
539                     int direction = getNudgeDirection(arguments);
540                     if (direction != INVALID_DIRECTION) {
541                         saveFocusAreaHistory(direction, mPreviousFocusArea,
542                                 (IFocusArea) mFocusArea, SystemClock.uptimeMillis());
543                     }
544                 }
545                 return success;
546             case ACTION_NUDGE_SHORTCUT:
547                 return nudgeToShortcutView(arguments);
548             case ACTION_NUDGE_TO_ANOTHER_FOCUS_AREA:
549                 return nudgeToAnotherFocusArea(arguments);
550             default:
551                 return false;
552         }
553     }
554 
focusOnDescendant()555     private boolean focusOnDescendant() {
556         View lastFocusedView = mRotaryCache.getFocusedView(SystemClock.uptimeMillis());
557         return ViewUtils.adjustFocus(mFocusArea, lastFocusedView, mDefaultFocusOverridesHistory);
558     }
559 
nudgeToShortcutView(Bundle arguments)560     private boolean nudgeToShortcutView(Bundle arguments) {
561         int direction = getNudgeDirection(arguments);
562         View targetView = getSpecifiedShortcut(direction);
563         if (targetView == null) {
564             // No nudge shortcut configured for the given direction.
565             return false;
566         }
567         if (targetView.isFocused()) {
568             // The nudge shortcut view is already focused; return false so that the user can
569             // nudge to another focus area.
570             return false;
571         }
572         return ViewUtils.requestFocus(targetView);
573     }
574 
nudgeToAnotherFocusArea(Bundle arguments)575     private boolean nudgeToAnotherFocusArea(Bundle arguments) {
576         int direction = getNudgeDirection(arguments);
577         long elapsedRealtime = SystemClock.uptimeMillis();
578 
579         // Try to nudge to specified focus area, if any.
580         IFocusArea targetFocusArea = getSpecifiedFocusArea(direction);
581         boolean success =
582                 targetFocusArea != null && targetFocusArea.getHelper().focusOnDescendant();
583 
584         // If failed, try to nudge to cached focus area, if any.
585         if (!success) {
586             targetFocusArea = mRotaryCache.getCachedFocusArea(direction, elapsedRealtime);
587             success = targetFocusArea != null && targetFocusArea.getHelper().focusOnDescendant();
588         }
589 
590         return success;
591     }
592 
getNudgeDirection(Bundle arguments)593     private static int getNudgeDirection(Bundle arguments) {
594         return arguments == null
595                 ? INVALID_DIRECTION
596                 : arguments.getInt(NUDGE_DIRECTION, INVALID_DIRECTION);
597     }
598 
saveFocusAreaHistory(int direction, @NonNull IFocusArea sourceFocusArea, @NonNull IFocusArea targetFocusArea, long elapsedRealtime)599     private void saveFocusAreaHistory(int direction, @NonNull IFocusArea sourceFocusArea,
600             @NonNull IFocusArea targetFocusArea, long elapsedRealtime) {
601         // Save one-way rather than two-way nudge history to avoid infinite nudge loop.
602         FocusAreaHelper sourceHelper = sourceFocusArea.getHelper();
603         if (sourceHelper.getCachedFocusArea(direction, elapsedRealtime) == null) {
604             // Save reversed nudge history so that the users can nudge back to where they were.
605             int oppositeDirection = getOppositeDirection(direction);
606             FocusAreaHelper targetHelper = targetFocusArea.getHelper();
607             targetHelper.saveFocusArea(oppositeDirection, sourceFocusArea, elapsedRealtime);
608         }
609     }
610 
611     @Nullable
getCachedFocusArea(int direction, long elapsedRealtime)612     IFocusArea getCachedFocusArea(int direction, long elapsedRealtime) {
613         return mRotaryCache.getCachedFocusArea(direction, elapsedRealtime);
614     }
615 
616     /** Saves the focus area nudge history. */
saveFocusArea(int direction, @NonNull IFocusArea targetFocusArea, long elapsedRealtime)617     void saveFocusArea(int direction, @NonNull IFocusArea targetFocusArea, long elapsedRealtime) {
618         mRotaryCache.saveFocusArea(direction, targetFocusArea, elapsedRealtime);
619     }
620 
621     /** Returns the direction opposite the given {@code direction} */
622     @VisibleForTesting
getOppositeDirection(int direction)623     private static int getOppositeDirection(int direction) {
624         switch (direction) {
625             case FOCUS_LEFT:
626                 return FOCUS_RIGHT;
627             case FOCUS_RIGHT:
628                 return FOCUS_LEFT;
629             case FOCUS_UP:
630                 return FOCUS_DOWN;
631             case FOCUS_DOWN:
632                 return FOCUS_UP;
633         }
634         throw new IllegalArgumentException("direction must be "
635                 + "FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, or FOCUS_RIGHT.");
636     }
637 
638     @Nullable
getSpecifiedFocusArea(int direction)639     private IFocusArea getSpecifiedFocusArea(int direction) {
640         maybeInitializeSpecifiedFocusAreas();
641         return mSpecifiedNudgeFocusAreaMap.get(direction);
642     }
643 
644     @Nullable
getSpecifiedShortcut(int direction)645     private View getSpecifiedShortcut(int direction) {
646         maybeInitializeSpecifiedShortcuts();
647         return mSpecifiedNudgeShortcutMap.get(direction);
648     }
649 
onDraw(Canvas canvas)650     void onDraw(Canvas canvas) {
651         // Draw highlight on top of mFocusArea (including its background and content) but
652         // behind its children.
653         if (mEnableBackgroundHighlight && mHasFocus && !mFocusArea.isInTouchMode()) {
654             mBackgroundHighlight.setBounds(
655                     mPaddingLeft + mFocusArea.getScrollX(),
656                     mPaddingTop + mFocusArea.getScrollY(),
657                     mFocusArea.getScrollX() + mFocusArea.getWidth() - mPaddingRight,
658                     mFocusArea.getScrollY() + mFocusArea.getHeight() - mPaddingBottom);
659             mBackgroundHighlight.draw(canvas);
660         }
661     }
662 
draw(Canvas canvas)663     void draw(Canvas canvas) {
664         // Draw highlight on top of mFocusArea (including its background and content) and its
665         // children (including background, content, focus highlight, etc).
666         if (mEnableForegroundHighlight && mHasFocus && !mFocusArea.isInTouchMode()) {
667             mForegroundHighlight.setBounds(
668                     mPaddingLeft + mFocusArea.getScrollX(),
669                     mPaddingTop + mFocusArea.getScrollY(),
670                     mFocusArea.getScrollX() + mFocusArea.getWidth() - mPaddingRight,
671                     mFocusArea.getScrollY() + mFocusArea.getHeight() - mPaddingBottom);
672             mForegroundHighlight.draw(canvas);
673         }
674     }
675 
onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info)676     void onInitializeAccessibilityNodeInfo(AccessibilityNodeInfo info) {
677         Bundle bundle = info.getExtras();
678         bundle.putInt(FOCUS_AREA_LEFT_BOUND_OFFSET, mLeftOffset);
679         bundle.putInt(FOCUS_AREA_RIGHT_BOUND_OFFSET, mRightOffset);
680         bundle.putInt(FOCUS_AREA_TOP_BOUND_OFFSET, mTopOffset);
681         bundle.putInt(FOCUS_AREA_BOTTOM_BOUND_OFFSET, mBottomOffset);
682     }
683 
onRequestFocusInDescendants()684     boolean onRequestFocusInDescendants() {
685         if (!mShouldRestoreFocus) {
686             return false;
687         }
688         return maybeAdjustFocus();
689     }
690 
restoreDefaultFocus()691     boolean restoreDefaultFocus() {
692         if (!mShouldRestoreFocus) {
693             return false;
694         }
695         return maybeAdjustFocus();
696     }
697 
maybeInitializeSpecifiedFocusAreas()698     private void maybeInitializeSpecifiedFocusAreas() {
699         if (mSpecifiedNudgeFocusAreaMap != null) {
700             return;
701         }
702         View root = mFocusArea.getRootView();
703         mSpecifiedNudgeFocusAreaMap = new SparseArray<>();
704         for (int direction : NUDGE_DIRECTIONS) {
705             int id = mSpecifiedNudgeIdMap.get(direction, View.NO_ID);
706             mSpecifiedNudgeFocusAreaMap.put(direction, root.findViewById(id));
707         }
708     }
709 
maybeInitializeSpecifiedShortcuts()710     private void maybeInitializeSpecifiedShortcuts() {
711         if (mSpecifiedNudgeShortcutMap != null) {
712             return;
713         }
714         View root = mFocusArea.getRootView();
715         mSpecifiedNudgeShortcutMap = new SparseArray<>();
716         for (int direction : NUDGE_DIRECTIONS) {
717             int id = mSpecifiedNudgeShortcutIdMap.get(direction, View.NO_ID);
718             mSpecifiedNudgeShortcutMap.put(direction, root.findViewById(id));
719         }
720     }
721 
722     /** Gets the default focus view in {@link #mFocusArea}. */
getDefaultFocusView()723     View getDefaultFocusView() {
724         return mDefaultFocusView;
725     }
726 
727     /** Sets the default focus view in {@link #mFocusArea}. */
setDefaultFocus(@onNull View defaultFocus)728     void setDefaultFocus(@NonNull View defaultFocus) {
729         mDefaultFocusView = defaultFocus;
730     }
731 
732     /**
733      * Sets the padding (in pixels) of the focus area highlight.
734      * <p>
735      * It doesn't affect other values, such as the paddings on {@link #mFocusArea}'s child views.
736      */
setHighlightPadding(int left, int top, int right, int bottom)737     void setHighlightPadding(int left, int top, int right, int bottom) {
738         if (mPaddingLeft == left && mPaddingTop == top && mPaddingRight == right
739                 && mPaddingBottom == bottom) {
740             return;
741         }
742         mPaddingLeft = left;
743         mPaddingTop = top;
744         mPaddingRight = right;
745         mPaddingBottom = bottom;
746         mFocusArea.invalidate();
747     }
748 
749     /**
750      * Sets the offset (in pixels) of {@link #mFocusArea}'s perceived bounds.
751      * <p>
752      * It only affects the perceived bounds for the purposes of finding the nudge target. It doesn't
753      * affect {@link #mFocusArea}'s view bounds or highlight bounds. The offset should only be used
754      * when focus areas are overlapping and nudge interaction is ambiguous.
755      */
setBoundsOffset(int left, int top, int right, int bottom)756     void setBoundsOffset(int left, int top, int right, int bottom) {
757         mLeftOffset = left;
758         mTopOffset = top;
759         mRightOffset = right;
760         mBottomOffset = bottom;
761     }
762 
763     /** Whether wrap-around is enabled for {@link #mFocusArea}. */
isWrapAround()764     boolean isWrapAround() {
765         return mWrapAround;
766     }
767 
768     /** Sets whether wrap-around is enabled for {@link #mFocusArea}. */
setWrapAround(boolean wrapAround)769     void setWrapAround(boolean wrapAround) {
770         mWrapAround = wrapAround;
771     }
772 
773     /**
774      * Sets the nudge shortcut for the given {@code direction}. Removes the nudge shortcut if
775      * {@code view} is {@code null}.
776      */
setNudgeShortcut(int direction, @Nullable View view)777     void setNudgeShortcut(int direction, @Nullable View view) {
778         if (!NUDGE_DIRECTIONS.contains(direction)) {
779             throw new IllegalArgumentException("direction must be "
780                     + "FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, or FOCUS_RIGHT.");
781         }
782         maybeInitializeSpecifiedShortcuts();
783         if (view == null) {
784             mSpecifiedNudgeShortcutMap.remove(direction);
785         } else {
786             mSpecifiedNudgeShortcutMap.put(direction, view);
787         }
788     }
789 
790     /**
791      * Sets the nudge target focus area for the given {@code direction}. Removes the existing
792      * target if {@code target} is {@code null}.
793      */
setNudgeTargetFocusArea(int direction, @Nullable IFocusArea target)794     void setNudgeTargetFocusArea(int direction, @Nullable IFocusArea target) {
795         if (!NUDGE_DIRECTIONS.contains(direction)) {
796             throw new IllegalArgumentException("direction must be "
797                     + "FOCUS_UP, FOCUS_DOWN, FOCUS_LEFT, or FOCUS_RIGHT.");
798         }
799         maybeInitializeSpecifiedFocusAreas();
800         if (target == null) {
801             mSpecifiedNudgeFocusAreaMap.remove(direction);
802         } else {
803             mSpecifiedNudgeFocusAreaMap.put(direction, target);
804         }
805     }
806 
setDefaultFocusOverridesHistory(boolean override)807     void setDefaultFocusOverridesHistory(boolean override) {
808         mDefaultFocusOverridesHistory = override;
809     }
810 
811     @VisibleForTesting
enableForegroundHighlight()812     void enableForegroundHighlight() {
813         mEnableForegroundHighlight = true;
814     }
815 
816     @VisibleForTesting
setRotaryCache(@onNull RotaryCache rotaryCache)817     void setRotaryCache(@NonNull RotaryCache rotaryCache) {
818         mRotaryCache = rotaryCache;
819     }
820 
821     @VisibleForTesting
setClearFocusAreaHistoryWhenRotating(boolean clear)822     void setClearFocusAreaHistoryWhenRotating(boolean clear) {
823         mClearFocusAreaHistoryWhenRotating = clear;
824     }
825 }
826