Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Override default Talkback automatic content grouping and generate a custom contentDescription #33690

Closed
wants to merge 61 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
61 commits
Select commit Hold shift + click to select a range
05a2bc3
draft
fabOnReact Apr 6, 2022
4ffd15d
draft
fabOnReact Apr 11, 2022
5e1622f
Merge branch 'main' into automatic-content-grouping
fabOnReact Apr 13, 2022
28f86a3
draft
fabOnReact Apr 14, 2022
963cd17
Merge branch 'main' into automatic-content-grouping
fabOnReact Apr 15, 2022
69473de
draft solution to announce content
fabOnReact Apr 21, 2022
bdabc4c
remove previous draft solution
fabOnReact Apr 21, 2022
ae66001
draft commit
fabOnReact Apr 22, 2022
7c287a4
remove getRole logic
fabOnReact Apr 22, 2022
6d73cd4
adding comments to methods
fabOnReact Apr 22, 2022
2a3a836
reorganize methods alph order
fabOnReact Apr 22, 2022
982fb20
revert changes to ButtonExample
fabOnReact Apr 22, 2022
e0e1629
Conditions that should trigger the custom contentDescription
fabOnReact Apr 25, 2022
a22c435
remove check on accessibilityLabel
fabOnReact Apr 25, 2022
07bac60
remove duplicate logic and add condition
fabOnReact Apr 26, 2022
821e623
avoid setContentDescription if talkback returns null
fabOnReact Apr 26, 2022
35190c0
adding GRID role to RADelegate
fabOnReact Apr 26, 2022
986b10b
adding TouchableNativeFeedback with non-acc child example
fabOnReact Apr 27, 2022
28ed277
adding TouchableNativeFeedback with non-acc child second example
fabOnReact Apr 27, 2022
e1521cf
adding @Nullable to description variable
fabOnReact Apr 27, 2022
90dd4a8
adding accessibilityAction to Text example
fabOnReact Apr 27, 2022
5e7aaf3
Merge branch 'main' into automatic-content-grouping
fabOnReact Apr 27, 2022
1f06997
Merge branch 'main' into automatic-content-grouping
fabOnReact Apr 27, 2022
a05b912
Returns a cached instance if such is available or a new one is create.
fabOnReact Apr 27, 2022
64bb48a
remove not required logic
fabOnReact Apr 28, 2022
7cf8fce
Merge branch 'main' into automatic-content-grouping
fabOnReact May 24, 2022
0b620e0
adding check on stateDescription in isSpeakingNode
fabOnReact May 24, 2022
45e3040
check for isScreenReaderFocusable and hasHint
fabOnReact May 26, 2022
5488aba
avoid adding/removing final delimiter
fabOnReact May 26, 2022
751b83e
test child with accessibilityValue, state, label
fabOnReact May 26, 2022
e3edefb
add addStateSegments to custom announcement
fabOnReact May 26, 2022
a0fb221
adding accRole to example child component
fabOnReact May 26, 2022
d2c8bfb
roleDescription and disabled attribute in node child
fabOnReact May 27, 2022
334b24d
Merge branch 'main' into automatic-content-grouping
fabOnReact May 27, 2022
9d72a70
adding test case for child EditText node
fabOnReact May 27, 2022
9d1bf23
EditText child component
fabOnReact May 27, 2022
abed89e
minimum repr ex https://github.com/facebook/react-native/pull/33690/c…
fabOnReact May 30, 2022
b90fff4
check that text/description are not empty
fabOnReact Jun 1, 2022
f1852f1
updating TextInput example with value different from placeholder
fabOnReact Jun 1, 2022
6bd06c4
Merge branch 'main' into automatic-content-grouping
fabOnReact Jun 1, 2022
2dc702e
fix circleci tests
fabOnReact Jun 1, 2022
8622c13
moving getRoleDescription to another branch
fabOnReact Jun 1, 2022
e4b31c3
using hasText instead of adding checks
fabOnReact Jun 1, 2022
251df27
Revert "moving getRoleDescription to another branch"
fabOnReact Jun 2, 2022
8aa5b25
adding child accessibilityRole
fabOnReact Jun 3, 2022
b804bd0
removing accessibilityRole functionality as not required from specs
fabOnReact Jun 3, 2022
f77a4a2
add check on missingTextOrDescription
fabOnReact Jun 3, 2022
44122ff
Moving not required logic to a separate branch
fabOnReact Jun 3, 2022
cb0e1b6
updating test cases descriptions
fabOnReact Jun 6, 2022
0c79fc8
adding test cases scenarios
fabOnReact Jun 6, 2022
46826d8
removing test case for EditText
fabOnReact Jun 6, 2022
ad6732a
fix flow circleci error
fabOnReact Jun 6, 2022
d5f4c9c
example - getStateDescription returns allways null
fabOnReact Jun 7, 2022
c17f00e
One of the child has accessibilityState (hasStateDescription triggers…
fabOnReact Jun 8, 2022
087e642
One of the child has accessibilityHint (hasText trigers the announcem…
fabOnReact Jun 8, 2022
51f23f0
remove import of Switch in AccessibilityExample
fabOnReact Jun 8, 2022
8b84521
fix AccessibilityExample actions
fabOnReact Jun 8, 2022
dc0cc5a
Merge branch 'main' into automatic-content-grouping
fabOnReact Aug 11, 2022
eac460e
Merge branch 'main' into automatic-content-grouping
fabOnReact Aug 30, 2022
ddfb345
updating examples in AccessibilityExample
fabOnReact Aug 30, 2022
11c4b00
improving examples titles
fabOnReact Aug 30, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,13 @@
import android.text.Layout;
import android.text.Spannable;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ClickableSpan;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.widget.EditText;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
Expand All @@ -41,6 +44,7 @@
import com.facebook.react.bridge.ReadableType;
import com.facebook.react.bridge.UIManager;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.uimanager.ReactAccessibilityDelegate.AccessibilityRole;
import com.facebook.react.uimanager.events.Event;
import com.facebook.react.uimanager.events.EventDispatcher;
import com.facebook.react.uimanager.util.ReactFindViewUtil;
Expand All @@ -59,6 +63,8 @@ public class ReactAccessibilityDelegate extends ExploreByTouchHelper {
private static int sCounter = 0x3f000000;
private static final int TIMEOUT_SEND_ACCESSIBILITY_EVENT = 200;
private static final int SEND_EVENT = 1;
private static final String delimiter = ", ";
private static final int delimiterLength = delimiter.length();

public static final HashMap<String, Integer> sActionIdMap = new HashMap<>();

Expand Down Expand Up @@ -319,6 +325,17 @@ public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCo
if (testId != null) {
info.setViewIdResourceName(testId);
}
boolean missingContentDescription = TextUtils.isEmpty(info.getContentDescription());
boolean missingText = TextUtils.isEmpty(info.getText());
boolean missingTextAndDescription = missingContentDescription && missingText;
boolean hasContentToAnnounce =
accessibilityActions != null
|| accessibilityState != null
|| accessibilityLabelledBy != null
|| accessibilityRole != null;
if (missingTextAndDescription && hasContentToAnnounce) {
info.setContentDescription(getTalkbackDescription(host, info));
}
}

@Override
Expand Down Expand Up @@ -726,4 +743,295 @@ private static class AccessibleLink {

return null;
}

/**
* Determines if the supplied {@link View} and {@link AccessibilityNodeInfoCompat} has any
* children which are not independently accessibility focusable and also have a spoken
* description.
*
* <p>NOTE: Accessibility services will include these children's descriptions in the closest
* focusable ancestor.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it has any non-actionable speaking descendants within its subtree
*/
public static boolean hasNonActionableSpeakingDescendants(
@Nullable AccessibilityNodeInfoCompat node, @Nullable View view) {

if (node == null || view == null || !(view instanceof ViewGroup)) {
return false;
}

final ViewGroup viewGroup = (ViewGroup) view;
for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
final View childView = viewGroup.getChildAt(i);

if (childView == null) {
continue;
}

final AccessibilityNodeInfoCompat childNode = AccessibilityNodeInfoCompat.obtain();
try {
ViewCompat.onInitializeAccessibilityNodeInfo(childView, childNode);

if (!childNode.isVisibleToUser()) {
continue;
}

if (isAccessibilityFocusable(childNode, childView)) {
continue;
}

if (isSpeakingNode(childNode, childView)) {
return true;
}
} finally {
if (childNode != null) {
childNode.recycle();
}
}
}

return false;
}

/**
* Returns whether the node has valid RangeInfo.
*
* @param node The node to check.
* @return Whether the node has valid RangeInfo.
*/
public static boolean hasValidRangeInfo(@Nullable AccessibilityNodeInfoCompat node) {
if (node == null) {
return false;
}

@Nullable final RangeInfoCompat rangeInfo = node.getRangeInfo();
if (rangeInfo == null) {
return false;
}

final float maxProgress = rangeInfo.getMax();
final float minProgress = rangeInfo.getMin();
final float currentProgress = rangeInfo.getCurrent();
final float diffProgress = maxProgress - minProgress;
return (diffProgress > 0.0f)
&& (currentProgress >= minProgress)
&& (currentProgress <= maxProgress);
}

/**
* Returns whether the specified node has state description.
*
* @param node The node to check.
* @return {@code true} if the node has state description.
*/
private static boolean hasStateDescription(@Nullable AccessibilityNodeInfoCompat node) {
return node != null
&& (!TextUtils.isEmpty(node.getStateDescription())
|| node.isCheckable()
|| hasValidRangeInfo(node));
}

/**
* Returns whether the supplied {@link View} and {@link AccessibilityNodeInfoCompat} would produce
* spoken feedback if it were accessibility focused. NOTE: not all speaking nodes are focusable.
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it meets the criterion for producing spoken feedback
*/
public static boolean isSpeakingNode(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing some features that newer versions of Talkback take into account, such as stateDescription.

Check out Talkback's own implementation here for reference:
https://github.com/google/talkback/blob/6c0b475b7f52469e309e51bfcc13de58f18176ff/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java#L905

Copy link
Contributor Author

@fabOnReact fabOnReact Jun 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A node with stateDescription is announced with TalkBack screenreader. The check on hasStateDescription is added to isSpeakingNode. isSpeakingNode will return true for nodes that have a stateDescription.

Copy link
Contributor Author

@fabOnReact fabOnReact Jun 6, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that getStateDescription returns null even when adding accessibilityState. Calling setStateDescription will update the state description, which is not set via the react accessibilityState or other props.

Seems that the method setViewState does not call setStateDescription.

I will further investigate tomorrow.

View#setStateDescription

    /**
     * Sets the {@link View}'s state description.
     * <p>
     * A state description briefly describes the states of the view and is primarily used
     * for accessibility support to determine how the states of a view should be presented to
     * the user. It is a supplement to the boolean states (for example, checked/unchecked) and
     * it is used for customized state description (for example, "wifi, connected, three bars").
     * State description changes frequently while content description should change less often.
     * State description should be localized. For android widgets which have default state
     * descriptions, app developers can call this method to override the state descriptions.
     * Setting state description to null restores the default behavior.
     *
     * @param stateDescription The state description.
     * @see #getStateDescription()
     */
    @RemotableViewMethod
    public void setStateDescription(@Nullable CharSequence stateDescription) {
        if (mStateDescription == null) {
            if (stateDescription == null) {
                return;
            }
        } else if (mStateDescription.equals(stateDescription)) {
            return;
        }
        mStateDescription = stateDescription;
        if (!TextUtils.isEmpty(stateDescription)
                && getImportantForAccessibility() == IMPORTANT_FOR_ACCESSIBILITY_AUTO) {
            setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_YES);
        }
        if (AccessibilityManager.getInstance(mContext).isEnabled()) {
            AccessibilityEvent event = AccessibilityEvent.obtain();
            event.setEventType(AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED);
            event.setContentChangeTypes(AccessibilityEvent.CONTENT_CHANGE_TYPE_STATE_DESCRIPTION);
            sendAccessibilityEventUnchecked(event);
        }
    }

This is the implementation of TalkBack hasStateDescription.

The following fields of the view are set with the accessibilityState:

setCheckable

https://github.com/aosp-mirror/platform_frameworks_base/blob/19e53cfdc8a5c6ef45c0adf2dd239576ddce5822/core/java/android/view/accessibility/AccessibilityNodeInfo.java#L2008

  /**
   * Sets whether this node is checkable.
   * <p>
   *   <strong>Note:</strong> Cannot be called from an
   *   {@link android.accessibilityservice.AccessibilityService}.
   *   This class is made immutable before being delivered to an AccessibilityService.
   * </p>
   *
   * @param checkable True if the node is checkable.
   *
   * @throws IllegalStateException If called from an AccessibilityService.
   */
  public void setCheckable(boolean checkable) {
      setBooleanProperty(BOOLEAN_PROPERTY_CHECKABLE, checkable);
  }

setEnabled

https://github.com/aosp-mirror/platform_frameworks_base/blob/19e53cfdc8a5c6ef45c0adf2dd239576ddce5822/core/java/android/view/accessibility/AccessibilityNodeInfo.java#L2227

    /**
     * Sets whether this node is enabled.
     * <p>
     *   <strong>Note:</strong> Cannot be called from an
     *   {@link android.accessibilityservice.AccessibilityService}.
     *   This class is made immutable before being delivered to an AccessibilityService.
     * </p>
     *
     * @param enabled True if the node is enabled.
     *
     * @throws IllegalStateException If called from an AccessibilityService.
     */
    public void setEnabled(boolean enabled) {
        setBooleanProperty(BOOLEAN_PROPERTY_ENABLED, enabled);
    }

The implementation of hasStateDescription is still valid

Copy link
Contributor Author

@fabOnReact fabOnReact Jun 8, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One of the child has accessibilityState (hasStateDescription triggers the announcement)

@blavalla The below test case was recorded with the example included in commit fabOnReact@c17f00e

#33690 (comment)

The test verifies that:

  • node with accessibilityState.checkable is added to the talkback announcement
  • node without accessibilityState.checkable, text or accessibilityLabel are not added to the talkback announcement

as explained in comment #33690 (comment)

I noticed that getStateDescription returns null even when adding accessibilityState. Calling setStateDescription will update the state description, which is not set via the react accessibilityState or other props.

View#onInitializeAccessibilityNodeInfoInternal

    public void onInitializeAccessibilityNodeInfoInternal(AccessibilityNodeInfo info) {
        if (mAttachInfo == null) {
            return;
        }

        Rect bounds = mAttachInfo.mTmpInvalRect;

        getDrawingRect(bounds);
        info.setBoundsInParent(bounds);

        getBoundsOnScreen(bounds, true);
        info.setBoundsInScreen(bounds);

        ViewParent parent = getParentForAccessibility();
        if (parent instanceof View) {
            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 ((mAttachInfo.mAccessibilityFetchFlags
                    & AccessibilityNodeInfo.FLAG_REPORT_VIEW_IDS) != 0
                    && Resources.resourceHasPackage(mID)) {
                try {
                    String viewId = getResources().getResourceName(mID);
                    info.setViewIdResourceName(viewId);
                } catch (Resources.NotFoundException nfe) {
                    /* ignore */
                }
            }
        }

        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);
            }
        }

        if (mAccessibilityTraversalBeforeId != View.NO_ID) {
            View rootView = getRootView();
            if (rootView == null) {
                rootView = this;
            }
            View next = rootView.findViewInsideOutShouldExist(this,
                    mAccessibilityTraversalBeforeId);
            if (next != null && next.includeForAccessibility()) {
                info.setTraversalBefore(next);
            }
        }

        if (mAccessibilityTraversalAfterId != View.NO_ID) {
            View rootView = getRootView();
            if (rootView == null) {
                rootView = this;
            }
            View next = rootView.findViewInsideOutShouldExist(this,
                    mAccessibilityTraversalAfterId);
            if (next != null && next.includeForAccessibility()) {
                info.setTraversalAfter(next);
            }
        }

        info.setVisibleToUser(isVisibleToUser());

        info.setImportantForAccessibility(isImportantForAccessibility());
        info.setPackageName(mContext.getPackageName());
        info.setClassName(getAccessibilityClassName());
        info.setStateDescription(getStateDescription());

