Java Code Examples for androidx.core.view.accessibility.AccessibilityNodeInfoCompat#recycle()

The following examples show how to use androidx.core.view.accessibility.AccessibilityNodeInfoCompat#recycle() . You can vote up the ones you like or vote down the ones you don't like, and go to the original project or source file by following the links above each example. You may check out the related API usage on the sidebar.
Example 1
Source File: TalkBackRadialMenuClient.java    From talkback with Apache License 2.0 6 votes vote down vote up
private boolean onPrepareEditingMenu(RadialMenu menu) {
  final AccessibilityNodeInfoCompat currentNode =
      accessibilityFocusMonitor.getAccessibilityFocus(/* useInputFocusIfEmpty= */ true);
  if (nodeMenuRuleProcessor == null || currentNode == null) {
    return false;
  }

  final boolean result = nodeMenuRuleProcessor.prepareEditingMenuForNode(menu, currentNode);
  if (!result && menu.size() == 0) {
    EventId eventId = EVENT_ID_UNTRACKED; // Not tracking performance of menu events.
    pipeline.returnFeedback(
        eventId,
        Feedback.speech(
            service.getString(R.string.title_local_breakout_no_items),
            SpeakOptions.create()
                .setQueueMode(QUEUE_MODE_FLUSH_ALL)
                .setFlags(
                    FeedbackItem.FLAG_NO_HISTORY
                        | FeedbackItem.FLAG_FORCED_FEEDBACK_AUDIO_PLAYBACK_ACTIVE
                        | FeedbackItem.FLAG_FORCED_FEEDBACK_MICROPHONE_ACTIVE
                        | FeedbackItem.FLAG_FORCED_FEEDBACK_SSB_ACTIVE)));
  }
  currentNode.recycle();
  return result;
}
 
Example 2
Source File: AccessibilityNodeInfoUtils.java    From talkback with Apache License 2.0 6 votes vote down vote up
public static int countVisibleChildren(AccessibilityNodeInfoCompat node) {
  if (node == null) {
    return 0;
  }
  int childCount = node.getChildCount();
  int childVisibleCount = 0;
  for (int i = 0; i < childCount; ++i) {
    AccessibilityNodeInfoCompat child = node.getChild(i);
    if (child != null) {
      try {
        if (child.isVisibleToUser()) {
          ++childVisibleCount;
        }
      } finally {
        child.recycle();
      }
    }
  }
  return childVisibleCount;
}
 
Example 3
Source File: AccessibilityUtil.java    From screenshot-tests-for-android with Apache License 2.0 6 votes vote down vote up
/**
 * Returns whether a View has any children that are visible.
 *
 * @param view The {@link View} to evaluate
 * @return {@code true} if node has any visible children
 */
public static boolean hasVisibleChildren(View view) {
  if (!(view instanceof ViewGroup)) {
    return false;
  }

  ViewGroup viewGroup = (ViewGroup) view;
  int childCount = viewGroup.getChildCount();
  for (int i = 0; i < childCount; ++i) {
    AccessibilityNodeInfoCompat childNodeInfo = createNodeInfoFromView(viewGroup.getChildAt(i));
    if (childNodeInfo != null) {
      try {
        if (childNodeInfo.isVisibleToUser()) {
          return true;
        }
      } finally {
        childNodeInfo.recycle();
      }
    }
  }

  return false;
}
 
Example 4
Source File: TalkBackRadialMenuClient.java    From talkback with Apache License 2.0 6 votes vote down vote up
private boolean onPrepareCustomActionMenu(RadialMenu menu) {
  final AccessibilityNodeInfoCompat currentNode =
      accessibilityFocusMonitor.getAccessibilityFocus(/* useInputFocusIfEmpty= */ true);
  if (nodeMenuRuleProcessor == null || currentNode == null) {
    return false;
  }

  final boolean result = nodeMenuRuleProcessor.prepareCustomActionMenuForNode(menu, currentNode);
  if (!result && menu.size() == 0) {
    EventId eventId = EVENT_ID_UNTRACKED; // Not tracking performance of menu events.
    pipeline.returnFeedback(
        eventId,
        Feedback.speech(
            service.getString(R.string.title_local_breakout_no_items),
            SpeakOptions.create()
                .setQueueMode(QUEUE_MODE_FLUSH_ALL)
                .setFlags(
                    FeedbackItem.FLAG_NO_HISTORY
                        | FeedbackItem.FLAG_FORCED_FEEDBACK_AUDIO_PLAYBACK_ACTIVE
                        | FeedbackItem.FLAG_FORCED_FEEDBACK_MICROPHONE_ACTIVE
                        | FeedbackItem.FLAG_FORCED_FEEDBACK_SSB_ACTIVE)));
  }
  currentNode.recycle();
  return result;
}
 
