Allowing association between a view and its label for accessibility.

1. For accessibility purposes it is important to be able to associate
   a view with content with a view that labels it. For example, if
   an accessibility service knows that a TextView is associated with
   an EditText, it can provide much richer feedback.

   This change adds APIs for setting a view to be the label for another
   one and setting the label for a view, i.e. the reverse association.

bug:5016937

Change-Id: I7b837265c5ed9302e3ce352396dc6e88413038b5
diff --git a/core/java/android/view/View.java b/core/java/android/view/View.java
index 8f77663..03f9b72 100644
--- a/core/java/android/view/View.java
+++ b/core/java/android/view/View.java
@@ -2759,6 +2759,23 @@
     private CharSequence mContentDescription;
 
     /**
+     * Specifies the id of a view for which this view serves as a label for
+     * accessibility purposes.
+     */
+    private int mLabelForId = View.NO_ID;
+
+    /**
+     * Predicate for matching labeled view id with its label for
+     * accessibility purposes.
+     */
+    private MatchLabelForPredicate mMatchLabelForPredicate;
+
+    /**
+     * Predicate for matching a view by its id.
+     */
+    private MatchIdPredicate mMatchIdPredicate;
+
+    /**
      * Cache the paddingRight set by the user to append to the scrollbar's size.
      *
      * @hide
@@ -3370,6 +3387,9 @@
                 case com.android.internal.R.styleable.View_contentDescription:
                     setContentDescription(a.getString(attr));
                     break;
+                case com.android.internal.R.styleable.View_labelFor:
+                    setLabelFor(a.getResourceId(attr, NO_ID));
+                    break;
                 case com.android.internal.R.styleable.View_soundEffectsEnabled:
                     if (!a.getBoolean(attr, true)) {
                         viewFlagValues &= ~SOUND_EFFECTS_ENABLED;
@@ -4837,6 +4857,28 @@
             info.setParent((View) parent);
         }
 
+        if (mID != View.NO_ID) {
+            View rootView = getRootView();
+            if (rootView == null) {
+                rootView = this;
+            }
+            View label = rootView.findLabelForView(this, mID);
+            if (label != null) {
+                info.setLabeledBy(label);
+            }
+        }
+
+        if (mLabelForId != View.NO_ID) {
+            View rootView = getRootView();
+            if (rootView == null) {
+                rootView = this;
+            }
+            View labeled = rootView.findViewInsideOutShouldExist(this, mLabelForId);
+            if (labeled != null) {
+                info.setLabelFor(labeled);
+            }
+        }
+
         info.setVisibleToUser(isVisibleToUser());
 
         info.setPackageName(mContext.getPackageName());
@@ -4888,6 +4930,14 @@
         }
     }
 
+    private View findLabelForView(View view, int labeledId) {
+        if (mMatchLabelForPredicate == null) {
+            mMatchLabelForPredicate = new MatchLabelForPredicate();
+        }
+        mMatchLabelForPredicate.mLabeledId = labeledId;
+        return findViewByPredicateInsideOut(view, mMatchLabelForPredicate);
+    }
+
     /**
      * Computes whether this view is visible to the user. Such a view is
      * attached, visible, all its predecessors are visible, it is not clipped
@@ -5059,6 +5109,32 @@
     }
 
     /**
+     * Gets the id of a view for which this view serves as a label for
+     * accessibility purposes.
+     *
+     * @return The labeled view id.
+     */
+    @ViewDebug.ExportedProperty(category = "accessibility")
+    public int getLabelFor() {
+        return mLabelForId;
+    }
+
+    /**
+     * Sets the id of a view for which this view serves as a label for
+     * accessibility purposes.
+     *
+     * @param id The labeled view id.
+     */
+    @RemotableViewMethod
+    public void setLabelFor(int id) {
+        mLabelForId = id;
+        if (mLabelForId != View.NO_ID
+                && mID == View.NO_ID) {
+            mID = generateViewId();
+        }
+    }
+
+    /**
      * Invoked whenever this view loses focus, either by losing window focus or by losing
      * focus within its window. This method can be used to clear any state tied to the
      * focus. For instance, if a button is held pressed with the trackball and the window
@@ -6110,17 +6186,14 @@
         return null;
     }
 
-    private View findViewInsideOutShouldExist(View root, final int childViewId) {
-        View result = root.findViewByPredicateInsideOut(this, new Predicate<View>() {
-            @Override
-            public boolean apply(View t) {
-                return t.mID == childViewId;
-            }
-        });
-
+    private View findViewInsideOutShouldExist(View root, int id) {
+        if (mMatchIdPredicate == null) {
+            mMatchIdPredicate = new MatchIdPredicate();
+        }
+        mMatchIdPredicate.mId = id;
+        View result = root.findViewByPredicateInsideOut(this, mMatchIdPredicate);
         if (result == null) {
-            Log.w(VIEW_LOG_TAG, "couldn't find next focus view specified "
-                    + "by user for id " + childViewId);
+            Log.w(VIEW_LOG_TAG, "couldn't find view with id " + id);
         }
         return result;
     }
@@ -14922,6 +14995,9 @@
      */
     public void setId(int id) {
         mID = id;
+        if (mID == View.NO_ID && mLabelForId != View.NO_ID) {
+            mID = generateViewId();
+        }
     }
 
     /**
@@ -18008,4 +18084,22 @@
             return null;
         }
     }
+
+    private class MatchIdPredicate implements Predicate<View> {
+        public int mId;
+
+        @Override
+        public boolean apply(View view) {
+            return (view.mID == mId);
+        }
+    }
+
+    private class MatchLabelForPredicate implements Predicate<View> {
+        private int mLabeledId;
+
+        @Override
+        public boolean apply(View view) {
+            return (view.mLabelForId == mLabeledId);
+        }
+    }
 }