Seems that the method setViewState does not call setStateDescription.

sourcecode from test case

<View accessible={true} accessibilityRole="button">
  <Text
    accessible={false}
    accessibilityLabel="label for text number 1">
    Text number 1
  </Text>
  <Text
    style={{backgroundColor: 'red', height: 40, width: 40}}
    accessible={false}
    accessibilityState={{checked: true}}
    accessibilityLabel="label for last child"
    accessibilityRole="image"
  />
</View>

first video test

XRecorder_08062022_132543.mp4

verifying that nodes with accessibilityState.checkable are added to the talkback announcement (enable audio for explanation)

2022-08-11.21-34-01.mp4

verifying that nodes without accessibilityState.checkable are NOT added to the talkback announcement (enable audio for explanation)

2022-08-11.21-31-06.mp4

@Nullable AccessibilityNodeInfoCompat node, @Nullable View view) {
if (node == null || view == null) {
return false;
}

final int important = ViewCompat.getImportantForAccessibility(view);
if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS
|| (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO && node.getChildCount() <= 0)) {
return false;
}

return hasText(node)
|| hasStateDescription(node)
|| node.isCheckable()
|| hasNonActionableSpeakingDescendants(node, view);
}

public static boolean hasText(@Nullable AccessibilityNodeInfoCompat node) {
return node != null
&& node.getCollectionInfo() == null
&& (!TextUtils.isEmpty(node.getText())
|| !TextUtils.isEmpty(node.getContentDescription())
|| !TextUtils.isEmpty(node.getHintText()));
}