Example 5
Source File: AccessibilityNodeInfoUtils.java    From talkback with Apache License 2.0 5 votes vote down vote up
/**
 * Check whether a given node or any of its ancestors matches the given filter.
 *
 * @param node The node to examine.
 * @param filter The filter to match the nodes against.
 * @return {@code true} if the node or one of its ancestors matches the filter.
 */
public static boolean isOrHasMatchingAncestor(
    AccessibilityNodeInfoCompat node, Filter<AccessibilityNodeInfoCompat> filter) {
  if (node == null) {
    return false;
  }

  final AccessibilityNodeInfoCompat result = getSelfOrMatchingAncestor(node, filter);
  if (result == null) {
    return false;
  }

  result.recycle();
  return true;
}
 
Example 6
Source File: DirectionalTraversalStrategy.java    From talkback with Apache License 2.0 5 votes vote down vote up
private void recycle(boolean recycleRoot) {
  for (AccessibilityNodeInfoCompat node : mAllNodes) {
    node.recycle();
  }
  mAllNodes.clear();
  mFocusables.clear(); // No recycle needed for mFocusables or mContainers because their
  mContainers.clear(); // nodes were already recycled from mAllNodes.
  mSpeakingNodesCache.clear();

  if (recycleRoot) {
    mRoot.recycle();
    mRoot = null;
  }
}
 
Example 7
Source File: AccessibilityNodeInfoRef.java    From talkback with Apache License 2.0 5 votes vote down vote up
/**
 * Traverses to the next sibling of this node within its parent, returning {@code true} on
 * success.
 */
boolean nextSibling() {
  if (mNode == null) {
    return false;
  }
  AccessibilityNodeInfoCompat parent = mNode.getParent();
  if (parent == null) {
    return false;
  }
  ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(parent);
  try {
    if (!moveIteratorAfterNode(iterator, mNode)) {
      return false;
    }

    while (iterator.hasNext()) {
      AccessibilityNodeInfoCompat newNode = iterator.next();
      if (newNode == null) {
        return false;
      }
      if (AccessibilityNodeInfoUtils.isVisible(newNode)) {
        reset(newNode);
        return true;
      }
      newNode.recycle();
    }
  } finally {
    iterator.recycle();
    parent.recycle();
  }
  return false;
}
 
Example 8
Source File: AccessibilityNodeInfoUtils.java    From talkback with Apache License 2.0 5 votes vote down vote up
/**
 * Collects all descendants that match filter, into matches.
 *
 * @param node The root node to start searching.
 * @param filter The filter to match the nodes against.
 * @param matchRoot Flag that allows match with root node.
 * @param visitedNodes The set of nodes already visited, for protection against loops. This will
 *     be modified. Caller is responsible to recycle the nodes.
 * @param matches The list of nodes matching filter. This will be appended to. Caller is
 *     responsible to recycle this.
 */
private static void getMatchingDescendants(
    @Nullable AccessibilityNodeInfoCompat node,
    Filter<AccessibilityNodeInfoCompat> filter,
    boolean matchRoot,
    Set<AccessibilityNodeInfoCompat> visitedNodes,
    List<AccessibilityNodeInfoCompat> matches) {

  if (node == null) {
    return;
  }

  // Update visited nodes.
  if (visitedNodes.contains(node)) {
    return;
  } else {
    visitedNodes.add(AccessibilityNodeInfoCompat.obtain(node)); // Caller must recycle
  }

  // If node matches filter... collect node.
  if (matchRoot && filter.accept(node)) {
    matches.add(AccessibilityNodeInfoCompat.obtain(node)); // Caller must recycle
  }

  // For each child of node...
  int childCount = node.getChildCount();
  for (int i = 0; i < childCount; ++i) {
    AccessibilityNodeInfoCompat child = node.getChild(i); // Must recycle
    if (child == null) {
      continue;
    }
    try {
      // Recurse on child.
      getMatchingDescendants(child, filter, /* matchRoot= */ true, visitedNodes, matches);
    } finally {
      child.recycle();
    }
  }
}
 
