Introduce a custom attribute for FocusArea

This CL introduces an attribute "app:defaultFocus". It's used to
specify the ID of a focusable descendant view which should be focused
when the user nudges to this focus area.

Bug: 158797952
Bug: 155698037
Test: atest com.android.car.ui.FocusAreaTest
Change-Id: Ic252c030c1507c2a3c30b68cad8b520251bc073c
diff --git a/car-ui-lib/res/values/attrs.xml b/car-ui-lib/res/values/attrs.xml
index 4b06a60..c42bdc7 100644
--- a/car-ui-lib/res/values/attrs.xml
+++ b/car-ui-lib/res/values/attrs.xml
@@ -144,4 +144,11 @@
     <attr name="carUiRecyclerViewStyle" format="reference" />
 
     <attr name="state_ux_restricted" format="boolean" />
+
+    <!-- Attributes for FocusArea. -->
+    <declare-styleable name="FocusArea">
+        <!-- The ID of a focusable descendant view which should be focused when the user nudges to
+             this FocusArea. -->
+        <attr name="defaultFocus" format="reference"/>
+    </declare-styleable>
 </resources>
diff --git a/car-ui-lib/src/com/android/car/ui/FocusArea.java b/car-ui-lib/src/com/android/car/ui/FocusArea.java
index f969380..ef28c38 100644
--- a/car-ui-lib/src/com/android/car/ui/FocusArea.java
+++ b/car-ui-lib/src/com/android/car/ui/FocusArea.java
@@ -16,16 +16,29 @@
 
 package com.android.car.ui;
 
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
+
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_ACTION_TYPE;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_DEFAULT;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_FIRST;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_INVALID;
+
 import android.content.Context;
+import android.content.res.TypedArray;
 import android.graphics.Canvas;
 import android.graphics.drawable.Drawable;
+import android.os.Bundle;
 import android.util.AttributeSet;
+import android.util.Log;
 import android.view.KeyEvent;
 import android.view.View;
 import android.widget.LinearLayout;
 
 import androidx.annotation.Nullable;
 
+import com.android.car.ui.utils.CarUiUtils;
+import com.android.car.ui.utils.RotaryConstants;
+
 /**
  * A {@link LinearLayout} used as a navigation block for the rotary controller.
  * <p>
@@ -48,6 +61,8 @@
  */
 public class FocusArea extends LinearLayout {
 
+    private static final String TAG = "FocusArea";
+
     /** Whether the FocusArea's descendant has focus (the FocusArea itself is not focusable). */
     private boolean mHasFocus;
 
@@ -81,28 +96,34 @@
     private int mPaddingTop;
     private int mPaddingBottom;
 
+    /** The ID of the view specified in {@code app:defaultFocus}. */
+    private int mDefaultFocusId = View.NO_ID;
+    /** The view specified in {@code app:defaultFocus}. */
+    @Nullable
+    private View mDefaultFocusView;
+
     public FocusArea(Context context) {
         super(context);
-        init();
+        init(context, null);
     }
 
     public FocusArea(Context context, @Nullable AttributeSet attrs) {
         super(context, attrs);
-        init();
+        init(context, attrs);
     }
 
     public FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
         super(context, attrs, defStyleAttr);
-        init();
+        init(context, attrs);
     }
 
     public FocusArea(Context context, @Nullable AttributeSet attrs, int defStyleAttr,
             int defStyleRes) {
         super(context, attrs, defStyleAttr, defStyleRes);
-        init();
+        init(context, attrs);
     }
 
-    private void init() {
+    private void init(Context context, @Nullable AttributeSet attrs) {
         mEnableForegroundHighlight = getContext().getResources().getBoolean(
                 R.bool.car_ui_enable_focus_area_foreground_highlight);
         mEnableBackgroundHighlight = getContext().getResources().getBoolean(
@@ -128,6 +149,59 @@
                         invalidate();
                     }
                 });