/**
* Determines if the provided {@link View} and {@link AccessibilityNodeInfoCompat} meet the
* criteria for gaining accessibility focus.
*
* <p>Note: this is evaluating general focusability by accessibility services, and does not mean
* this view will be guaranteed to be focused by specific services such as Talkback. For Talkback
* focusability, see {@link #isTalkbackFocusable(View)}
*
* @param view The {@link View} to evaluate
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if it is possible to gain accessibility focus
*/
public static boolean isAccessibilityFocusable(
@Nullable AccessibilityNodeInfoCompat node, @Nullable View view) {
if (node == null || view == null) {
return false;
}

// Never focus invisible nodes.
if (!node.isVisibleToUser()) {
return false;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Contributor Author

@fabOnReact fabOnReact Jun 1, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added the check isScreenReaderFocusable in isAccessibilityFocusable.

The method is used by:

  • getTalkbackDescription to check if the children nodes are focusable from TalkBack screenreader.
  • hasNonActionableSpeakingDescendants to detect speaking descendants

Copy link
Contributor Author

@fabOnReact fabOnReact Aug 15, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@blavalla I added the check with commit 45e3040.

https://developer.android.com/reference/android/view/View#setScreenReaderFocusable(boolean)

Sets whether this View should be a focusable element for screen readers and include non-focusable Views from its subtree when providing feedback. Note: this is similar to using {@code android:focusable}, but does not impact input focus behavior.

https://developer.android.com/reference/android/view/View#attr_android:focusable

android:focusable
Controls whether a view can take focus. By default, this is "auto" which lets the framework determine whether a user can move focus to a view. By setting this attribute to true the view is allowed to take focus. By setting it to "false" the view will not take focus. This value does not impact the behavior of directly calling View.requestFocus(), which will always request focus regardless of this view. It only impacts where focus navigation will try to move focus.

https://github.com/facebook/react-native/pull/24359/files#r288296027

clickable was renamed to focusable
https://reactnative.dev/docs/next/view#focusable-android

#24359 (review)

anything that has an onClick or onPress listener should already be setting the View's focusable and clickable attributes to true.
https://developer.android.com/reference/android/view/View#setOnClickListener(android.view.View.OnClickListener)

// Always focus "actionable" nodes.
return node.isScreenReaderFocusable() || isActionableForAccessibility(node);
}

/**
* Returns whether a node is actionable. That is, the node supports one of {@link
* AccessibilityNodeInfoCompat#isClickable()}, {@link AccessibilityNodeInfoCompat#isFocusable()},
* or {@link AccessibilityNodeInfoCompat#isLongClickable()}.
*
* @param node The {@link AccessibilityNodeInfoCompat} to evaluate
* @return {@code true} if node is actionable.
*/
public static boolean isActionableForAccessibility(@Nullable AccessibilityNodeInfoCompat node) {
if (node == null) {
return false;
}

if (node.isClickable() || node.isLongClickable() || node.isFocusable()) {
return true;
}

final List actionList = node.getActionList();
return actionList.contains(AccessibilityNodeInfoCompat.ACTION_CLICK)
|| actionList.contains(AccessibilityNodeInfoCompat.ACTION_LONG_CLICK)
|| actionList.contains(AccessibilityNodeInfoCompat.ACTION_FOCUS);
}

/**
* Returns a cached instance if such is available otherwise a new one.
*
* @param view The {@link View} to derive the AccessibilityNodeInfo properties from.
* @return {@link FlipperObject} containing the properties.
*/
@Nullable
public static AccessibilityNodeInfoCompat createNodeInfoFromView(View view) {
if (view == null) {
return null;
}

final AccessibilityNodeInfoCompat nodeInfo = AccessibilityNodeInfoCompat.obtain();

// For some unknown reason, Android seems to occasionally throw a NPE from
// onInitializeAccessibilityNodeInfo.
try {
ViewCompat.onInitializeAccessibilityNodeInfo(view, nodeInfo);
} catch (NullPointerException e) {
if (nodeInfo != null) {
nodeInfo.recycle();
}
return null;
}

return nodeInfo;
}

/**
* Creates the text that Google's TalkBack screen reader will read aloud for a given {@link View}.
* This may be any combination of the {@link View}'s {@code text}, {@code contentDescription}, and
* the {@code text} and {@code contentDescription} of any ancestor {@link View}.
*
* <p>This description is generally ported over from Google's TalkBack screen reader, and this
* should be kept up to date with their implementation (as much as necessary). Details can be seen
* in their source code here:
*
* <p>https://github.com/google/talkback/compositor/src/main/res/raw/compositor.json - search for
* "get_description_for_tree", "append_description_for_tree", "description_for_tree_nodes"
*
* @param view The {@link View} to evaluate.
* @param info The default {@link AccessibilityNodeInfoCompat}.
* @return {@code String} representing what talkback will say when a {@link View} is focused.
*/
@Nullable
public static CharSequence getTalkbackDescription(
View view, @Nullable AccessibilityNodeInfoCompat info) {
final AccessibilityNodeInfoCompat node =
info == null ? createNodeInfoFromView(view) : AccessibilityNodeInfoCompat.obtain(info);

if (node == null) {
return null;
}
try {
final CharSequence contentDescription = node.getContentDescription();
final CharSequence nodeText = node.getText();

final boolean hasNodeText = !TextUtils.isEmpty(nodeText);
final boolean isEditText = view instanceof EditText;

StringBuilder talkbackSegments = new StringBuilder();

// EditText's prioritize their own text content over a contentDescription so skip this
if (!TextUtils.isEmpty(contentDescription) && (!isEditText || !hasNodeText)) {
// next add content description
talkbackSegments.append(contentDescription);
return talkbackSegments;
}

// EditText
if (hasNodeText) {
// skipped status checks above for EditText

// description
talkbackSegments.append(nodeText);
return talkbackSegments;
}

// If there are child views and no contentDescription the text of all non-focusable children,
// comma separated, becomes the description.
if (view instanceof ViewGroup) {
final StringBuilder concatChildDescription = new StringBuilder();
final ViewGroup viewGroup = (ViewGroup) view;

for (int i = 0, count = viewGroup.getChildCount(); i < count; i++) {
final View child = viewGroup.getChildAt(i);

final AccessibilityNodeInfoCompat childNodeInfo = AccessibilityNodeInfoCompat.obtain();
ViewCompat.onInitializeAccessibilityNodeInfo(child, childNodeInfo);

if (isSpeakingNode(childNodeInfo, child)
&& !isAccessibilityFocusable(childNodeInfo, child)) {
CharSequence childNodeDescription = getTalkbackDescription(child, null);
if (!TextUtils.isEmpty(childNodeDescription)) {
concatChildDescription.append(childNodeDescription + delimiter);
}
}
childNodeInfo.recycle();
}

return removeFinalDelimiter(concatChildDescription);
}

return null;
} finally {
node.recycle();
}
}

private static String removeFinalDelimiter(StringBuilder builder) {
int end = builder.length();
if (end > 0) {
builder.delete(end - delimiterLength, end);
}
return builder.toString();
}
}
Loading