Example 9
Source File: AccessibilityServiceCompatUtils.java    From talkback with Apache License 2.0 5 votes vote down vote up
public static AccessibilityNodeInfoCompat getInputFocusedNode(AccessibilityService service) {
  // TODO: Shall we use active window or accessibility focused window?
  AccessibilityNodeInfoCompat activeRoot = getRootInActiveWindow(service);
  if (activeRoot != null) {
    try {
      return activeRoot.findFocus(AccessibilityNodeInfoCompat.FOCUS_INPUT);
    } finally {
      activeRoot.recycle();
    }
  }
  return null;
}
 
Example 10
Source File: CollectionState.java    From talkback with Apache License 2.0 5 votes vote down vote up
@Nullable
private static TableItemState getTableItemState(
    AccessibilityNodeInfoCompat collectionRoot,
    AccessibilityNodeInfoCompat announcedNode,
    SparseArray<CharSequence> rowHeaders,
    SparseArray<CharSequence> columnHeaders,
    boolean computeHeaders,
    boolean computeNumbering) {
  if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) {
    return null;
  }

  // Checking the ancestors should incur zero performance penalty in the typical case
  // where list items are direct descendants. Assuming list items are not deeply
  // nested, any performance penalty would be minimal.
  AccessibilityNodeInfoCompat collectionItem =
      AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(
          announcedNode, collectionRoot, FILTER_COLLECTION_ITEM);

  if (collectionItem == null) {
    return null;
  }

  CollectionInfoCompat collection = collectionRoot.getCollectionInfo();
  CollectionItemInfoCompat item = collectionItem.getCollectionItemInfo();

  int heading = computeHeaders ? getTableHeading(collectionItem, item, collection) : TYPE_NONE;
  int rowIndex = getRowIndex(item, collection);
  int columnIndex = getColumnIndex(item, collection);
  CharSequence rowName = rowIndex != -1 ? rowHeaders.get(rowIndex) : null;
  CharSequence columnName = columnIndex != -1 ? columnHeaders.get(columnIndex) : null;

  collectionItem.recycle();
  return new TableItemState(
      heading, rowName, columnName, rowIndex, columnIndex, computeNumbering);
}
 
Example 11
Source File: CollectionState.java    From talkback with Apache License 2.0 5 votes vote down vote up
@Nullable
private static ListItemState getListItemState(
    AccessibilityNodeInfoCompat collectionRoot,
    AccessibilityNodeInfoCompat announcedNode,
    boolean computeNumbering) {
  if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) {
    return null;
  }

  // Checking the ancestors should incur zero performance penalty in the typical case
  // where list items are direct descendants. Assuming list items are not deeply
  // nested, any performance penalty would be minimal.
  AccessibilityNodeInfoCompat collectionItem =
      AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(
          announcedNode, collectionRoot, FILTER_COLLECTION_ITEM);

  if (collectionItem == null) {
    return null;
  }

  CollectionInfoCompat collection = collectionRoot.getCollectionInfo();
  CollectionItemInfoCompat item = collectionItem.getCollectionItemInfo();

  boolean heading = AccessibilityNodeInfoUtils.isHeading(collectionItem);
  int index;
  if (getCollectionAlignmentInternal(collection) == ALIGNMENT_VERTICAL) {
    index = getRowIndex(item, collection);
  } else {
    index = getColumnIndex(item, collection);
  }

  collectionItem.recycle();
  return new ListItemState(heading, index, computeNumbering);
}
 
Example 12
Source File: AccessibilityEventUtils.java    From talkback with Apache License 2.0 5 votes vote down vote up
/**
 * Returns true if the event came from a non main window event, such as invisible, toast, or IME
 * window; otherwise returns false. Examples:
 *
 * <ul>
 *   <li>Returns false for main activity windows.
 *   <li>Returns true for the soft keyboard window in an IME.
 *   <li>Returns true for toasts.
 *   <li>Returns true for volume slider.
 * </ul>
 */
