/* * Copyright (C) 2015 Google Inc. * * 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 com.google.android.apps.common.testing.accessibility.framework; import android.content.res.Resources; import android.graphics.Rect; import android.os.Build; import androidx.annotation.RequiresApi; import androidx.core.view.ViewCompat; import android.text.TextUtils; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.widget.AdapterView; import android.widget.Checkable; import android.widget.EditText; import android.widget.HorizontalScrollView; import android.widget.ScrollView; import android.widget.Spinner; import android.widget.TextView; import com.google.android.libraries.accessibility.utils.log.LogUtils; import java.util.HashSet; import java.util.Set; import org.checkerframework.checker.nullness.qual.Nullable; /** * This class provides a set of utilities used to evaluate accessibility properties and behaviors of * hierarchies of {@link View}s. */ public final class ViewAccessibilityUtils { private static final String TAG = "ViewA11yUtils"; private ViewAccessibilityUtils() {} /** * @param rootView The root of a View hierarchy * @return A Set containing the root view and all views below it in the hierarchy */ public static Set<View> getAllViewsInHierarchy(View rootView) { Set<View> allViews = new HashSet<>(); allViews.add(rootView); addAllChildrenToSet(rootView, allViews); return allViews; } /** See {@link View#isImportantForAccessibility()}. */ public static boolean isImportantForAccessibility(View view) { if (view == null) { return false; } if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { return view.isImportantForAccessibility(); } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) { // Prior to Jelly Bean, all Views were considered important for accessibility. return true; } else { // On APIs between 16 and 21, we must piece together accessibility importance from the // available properties. We return false incorrectly for some cases where unretrievable // listeners prevent us from determining importance. // If the developer marked the view as explicitly not important, it isn't. int mode = view.getImportantForAccessibility(); if ((mode == View.IMPORTANT_FOR_ACCESSIBILITY_NO) || (mode == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS)) { return false; } // No parent view can be hiding us. (APIs 19 to 21) ViewParent parent = view.getParent(); while (parent instanceof View) { if (((View) parent).getImportantForAccessibility() == View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) { return false; } parent = parent.getParent(); } // Interrogate the view's other properties to determine importance. return (mode == View.IMPORTANT_FOR_ACCESSIBILITY_YES) || isActionableForAccessibility(view) || hasListenersForAccessibility(view) || (view.getAccessibilityNodeProvider() != null) || (ViewCompat.getAccessibilityLiveRegion(view) != ViewCompat.ACCESSIBILITY_LIVE_REGION_NONE); } } /** * Determines if the supplied {@link View} is actionable for accessibility purposes. * * @param view The {@link View} to evaluate * @return {@code true} if {@code view} is considered actionable for accessibility */ public static boolean isActionableForAccessibility(View view) { if (view == null) { return false; } return (view.isClickable() || view.isLongClickable() || view.isFocusable()); } /** * Determines if the supplied {@link View} is visible to the user, which requires that it be * marked visible, that all its parents are visible, that it and all parents have alpha greater * than 0, and that it has non-zero size. This code attempts to replicate the protected method * {@code View.isVisibleToUser}. * * @param view The {@link View} to evaluate * @return {@code true} if {@code view} is visible to the user */ @RequiresApi(Build.VERSION_CODES.HONEYCOMB) // Uses View#getAlpha public static boolean isVisibleToUser(View view) { if (view == null) { return false; } Object current = view; while (current instanceof View) { View currentView = (View) current; if ((currentView.getAlpha() <= 0) || (currentView.getVisibility() != View.VISIBLE)) { return false; } current = currentView.getParent(); } return view.getGlobalVisibleRect(new Rect()); } /** * Determines if the supplied {@link View} would be focused during navigation operations with a * screen reader. * * @param view The {@link View} to evaluate * @return {@code true} if a screen reader would choose to place accessibility focus on {@code * view}, {@code false} otherwise. */ @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) public static boolean shouldFocusView(View view) { if (view == null) { return false; } if (!isVisibleToUser(view) || !isImportantForAccessibility(view)) { // We don't focus views that are not visible or not important for accessibility return false; } if (isAccessibilityFocusable(view)) { if (!(view instanceof ViewGroup) || ((view instanceof ViewGroup) && !hasAnyImportantDescendant((ViewGroup) view))) { // Leaves that are accessibility focusable always gain focus regardless of presence of a // spoken description. This allows unlabeled, but still actionable, widgets to be activated // by the user. return true; } else if (isSpeakingView(view)) { // The view (or its grouped non-actionable children) have content to speak. return true; } return false; } if (hasText(view) && !hasFocusableAncestor(view)) { return true; } return false; } /** * Find a {@code View}, if one exists, that labels a given {@code View}. * * @param view The target of the labelFor. * @return The {@code View} that is the labelFor the specified view. {@code null} if nothing * labels it. */ public static @Nullable View getLabelForView(View view) { if ((Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1)) { /* Earlier versions don't support labelFor */ return null; } int idToFind = view.getId(); if (idToFind == View.NO_ID) { /* Views lacking IDs can't be labeled by others */ return null; } /* * Search for the "nearest" View that labels this one, since IDs aren't unique. This code * follows the framework code by DFSing first children, then siblings, then parent and its * siblings, etc. childToSkip is passed in to the helper method to avoid repeating consideration * of a View when examining its parent. */ View childToSkip = null; while (true) { View labelingView = lookForLabelForViewInViewAndChildren(view, childToSkip, idToFind); if (labelingView != null) { return labelingView; } ViewParent parent = view.getParent(); childToSkip = view; if (!(parent instanceof View)) { return null; } view = (View) parent; } } /** * @param view The {@link View} to evaluate * @return {@link Boolean#TRUE} if {@code view} is considered editable, {@link Boolean#FALSE} if * not, or {@code null} if this information cannot be determined. */ public static @Nullable Boolean isViewEditable(View view) { if (view == null) { return null; } if (view instanceof EditText) { return true; } if (view instanceof TextView) { return ((TextView) view).getEditableText() != null; } return false; } /** * @param view The {@link View} to identify * @return a {@link String} resource name for the provided {@code view} in the format * "package:type/entry", or {@code null} if a resource name does not exist or cannot be * resolved. */ public static @Nullable String getResourceNameForView(View view) { if ((view == null) || (view.getId() == View.NO_ID) || (view.getResources() == null)) { return null; } if (!isViewIdGenerated(view.getId())) { try { return view.getResources().getResourceName(view.getId()); } catch (Resources.NotFoundException nfe) { // Do nothing -- Potential test environment issue LogUtils.w(TAG, "Unable to resolve resource name from view ID."); } } return null; } /** * Determines if a View's resource identifier was generated at runtime. * * @param resourceId to evaluate * @return {@code true} if the identifier was generated a runtime, or {@code false} if generated * by AAPT. */ public static boolean isViewIdGenerated(int resourceId) { return (resourceId & 0xFF000000) == 0 && (resourceId & 0x00FFFFFF) != 0; } @RequiresApi(Build.VERSION_CODES.JELLY_BEAN_MR1) // Calls View#getLabelFor private static @Nullable View lookForLabelForViewInViewAndChildren( View view, @Nullable View childToSkip, int idToFind) { if (view.getLabelFor() == idToFind) { return view; } if (!(view instanceof ViewGroup)) { return null; } ViewGroup viewGroup = (ViewGroup) view; for (int i = 0; i < viewGroup.getChildCount(); ++i) { View child = viewGroup.getChildAt(i); if (!child.equals(childToSkip)) { View labelingView = lookForLabelForViewInViewAndChildren(child, null, idToFind); if (labelingView != null) { return labelingView; } } } return null; } /** * Add all children in the view tree rooted at rootView to a set * * @param rootView The root of the view tree desired * @param theSet The set to add views to */ private static void addAllChildrenToSet(View rootView, Set<View> theSet) { if (!(rootView instanceof ViewGroup)) { return; } ViewGroup rootViewGroup = (ViewGroup) rootView; for (int i = 0; i < rootViewGroup.getChildCount(); ++i) { View nextView = rootViewGroup.getChildAt(i); theSet.add(nextView); addAllChildrenToSet(nextView, theSet); } } /** * Determines if the supplied {@link View} has any retrievable listeners that might qualify the * view to be important for accessibility purposes. * * <p>NOTE: This method tries to behave like the hidden {@code * View#hasListenersForAccessibility()} method, but cannot retrieve several of the listeners. * * @param view The {@link View} to evaluate * @return {@code true} if any of the retrievable listeners on {@code view} might qualify it to be * important for accessibility purposes. */ private static boolean hasListenersForAccessibility(View view) { if (view == null) { return false; } boolean result = false; // Ideally, here we check for... // TouchDelegate result |= view.getTouchDelegate() != null; // OnKeyListener, OnTouchListener, OnGenericMotionListener, OnHoverListener, OnDragListener // aren't accessible to us. return result; } /** * Determines if the supplied {@link View} has an ancestor which meets the criteria for gaining * accessibility focus. * * <p>NOTE: This method only evaluates ancestors which may be considered important for * accessibility and explicitly does not evaluate the supplied {@code view}. * * @param view The {@link View} to evaluate * @return {@code true} if an ancestor of {@code view} may gain accessibility focus, {@code false} * otherwise */ @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) // Calls View#getParentForAccessibility private static boolean hasFocusableAncestor(View view) { if (view == null) { return false; } ViewParent parent = view.getParentForAccessibility(); if (!(parent instanceof View)) { return false; } if (isAccessibilityFocusable((View) parent)) { return true; } return hasFocusableAncestor((View) parent); } /** * Determines if the supplied {@link View} meets the criteria for gaining accessibility focus. * * @param view The {@link View} to evaluate * @return {@code true} if it is possible for {@code view} to gain accessibility focus, {@code * false} otherwise. */ @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) // Calls isChildOfScrollableContainer private static boolean isAccessibilityFocusable(View view) { if (view == null) { return false; } if (view.getVisibility() != View.VISIBLE) { return false; } if (!isImportantForAccessibility(view)) { return false; } if (isActionableForAccessibility(view)) { return true; } return isChildOfScrollableContainer(view) && isSpeakingView(view); } /** * Determines if the supplied {@link View} is a top-level item within a scrollable container. * * @param view The {@link View} to evaluate * @return {@code true} if {@code view} is a top-level view within a scrollable container, {@code * false} otherwise */ @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) // Calls View#getParentForAccessibility private static boolean isChildOfScrollableContainer(View view) { if (view == null) { return false; } ViewParent viewParent = view.getParentForAccessibility(); if ((viewParent == null) || !(viewParent instanceof View)) { return false; } View parent = (View) viewParent; if (parent.isScrollContainer()) { return true; } // Specifically check for parents that are AdapterView, ScrollView, or HorizontalScrollView, but // exclude Spinners, which are a special case of AdapterView. return (((parent instanceof AdapterView) || (parent instanceof ScrollView) || (parent instanceof HorizontalScrollView)) && !(parent instanceof Spinner)); } /** * Determines if the supplied {@link View} is one which would produce speech if it were to gain * accessibility focus. * * <p>NOTE: This method also evaluates the subtree of the {@code view} for children that should be * included in {@code view}'s spoken description. * * @param view The {@link View} to evaluate * @return {@code true} if a spoken description for {@code view} was determined, {@code false} * otherwise. */ @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) // Calls hasNonActionableSpeakingChildren private static boolean isSpeakingView(View view) { if (hasText(view)) { return true; } else if (view instanceof Checkable) { // Special case for checkable items, which screen readers may describe without text return true; } else if (hasNonActionableSpeakingChildren(view)) { return true; } return false; } /** * Determines if the supplied {@link View} has child view(s) which are not independently * accessibility focusable and also have a spoken description. Put another way, this method * determines if {@code view} has at least one child which should be included in {@code view}'s * spoken description if {@code view} were to be accessibility focused. * * @param view The {@link View} to evaluate * @return {@code true} if {@code view} has non-actionable speaking children within its subtree */ @RequiresApi(Build.VERSION_CODES.JELLY_BEAN) // Calls isAccessibilityFocusable private static boolean hasNonActionableSpeakingChildren(View view) { if ((view == null) || !(view instanceof ViewGroup)) { return false; } ViewGroup group = (ViewGroup) view; for (int i = 0; i < group.getChildCount(); ++i) { View child = group.getChildAt(i); if ((child == null) || (child.getVisibility() != View.VISIBLE) || isAccessibilityFocusable(child)) { continue; } if (isImportantForAccessibility(child) && isSpeakingView(child)) { return true; } } return false; } /** * Determines if the supplied {@link View} has a contentDescription or text. * * @param view The {@link View} to evaluate * @return {@code true} if {@code view} has a contentDescription or text. */ private static boolean hasText(View view) { if (!TextUtils.isEmpty(view.getContentDescription())) { return true; } else if (view instanceof TextView) { return !TextUtils.isEmpty(((TextView) view).getText()); } return false; } /** * Determines if the provided {@code group} has any descendant, direct or indirect, which is * considered important for accessibility. This is useful in determining whether or not the * Android framework will attempt to reparent any child in the subtree as a direct descendant of * {@code group} while converting the hierarchy to an accessibility API representation. * * @param group the {@link ViewGroup} to evaluate * @return {@code true} if any child in {@code group}'s subtree is considered important for * accessibility, {@code false} otherwise */ private static boolean hasAnyImportantDescendant(ViewGroup group) { if (group == null) { return false; } for (int i = 0; i < group.getChildCount(); ++i) { View child = group.getChildAt(i); if (isImportantForAccessibility(child)) { return true; } if (child instanceof ViewGroup) { if (hasAnyImportantDescendant((ViewGroup) child)) { return true; } } } return false; } }