+
+        initAttrs(context, attrs);
+    }
+
+    private void initAttrs(Context context, @Nullable AttributeSet attrs) {
+        if (attrs == null) {
+            return;
+        }
+        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.FocusArea);
+        try {
+            mDefaultFocusId = a.getResourceId(R.styleable.FocusArea_defaultFocus, View.NO_ID);
+        } finally {
+            a.recycle();
+        }
+    }
+
+    @Override
+    protected void onFinishInflate() {
+        super.onFinishInflate();
+        if (mDefaultFocusId != View.NO_ID) {
+            mDefaultFocusView = CarUiUtils.requireViewByRefId(this, mDefaultFocusId);
+        }
+    }
+
+    @Override
+    public boolean performAccessibilityAction(int action, Bundle arguments) {
+        // FocusArea is not focusable, so we focus on its descendant when handling ACTION_FOCUS.
+        if (action == ACTION_FOCUS) {
+            if (arguments == null) {
+                Log.e(TAG, "Must specify action type when performing ACTION_FOCUS on FocusArea");
+                return false;
+            }
+            @RotaryConstants.FocusActionType
+            int type = arguments.getInt(FOCUS_ACTION_TYPE, FOCUS_INVALID);
+            switch (type) {
+                case FOCUS_DEFAULT:
+                    // Move focus to the default focus (mDefaultFocusView), if any.
+                    if (mDefaultFocusView != null) {
+                        if (mDefaultFocusView.requestFocus()) {
+                            return true;
+                        }
+                        Log.e(TAG, "The default focus of the FocusArea can't take focus");
+                    }
+                    return false;
+                case FOCUS_FIRST:
+                    // Focus on the first focusable view in the FocusArea.
+                    return requestFocus();
+                default:
+                    Log.e(TAG, "Invalid action type " + type);
+                    return false;
+            }
+        }
+        return super.performAccessibilityAction(action, arguments);
     }
 
     @Override
diff --git a/car-ui-lib/src/com/android/car/ui/utils/RotaryConstants.java b/car-ui-lib/src/com/android/car/ui/utils/RotaryConstants.java
index cb418bb..e1c6fb8 100644
--- a/car-ui-lib/src/com/android/car/ui/utils/RotaryConstants.java
+++ b/car-ui-lib/src/com/android/car/ui/utils/RotaryConstants.java
@@ -16,6 +16,11 @@
 
 package com.android.car.ui.utils;
 