// TODO: Add window-types similar to AccessibilityWindowInfo.getType(), but more
// specific and more stable across android versions.
public static boolean isNonMainWindowEvent(AccessibilityEvent event) {
  // If there's an actual window ID, we need to check the window type (if window available).
  boolean isNonMainWindow = false;
  AccessibilityRecordCompat record = AccessibilityEventCompat.asRecord(event);
  AccessibilityNodeInfoCompat source = record.getSource();
  if (source != null) {
    AccessibilityWindowInfoCompat window = AccessibilityNodeInfoUtils.getWindow(source);
    if (window == null) {
      // If window is not visible, we cannot know whether the window type is input method
      // or not. Let's assume that it comes from an IME. If window is visible but window
      // info is not available, it can be non-focusable visible window.
      isNonMainWindow = true;
    } else {
      switch (window.getType()) {
        case AccessibilityWindowInfoCompat.TYPE_INPUT_METHOD:
          // IME case.
          isNonMainWindow = true;
          break;
        case AccessibilityWindowInfoCompat.TYPE_SYSTEM:
          isNonMainWindow = isFromVolumeControlPanel(event);
          break;
        default: // fall out
      }
      window.recycle();
    }
    source.recycle();
  }
  return isNonMainWindow;
}
 
Example 13
Source File: AccessibilityNodeInfoUtils.java    From talkback with Apache License 2.0 5 votes vote down vote up
/**
 * Returns a fresh copy of node by traversing the given window for a similar node. For example,
 * the node that you want might be in a popup window that has closed and re-opened, causing the
 * accessibility IDs of its views to be different. Note: you must recycle the node that is
 * returned from this method.
 */
public static AccessibilityNodeInfoCompat refreshNodeFuzzy(
    final AccessibilityNodeInfoCompat node, AccessibilityWindowInfo window) {
  if (window == null || node == null) {
    return null;
  }

  AccessibilityNodeInfo root = AccessibilityWindowInfoUtils.getRoot(window);
  if (root == null) {
    return null;
  }

  Filter<AccessibilityNodeInfoCompat> similarFilter =
      new Filter<AccessibilityNodeInfoCompat>() {
        @Override
        public boolean accept(AccessibilityNodeInfoCompat other) {
          return other != null && TextUtils.equals(node.getText(), other.getText());
        }
      };

  AccessibilityNodeInfoCompat rootCompat = AccessibilityNodeInfoUtils.toCompat(root);
  try {
    return getMatchingDescendant(rootCompat, similarFilter);
  } finally {
    rootCompat.recycle();
  }
}
 
Example 14
Source File: ProcessorCursorState.java    From talkback with Apache License 2.0 5 votes vote down vote up
private void saveFocusedNode(AccessibilityRecordCompat record) {
  if (focusedNode != null) {
    focusedNode.recycle();
    focusedNode = null;
  }

  AccessibilityNodeInfoCompat source = record.getSource();
  if (source != null) {
    if (Role.getRole(source) == Role.ROLE_EDIT_TEXT) {
      focusedNode = source;
    } else {
      source.recycle();
    }
  }
}
 
Example 15
Source File: AccessibilityNodeInfoUtils.java    From talkback with Apache License 2.0 5 votes vote down vote up
/**
 * Recycles the given nodes.
 *
 * @param nodes The nodes to recycle.
 */
public static void recycleNodes(@Nullable AccessibilityNodeInfoCompat... nodes) {
  if (nodes == null) {
    return;
  }

  for (AccessibilityNodeInfoCompat node : nodes) {
    if (node != null) {
      node.recycle();
    }
  }
}
 
Example 16
Source File: FeedbackUtils.java    From talkback with Apache License 2.0 4 votes vote down vote up
/**
 * Gets the developer-provided text inside a node that will be used to generate spoken feedback.
 * If the node does not have any text, we attempt to get node text of any non-focusable children.
 * If there are no focusable children with text, an empty string will be returned.
 *
 * <p>Note: This method should never be called with nodes returned from
 * AccessibilityNodeInfo#obtain. These nodes do not retain children information, so this method
 * may return the incorrect text. Instead, use SwitchAccessNodeCompat#getNodeText.
 *
 * @param nodeCompat the {@link AccessibilityNodeInfoCompat} of the node from which the
 *     developer-provided text should be retrieved
 * @return the developer-provided text (content description or text) of the given node. If there
 *     is neither content description nor text inside the node or its children, then return an
 *     empty string
 */
public static String getNodeText(AccessibilityNodeInfoCompat nodeCompat) {
  CharSequence speakableText = nodeCompat.getContentDescription();

  if (StringUtils.isEmpty(speakableText)) {
    speakableText = nodeCompat.getText();
  }

  // If speakable text is empty, see if there are any non-focusable children nodes. If so, use
  // their text for the speakable text of this node. We filter out any focusable children nodes
  // to prevent duplicated speakable text from both a parent and child node.
  if (StringUtils.isEmpty(speakableText)) {
    StringBuilder builder = new StringBuilder();

    int numChildren = nodeCompat.getChildCount();
    for (int i = 0; i < numChildren; i++) {
      AccessibilityNodeInfoCompat child = nodeCompat.getChild(i);
      if ((child != null)
          && AccessibilityNodeInfoUtils.hasMinimumPixelsVisibleOnScreen(child)
          && !AccessibilityNodeInfoUtils.shouldFocusNode(child)) {
        CharSequence childText = getNodeText(child);

        if (!StringUtils.isEmpty(childText)) {
          if (builder.length() != 0) {
            builder.append(" ");
          }
          builder.append(childText);
        }
      }

      if (child != null) {
        child.recycle();
      }
    }

    speakableText = builder.toString();
  }
  if (StringUtils.isEmpty(speakableText)) {
    speakableText = "";
  }

  return speakableText.toString();
}
 
Example 17
Source File: CollectionState.java    From talkback with Apache License 2.0 4 votes vote down vote up
private static void updateTableHeaderInfo(
    AccessibilityNodeInfoCompat collectionRoot,
    SparseArray<CharSequence> rowHeaders,
    SparseArray<CharSequence> columnHeaders,
    boolean computeHeaders) {
  rowHeaders.clear();
  columnHeaders.clear();

  if (!computeHeaders) {
    return;
  }

  if (collectionRoot == null || collectionRoot.getCollectionInfo() == null) {
    return;
  }

  // Limit search to children and grandchildren of the root node for performance reasons.
  // We want to search grandchildren because web pages put table headers <th> inside table
  // rows <tr> so they are nested two levels down.
  CollectionInfoCompat collectionInfo = collectionRoot.getCollectionInfo();
  int numChildren = collectionRoot.getChildCount();
  for (int i = 0; i < numChildren; ++i) {
    AccessibilityNodeInfoCompat child = collectionRoot.getChild(i);
    if (child == null) {
      continue;
    }
    if (!updateSingleTableHeader(child, collectionInfo, rowHeaders, columnHeaders)) {
      int numGrandchildren = child.getChildCount();
      for (int j = 0; j < numGrandchildren; ++j) {
        AccessibilityNodeInfoCompat grandchild = child.getChild(j);
        if (grandchild == null) {
          continue;
        }
        updateSingleTableHeader(grandchild, collectionInfo, rowHeaders, columnHeaders);
        grandchild.recycle();
      }
    }

    child.recycle();
  }
}
 
Example 18
Source File: AccessibilityUtil.java    From screenshot-tests-for-android with Apache License 2.0 4 votes vote down vote up
/**
 * Returns whether a given {@link View} will be focusable by Google's TalkBack screen reader.
 *
 * @param view The {@link View} to evaluate.
 * @return {@code boolean} if the view will be ignored by TalkBack.
 */
public static boolean isTalkbackFocusable(View view) {
  if (view == null) {
    return false;
  }

  final int important = ViewCompat.getImportantForAccessibility(view);
  if (important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO
      || important == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
    return false;
  }

  // Go all the way up the tree to make sure no parent has hidden its descendants
  ViewParent parent = view.getParent();
  while (parent instanceof View) {
    if (ViewCompat.getImportantForAccessibility((View) parent)
        == ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS) {
      return false;
    }
    parent = parent.getParent();
  }

  // Trying to evaluate the focusability of certain element types (mainly list views) can cause
  // problems when trying to determine the offset of the elements Rect relative to its parent in
  // ViewGroup.offsetRectBetweenParentAndChild. If this happens, simply return false, as this view
  // will not be focusable.
  AccessibilityNodeInfoCompat node;
  try {
    node = createNodeInfoFromView(view);
  } catch (IllegalArgumentException e) {
    return false;
  }

  if (node == null) {
    return false;
  }

  // Non-leaf nodes identical in size to their Window should not be focusable.
  if (areBoundsIdenticalToWindow(node, view) && node.getChildCount() > 0) {
    return false;
  }

  try {
    if (!node.isVisibleToUser()) {
      return false;
    }

    if (isAccessibilityFocusable(node, view)) {
      if (!hasVisibleChildren(view)) {
        // Leaves that are accessibility focusable are never ignored, even if they don't have a
        // speakable description
        return true;
      } else if (isSpeakingNode(node, view)) {
        // Node is focusable and has something to speak
        return true;
      }

      // Node is focusable and has nothing to speak
      return false;
    }

    // if view is not accessibility focusable, it needs to have text and no focusable ancestors.
    if (!hasText(node)) {
      return false;
    }

    if (!hasFocusableAncestor(node, view)) {
      return true;
    }

    return false;
  } finally {
    node.recycle();
  }
}
 