+import androidx.annotation.IntDef;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+
 /** Constants for the rotary controller. */
 public final class RotaryConstants {
     /**
@@ -23,14 +28,37 @@
      * horizontally.
      */
     public static final String ROTARY_HORIZONTALLY_SCROLLABLE =
-            "android.rotary.HORIZONTALLY_SCROLLABLE";
+            "com.android.car.ui.utils.HORIZONTALLY_SCROLLABLE";
 
     /**
      * Content description indicating that the rotary controller should scroll this view
      * vertically.
      */
     public static final String ROTARY_VERTICALLY_SCROLLABLE =
-            "android.rotary.VERTICALLY_SCROLLABLE";
+            "com.android.car.ui.utils.VERTICALLY_SCROLLABLE";
+
+    /** The key to store a FocusActionType in the Bundle. */
+    public static final String FOCUS_ACTION_TYPE = "com.android.car.ui.utils.FOCUS_ACTION_TYPE";
+
+    /** Value indicating that the ACTION_FOCUS hasn't specified what to focus. */
+    public static final int FOCUS_INVALID = 0;
+
+    /**
+     * Value indicating that the ACTION_FOCUS is meant to focus on the default focus in the
+     * FocusArea.
+     */
+    public static final int FOCUS_DEFAULT = 1;
+
+    /**
+     * Value indicating that the ACTION_FOCUS is meant to focus on the first focusable view in the
+     * FocusArea.
+     */
+    public static final int FOCUS_FIRST = 2;
+
+    @IntDef(flag = true, value = {FOCUS_DEFAULT, FOCUS_FIRST})
+    @Retention(RetentionPolicy.SOURCE)
+    public @interface FocusActionType {
+    }
 
     /** Prevent instantiation. */
     private RotaryConstants() {}
diff --git a/car-ui-lib/tests/unit/res/layout/focus_area_test_activity.xml b/car-ui-lib/tests/unit/res/layout/focus_area_test_activity.xml
index 405bbfe..4482848 100644
--- a/car-ui-lib/tests/unit/res/layout/focus_area_test_activity.xml
+++ b/car-ui-lib/tests/unit/res/layout/focus_area_test_activity.xml
@@ -16,17 +16,28 @@
   -->
 <LinearLayout
     xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
     android:layout_width="match_parent"
     android:layout_height="match_parent">
+    <View
+        android:focusable="true"
+        android:layout_width="10dp"
+        android:layout_height="10dp"/>
     <com.android.car.ui.TestFocusArea
         android:id="@+id/focus_area"
         android:layout_width="wrap_content"
-        android:layout_height="wrap_content">
+        android:layout_height="wrap_content"
+        app:defaultFocus="@+id/default_focus">
         <View
             android:id="@+id/child"
             android:focusable="true"
             android:layout_width="10dp"
             android:layout_height="10dp"/>
+        <View
+            android:id="@+id/default_focus"
+            android:focusable="true"
+            android:layout_width="10dp"
+            android:layout_height="10dp"/>
     </com.android.car.ui.TestFocusArea>
     <View
         android:id="@+id/non_child"
diff --git a/car-ui-lib/tests/unit/src/com/android/car/ui/FocusAreaTest.java b/car-ui-lib/tests/unit/src/com/android/car/ui/FocusAreaTest.java
index 8b2ddc1..fab32b7 100644
--- a/car-ui-lib/tests/unit/src/com/android/car/ui/FocusAreaTest.java
+++ b/car-ui-lib/tests/unit/src/com/android/car/ui/FocusAreaTest.java
@@ -16,8 +16,15 @@
 
 package com.android.car.ui;
 
-import static org.junit.Assert.assertTrue;
+import static android.view.accessibility.AccessibilityNodeInfo.ACTION_FOCUS;
 
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_ACTION_TYPE;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_DEFAULT;
+import static com.android.car.ui.utils.RotaryConstants.FOCUS_FIRST;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.os.Bundle;
 import android.view.View;
 
 import androidx.test.rule.ActivityTestRule;
@@ -42,6 +49,7 @@
     private FocusAreaTestActivity mActivity;
     private TestFocusArea mFocusArea;
     private View mChild;
+    private View mDefaultFocus;
     private View mNonChild;
 
     @Before
@@ -49,6 +57,7 @@
         mActivity = mActivityRule.getActivity();
         mFocusArea = mActivity.findViewById(R.id.focus_area);
         mChild = mActivity.findViewById(R.id.child);
+        mDefaultFocus = mActivity.findViewById(R.id.default_focus);
         mNonChild = mActivity.findViewById(R.id.non_child);
     }
 
@@ -90,9 +99,39 @@
         assertDrawMethodsCalled(latch);
     }
 
+    @Test
+    public void testFocusOnDefaultFocus() throws Exception {
+        assertThat(mDefaultFocus.isFocused()).isFalse();
+
+        Bundle bundle = new Bundle();
+        bundle.putInt(FOCUS_ACTION_TYPE, FOCUS_DEFAULT);
+        CountDownLatch latch = new CountDownLatch(1);
+        mFocusArea.post(() -> {
+            mFocusArea.performAccessibilityAction(ACTION_FOCUS, bundle);
+            latch.countDown();
+        });
+        latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS);
+        assertThat(mDefaultFocus.isFocused()).isTrue();
+    }
+
+    @Test
+    public void testFocusOnFirstFocusable() throws Exception {
+        assertThat(mChild.isFocused()).isFalse();
+
+        Bundle bundle = new Bundle();
+        bundle.putInt(FOCUS_ACTION_TYPE, FOCUS_FIRST);
+        CountDownLatch latch = new CountDownLatch(1);
+        mFocusArea.post(() -> {
+            mFocusArea.performAccessibilityAction(ACTION_FOCUS, bundle);
+            latch.countDown();
+        });
+        latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS);
+        assertThat(mChild.isFocused()).isTrue();
+    }
+
     private void assertDrawMethodsCalled(CountDownLatch latch) throws Exception {
         latch.await(WAIT_TIME_MS, TimeUnit.MILLISECONDS);
-        assertTrue(mFocusArea.onDrawCalled());
-        assertTrue(mFocusArea.drawCalled());
+        assertThat(mFocusArea.onDrawCalled()).isTrue();
+        assertThat(mFocusArea.drawCalled()).isTrue();
     }
 }