Example 19
Source File: SpannableTraversalUtils.java    From talkback with Apache License 2.0 4 votes vote down vote up
/**
 * Search for SpannableStrings under <strong>node description tree</strong> of {@code root}.
 * <strong>Note:</strong> {@code root} will be added to {@code visitedNodes} if it's not null.
 * Caller should recycle {@root visitedNodes}.
 *
 * @param root Root of node tree. Caller does not need to recycle this node.
 * @param visitedNodes Set of {@link AccessibilityNodeInfoCompat} to record visited nodes, used to
 *     avoid loops. Caller should recycle this node set.
 * @param result List of SpannableStrings collected.
 * @param targetSpanClass Class of target span.
 * @return true if any SpannableString is found in the description tree.
 */
private static boolean searchSpannableStringsInNodeTree(
    AccessibilityNodeInfoCompat root,
    @NonNull Set<AccessibilityNodeInfoCompat> visitedNodes,
    @Nullable List<SpannableString> result,
    Class<?> targetSpanClass) {
  if (root == null) {
    return false;
  }
  if (!visitedNodes.add(root)) {
    // Root already visited. Recycle root node and stop searching.
    root.recycle();
    return false;
  }
  SpannableString string = SpannableUtils.getStringWithTargetSpan(root, targetSpanClass);
  boolean hasSpannableString = !TextUtils.isEmpty(string);
  if (hasSpannableString) {
    if (result == null) {
      // If we don't need to collect result and we found a Spannable String, return true.
      return true;
    } else {
      result.add(string);
    }
  }

  // TODO: Check if we should search descendents of web content node.
  if (!TextUtils.isEmpty(root.getContentDescription())) {
    // If root has content description, do not search the children nodes.
    return hasSpannableString;
  }
  ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(root);
  boolean containsSpannableDescendents = false;
  while (iterator.hasNext()) {
    AccessibilityNodeInfoCompat child = iterator.next();
    if (AccessibilityNodeInfoUtils.FILTER_NON_FOCUSABLE_VISIBLE_NODE.accept(child)) {
      containsSpannableDescendents |=
          searchSpannableStringsInNodeTree(child, visitedNodes, result, targetSpanClass);
    } else {
      AccessibilityNodeInfoUtils.recycleNodes(child);
    }
    if (containsSpannableDescendents && result == null) {
      return true;
    }
  }
  return hasSpannableString || containsSpannableDescendents;
}
 
Example 20
Source File: AccessibilityNodeInfoUtils.java    From talkback with Apache License 2.0 4 votes vote down vote up
/**
 * Returns the first ancestor of {@code node} that matches the {@code filter}, terminating the
 * search once it reaches {@code end}. The search is exclusive of both {@code node} and {@code
 * end}. Returns {@code null} if no nodes match.
 *
 * <p><strong>Note:</strong> Caller is responsible for recycling the returned node.
 */
private static @Nullable AccessibilityNodeInfoCompat getMatchingAncestor(
    AccessibilityNodeInfoCompat node,
    AccessibilityNodeInfoCompat end,
    Filter<AccessibilityNodeInfoCompat> filter) {
  if (node == null) {
    return null;
  }

  final HashSet<AccessibilityNodeInfoCompat> ancestors = new HashSet<>();

  try {
    ancestors.add(AccessibilityNodeInfoCompat.obtain(node));
    node = node.getParent();

    while (node != null) {
      if (!ancestors.add(node)) {
        // Already seen this node, so abort!
        node.recycle();
        return null;
      }

      if (end != null && node.equals(end)) {
        // Reached the end node, so abort!
        // Don't recycle the node here, it was added to ancestors and will be recycled.
        return null;
      }

      if (filter.accept(node)) {
        // Send a copy since node gets recycled.
        return AccessibilityNodeInfoCompat.obtain(node);
      }

      node = node.getParent();
    }
  } finally {
    recycleNodes(ancestors);
  }

  return null;
}