diff --git a/braille/brltty/build.gradle b/braille/brltty/build.gradle
index e4a4865e2..04f298603 100644
--- a/braille/brltty/build.gradle
+++ b/braille/brltty/build.gradle
@@ -3,7 +3,7 @@ apply from: "../../shared.gradle"
android {
namespace "com.google.android.accessibility.braille.brltty"
- ndkVersion "21.4.7075529"
+ ndkVersion "27.2.12479018"
externalNativeBuild {
ndkBuild {
path file('src/phone/jni/Android.mk')
diff --git a/braille/translate/build.gradle b/braille/translate/build.gradle
index 0b32e6a8c..65e4026fe 100644
--- a/braille/translate/build.gradle
+++ b/braille/translate/build.gradle
@@ -3,7 +3,7 @@ apply from: "../../shared.gradle"
android {
namespace "com.google.android.accessibility.braille.translate"
- ndkVersion "21.4.7075529"
+ ndkVersion "27.2.12479018"
externalNativeBuild {
ndkBuild {
path file('src/phone/jni/Android.mk')
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000..b1b8ef56b
Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000..e3aae5528
--- /dev/null
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,9 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
+networkTimeout=10000
+retries=0
+retryBackOffMs=500
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
new file mode 100755
index 000000000..249efbb03
--- /dev/null
+++ b/gradlew
@@ -0,0 +1,248 @@
+#!/bin/sh
+
+#
+# Copyright © 2015 the original authors.
+#
+# 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
+#
+# https://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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+# gradlew start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh gradlew
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ if ! command -v java >/dev/null 2>&1
+ then
+ die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+ # shellcheck disable=SC2039,SC3045
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+# and any embedded shellness will be escaped.
+# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+# treated as '${Hostname}' itself on the command line.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
+ "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+ die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/gradlew.bat b/gradlew.bat
new file mode 100644
index 000000000..8508ef684
--- /dev/null
+++ b/gradlew.bat
@@ -0,0 +1,82 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem gradlew startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables, and ensure extensions are enabled
+setlocal EnableExtensions
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+"%COMSPEC%" /c exit 1
+
+:execute
+@rem Setup the command line
+
+
+
+@rem Execute gradlew
+@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
+@rem which allows us to clear the local environment before executing the java command
+endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
+
+:exitWithErrorLevel
+@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
+"%COMSPEC%" /c exit %ERRORLEVEL%
diff --git a/shared.gradle b/shared.gradle
index c783e539d..4d499f22f 100644
--- a/shared.gradle
+++ b/shared.gradle
@@ -43,6 +43,16 @@ android {
}
}
+// Kotlin interface defaults become real Java default methods, so Java
+// implementers keep inheriting them (e.g. GestureConfigProvider), matching the
+// original Java. (Dynamic access because script plugins cannot reference the
+// Kotlin plugin classes by name.)
+tasks.configureEach { task ->
+ if (task.name.startsWith("compile") && task.name.contains("Kotlin")) {
+ task.kotlinOptions.freeCompilerArgs += "-Xjvm-default=all"
+ }
+}
+
dependencies {
// Google common
diff --git a/utils/build.gradle b/utils/build.gradle
index db64c88d0..657c01bce 100644
--- a/utils/build.gradle
+++ b/utils/build.gradle
@@ -6,6 +6,10 @@ apply from: "../shared.gradle"
dependencies {
implementation project(':proguard')
+ // Characterization tests: written against the original Java, then run
+ // unchanged against the converted Kotlin.
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.robolectric:robolectric:4.14.1'
}
android {
@@ -14,4 +18,9 @@ android {
buildConfigField("String", "TALKBACK_APPLICATION_ID", '"' + talkbackApplicationId + '"')
buildConfigField("String", "IS_SYSTEM_PRELOAD", "\"False\"")
}
+ testOptions {
+ unitTests {
+ includeAndroidResources = true
+ }
+ }
}
diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java
deleted file mode 100644
index 04ce4d617..000000000
--- a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.java
+++ /dev/null
@@ -1,310 +0,0 @@
-/*
- * Copyright (C) 2012 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.accessibility.utils;
-
-import androidx.annotation.Nullable;
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
-import com.google.android.accessibility.utils.traversal.ReorderedChildrenIterator;
-import com.google.errorprone.annotations.CanIgnoreReturnValue;
-import java.util.HashSet;
-import java.util.Iterator;
-import java.util.Set;
-
-/**
- * A class that simplifies traversal of node trees.
- *
- *
This class keeps track of an {@link AccessibilityNodeInfoCompat} object and can traverse to
- * other nodes in the tree, or be reset to other nodes. The node can be owned.
- *
- *
Any node can be assigned to objects of this class, including nodes that are not visible to the
- * user. The traversal methods, however, will only traverse to visible nodes.
- *
- * @see AccessibilityNodeInfoUtils#isVisible(AccessibilityNodeInfoCompat)
- */
-public class AccessibilityNodeInfoRef {
- private AccessibilityNodeInfoCompat mNode;
-
- /** Returns the current node. */
- public AccessibilityNodeInfoCompat get() {
- return mNode;
- }
-
- /** Clears this object. */
- public void clear() {
- reset((AccessibilityNodeInfoCompat) null);
- }
-
- /** Resets this object to contain a new node, taking ownership of the new node. */
- public void reset(AccessibilityNodeInfoCompat newNode) {
- mNode = newNode;
- }
-
- /**
- * Resets this object with the node held by {@code newNode}. if {@code newNode} was owning the
- * node, ownership is transfered to this object.
- */
- public void reset(AccessibilityNodeInfoRef newNode) {
- reset(newNode.get());
- }
-
- /** Creates a new instance of this class. */
- public static AccessibilityNodeInfoRef obtain(AccessibilityNodeInfoCompat node) {
- return new AccessibilityNodeInfoRef(node);
- }
-
- /** Creates a new instance of this class without assuming ownership of {@code node}. */
- @Nullable
- public static AccessibilityNodeInfoRef unOwned(AccessibilityNodeInfoCompat node) {
- return node != null ? new AccessibilityNodeInfoRef(node) : null;
- }
-
- /** Creates a new instance of this class taking ownership of {@code node}. */
- @Nullable
- public static AccessibilityNodeInfoRef owned(AccessibilityNodeInfoCompat node) {
- return node != null ? new AccessibilityNodeInfoRef(node) : null;
- }
-
- /**
- * Creates an {@link AccessibilityNodeInfoRef} with a refreshed copy of {@code node}, taking
- * ownership of the copy. If {@code node} is {@code null}, {@code null} is returned.
- */
- public static AccessibilityNodeInfoRef refreshed(AccessibilityNodeInfoCompat node) {
- return owned(AccessibilityNodeInfoUtils.refreshNode(node));
- }
-
- /**
- * Makes sure that this object owns its own copy of the node it holds by creating a new copy of
- * the node if not already owned or doing nothing otherwise.
- */
- @CanIgnoreReturnValue
- public AccessibilityNodeInfoRef makeOwned() {
- reset(mNode);
- return this;
- }
-
- public AccessibilityNodeInfoRef() {}
-
- private AccessibilityNodeInfoRef(AccessibilityNodeInfoCompat node) {
- mNode = node;
- }
-
- public static boolean isNull(AccessibilityNodeInfoRef ref) {
- return ref == null || ref.get() == null;
- }
-
- /**
- * Releases the ownership of the underlying node if it was owned, returning the underlying node.
- * This is typically chained with {@link #makeOwned} to have a copy that can be put in another
- * container or {@link AccessibilityNodeInfoRef}. After this call, this object still refers to the
- * underlying node so that any of the traversal methods can be used afterwards.
- */
- public AccessibilityNodeInfoCompat release() {
- return mNode;
- }
-
- /** Traverses to the last child of this node, returning {@code true} on success. */
- boolean lastChild() {
- if (mNode == null || mNode.getChildCount() < 1) {
- return false;
- }
-
- ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createDescendingIterator(mNode);
- while (iterator.hasNext()) {
- AccessibilityNodeInfoCompat newNode = iterator.next();
- if (newNode == null) {
- return false;
- }
-
- if (AccessibilityNodeInfoUtils.isVisible(newNode)) {
- reset(newNode);
- return true;
- }
- }
- return false;
- }
-
- /**
- * Traverses to the previous sibling of this node within its parent, returning {@code true} on
- * success.
- */
- public boolean previousSibling() {
- if (mNode == null) {
- return false;
- }
- AccessibilityNodeInfoCompat parent = mNode.getParent();
- if (parent == null) {
- return false;
- }
- ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createDescendingIterator(parent);
- 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;
- }
- }
- return false;
- }
-
- /** Traverses to the first child of this node if any, returning {@code true} on success. */
- boolean firstChild() {
- if (mNode == null) {
- return false;
- }
-
- ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(mNode);
- while (iterator.hasNext()) {
- AccessibilityNodeInfoCompat newNode = iterator.next();
- if (newNode == null) {
- return false;
- }
- if (AccessibilityNodeInfoUtils.isVisible(newNode)) {
- reset(newNode);
- return true;
- }
- }
- return false;
- }
-
- /**
- * Traverses to the next sibling of this node within its parent, returning {@code true} on
- * success.
- */
- public boolean nextSibling() {
- if (mNode == null) {
- return false;
- }
- AccessibilityNodeInfoCompat parent = mNode.getParent();
- if (parent == null) {
- return false;
- }
- ReorderedChildrenIterator iterator = ReorderedChildrenIterator.createAscendingIterator(parent);
- 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;
- }
- }
- return false;
- }
-
- @SuppressWarnings("BooleanMethodIsAlwaysInverted")
- private boolean moveIteratorAfterNode(
- Iterator iterator, AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
- while (iterator.hasNext()) {
- AccessibilityNodeInfoCompat nextNode = iterator.next();
- if (node.equals(nextNode)) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Traverses to the parent of this node, returning {@code true} on success. On failure, returns
- * {@code false} and does not move.
- */
- public boolean parent() {
- if (mNode == null) {
- return false;
- }
- Set visitedNodes = new HashSet<>();
- visitedNodes.add(mNode);
- AccessibilityNodeInfoCompat parentNode = mNode.getParent();
- while (parentNode != null) {
- if (visitedNodes.contains(parentNode)) {
- return false;
- }
-
- if (AccessibilityNodeInfoUtils.isVisible(parentNode)) {
- reset(parentNode);
- return true;
- }
- visitedNodes.add(parentNode);
- parentNode = parentNode.getParent();
- }
- return false;
- }
-
- /** Traverses to the next node in depth-first order, returning {@code true} on success. */
- public boolean nextInOrder() {
- if (mNode == null) {
- return false;
- }
- if (firstChild()) {
- return true;
- }
- if (nextSibling()) {
- return true;
- }
- AccessibilityNodeInfoRef tmp = unOwned(mNode);
- while (tmp.parent()) {
- if (tmp.nextSibling()) {
- reset(tmp);
- return true;
- }
- }
- tmp.clear();
- return false;
- }
-
- /** Traverses to the previous node in depth-first order, returning {@code true} on success. */
- public boolean previousInOrder() {
- if (mNode == null) {
- return false;
- }
- if (previousSibling()) {
- lastDescendant();
- return true;
- }
- return parent();
- }
-
- /** Traverses to the last descendant of this node, returning {@code true} on success. */
- public boolean lastDescendant() {
- if (!lastChild()) {
- return false;
- }
- Set visitedNodes = new HashSet<>();
- while (lastChild()) {
- if (visitedNodes.contains(mNode)) {
- return false;
- }
- visitedNodes.add(mNode);
- }
- return true;
- }
-}
diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.kt b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.kt
new file mode 100644
index 000000000..5b9c2548c
--- /dev/null
+++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoRef.kt
@@ -0,0 +1,280 @@
+/*
+ * Copyright (C) The Android Open Source Project
+ *
+ * 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.
+ *
+ * Ported from Java to Kotlin.
+ */
+
+package com.google.android.accessibility.utils
+
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
+import com.google.android.accessibility.utils.traversal.ReorderedChildrenIterator
+import com.google.errorprone.annotations.CanIgnoreReturnValue
+
+/**
+ * A class that simplifies traversal of node trees.
+ *
+ * This class keeps track of an {@link AccessibilityNodeInfoCompat} object and can traverse to
+ * other nodes in the tree, or be reset to other nodes. The node can be owned.
+ *
+ *
Any node can be assigned to objects of this class, including nodes that are not visible to the
+ * user. The traversal methods, however, will only traverse to visible nodes.
+ *
+ * @see AccessibilityNodeInfoUtils#isVisible(AccessibilityNodeInfoCompat)
+ */
+class AccessibilityNodeInfoRef {
+ private var mNode: AccessibilityNodeInfoCompat? = null
+
+ /** Returns the current node. */
+ fun get(): AccessibilityNodeInfoCompat? = mNode
+
+ /** Clears this object. */
+ fun clear() {
+ reset(null as AccessibilityNodeInfoCompat?)
+ }
+
+ /** Resets this object to contain a new node, taking ownership of the new node. */
+ fun reset(newNode: AccessibilityNodeInfoCompat?) {
+ mNode = newNode
+ }
+
+ /**
+ * Resets this object with the node held by {@code newNode}. if {@code newNode} was owning the
+ * node, ownership is transfered to this object.
+ */
+ fun reset(newNode: AccessibilityNodeInfoRef) {
+ reset(newNode.get())
+ }
+
+ /**
+ * Makes sure that this object owns its own copy of the node it holds by creating a new copy of
+ * the node if not already owned or doing nothing otherwise.
+ */
+ @CanIgnoreReturnValue
+ fun makeOwned(): AccessibilityNodeInfoRef {
+ reset(mNode)
+ return this
+ }
+
+ constructor()
+
+ private constructor(node: AccessibilityNodeInfoCompat?) {
+ mNode = node
+ }
+
+ /**
+ * Releases the ownership of the underlying node if it was owned, returning the underlying node.
+ * This is typically chained with {@link #makeOwned} to have a copy that can be put in another
+ * container or {@link AccessibilityNodeInfoRef}. After this call, this object still refers to the
+ * underlying node so that any of the traversal methods can be used afterwards.
+ */
+ fun release(): AccessibilityNodeInfoCompat? = mNode
+
+ /** Traverses to the last child of this node, returning {@code true} on success. */
+ internal fun lastChild(): Boolean {
+ val node = mNode
+ if (node == null || node.childCount < 1) {
+ return false
+ }
+
+ val iterator = ReorderedChildrenIterator.createDescendingIterator(node)
+ while (iterator.hasNext()) {
+ val newNode = iterator.next() ?: return false
+
+ if (AccessibilityNodeInfoUtils.isVisible(newNode)) {
+ reset(newNode)
+ return true
+ }
+ }
+ return false
+ }
+
+ /**
+ * Traverses to the previous sibling of this node within its parent, returning {@code true} on
+ * success.
+ */
+ fun previousSibling(): Boolean {
+ val node = mNode ?: return false
+ val parent = node.parent ?: return false
+ val iterator = ReorderedChildrenIterator.createDescendingIterator(parent)
+ if (!moveIteratorAfterNode(iterator, node)) {
+ return false
+ }
+
+ while (iterator.hasNext()) {
+ val newNode = iterator.next() ?: return false
+ if (AccessibilityNodeInfoUtils.isVisible(newNode)) {
+ reset(newNode)
+ return true
+ }
+ }
+ return false
+ }
+
+ /** Traverses to the first child of this node if any, returning {@code true} on success. */
+ internal fun firstChild(): Boolean {
+ val node = mNode ?: return false
+
+ val iterator = ReorderedChildrenIterator.createAscendingIterator(node)
+ while (iterator.hasNext()) {
+ val newNode = iterator.next() ?: return false
+ if (AccessibilityNodeInfoUtils.isVisible(newNode)) {
+ reset(newNode)
+ return true
+ }
+ }
+ return false
+ }
+
+ /**
+ * Traverses to the next sibling of this node within its parent, returning {@code true} on
+ * success.
+ */
+ fun nextSibling(): Boolean {
+ val node = mNode ?: return false
+ val parent = node.parent ?: return false
+ val iterator = ReorderedChildrenIterator.createAscendingIterator(parent)
+ if (!moveIteratorAfterNode(iterator, node)) {
+ return false
+ }
+
+ while (iterator.hasNext()) {
+ val newNode = iterator.next() ?: return false
+ if (AccessibilityNodeInfoUtils.isVisible(newNode)) {
+ reset(newNode)
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun moveIteratorAfterNode(
+ iterator: Iterator,
+ node: AccessibilityNodeInfoCompat?,
+ ): Boolean {
+ if (node == null) {
+ return false
+ }
+ while (iterator.hasNext()) {
+ val nextNode = iterator.next()
+ if (node == nextNode) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ /**
+ * Traverses to the parent of this node, returning {@code true} on success. On failure, returns
+ * {@code false} and does not move.
+ */
+ fun parent(): Boolean {
+ val node = mNode ?: return false
+ val visitedNodes = HashSet()
+ visitedNodes.add(node)
+ var parentNode = node.parent
+ while (parentNode != null) {
+ if (visitedNodes.contains(parentNode)) {
+ return false
+ }
+
+ if (AccessibilityNodeInfoUtils.isVisible(parentNode)) {
+ reset(parentNode)
+ return true
+ }
+ visitedNodes.add(parentNode)
+ parentNode = parentNode.parent
+ }
+ return false
+ }
+
+ /** Traverses to the next node in depth-first order, returning {@code true} on success. */
+ fun nextInOrder(): Boolean {
+ if (mNode == null) {
+ return false
+ }
+ if (firstChild()) {
+ return true
+ }
+ if (nextSibling()) {
+ return true
+ }
+ val tmp = unOwned(mNode) ?: return false
+ while (tmp.parent()) {
+ if (tmp.nextSibling()) {
+ reset(tmp)
+ return true
+ }
+ }
+ tmp.clear()
+ return false
+ }
+
+ /** Traverses to the previous node in depth-first order, returning {@code true} on success. */
+ fun previousInOrder(): Boolean {
+ if (mNode == null) {
+ return false
+ }
+ if (previousSibling()) {
+ lastDescendant()
+ return true
+ }
+ return parent()
+ }
+
+ /** Traverses to the last descendant of this node, returning {@code true} on success. */
+ fun lastDescendant(): Boolean {
+ if (!lastChild()) {
+ return false
+ }
+ val visitedNodes = HashSet()
+ while (lastChild()) {
+ val node = mNode ?: return false
+ if (visitedNodes.contains(node)) {
+ return false
+ }
+ visitedNodes.add(node)
+ }
+ return true
+ }
+
+ companion object {
+ /** Creates a new instance of this class. */
+ @JvmStatic
+ fun obtain(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoRef =
+ AccessibilityNodeInfoRef(node)
+
+ /** Creates a new instance of this class without assuming ownership of {@code node}. */
+ @JvmStatic
+ fun unOwned(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoRef? =
+ if (node != null) AccessibilityNodeInfoRef(node) else null
+
+ /** Creates a new instance of this class taking ownership of {@code node}. */
+ @JvmStatic
+ fun owned(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoRef? =
+ if (node != null) AccessibilityNodeInfoRef(node) else null
+
+ /**
+ * Creates an {@link AccessibilityNodeInfoRef} with a refreshed copy of {@code node}, taking
+ * ownership of the copy. If {@code node} is {@code null}, {@code null} is returned.
+ */
+ @JvmStatic
+ fun refreshed(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoRef? =
+ owned(AccessibilityNodeInfoUtils.refreshNode(node))
+
+ @JvmStatic
+ fun isNull(ref: AccessibilityNodeInfoRef?): Boolean = ref == null || ref.get() == null
+ }
+}
diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java
deleted file mode 100644
index 22537909c..000000000
--- a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.java
+++ /dev/null
@@ -1,3479 +0,0 @@
-/*
- * Copyright (C) 2012 The Android Open Source Project
- *
- * 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.accessibility.utils;
-
-import static android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS;
-import static com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_ID_NONE;
-import static com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_TYPE_NONE;
-import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS;
-import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_SPEAKABLE;
-import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_VISIBLE;
-import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN;
-import static com.google.android.accessibility.utils.DiagnosticOverlayUtils.NONE;
-import static com.google.android.accessibility.utils.Role.ROLE_GRID;
-import static com.google.android.accessibility.utils.Role.ROLE_HORIZONTAL_SCROLL_VIEW;
-import static com.google.android.accessibility.utils.Role.ROLE_LIST;
-import static com.google.android.accessibility.utils.Role.ROLE_PAGER;
-import static com.google.android.accessibility.utils.Role.ROLE_SCROLL_VIEW;
-import static com.google.android.accessibility.utils.Role.ROLE_WEB_VIEW;
-
-import android.content.Context;
-import android.graphics.Point;
-import android.graphics.Rect;
-import android.graphics.RectF;
-import android.os.Bundle;
-import android.os.LocaleList;
-import android.os.Parcelable;
-import android.text.Spannable;
-import android.text.SpannableString;
-import android.text.TextUtils;
-import android.text.style.ClickableSpan;
-import android.text.style.SuggestionSpan;
-import android.text.style.URLSpan;
-import android.util.Pair;
-import android.view.accessibility.AccessibilityNodeInfo;
-import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
-import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo;
-import android.view.accessibility.AccessibilityWindowInfo;
-import android.widget.GridView;
-import android.widget.ListView;
-import androidx.annotation.VisibleForTesting;
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat;
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat;
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat;
-import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat;
-import androidx.core.view.accessibility.AccessibilityWindowInfoCompat;
-import com.google.android.accessibility.utils.DiagnosticOverlayUtils.DiagnosticType;
-import com.google.android.accessibility.utils.Role.RoleName;
-import com.google.android.accessibility.utils.SpannableUtils.SpannableWithOffset;
-import com.google.android.accessibility.utils.compat.CompatUtils;
-import com.google.android.accessibility.utils.traversal.SpannableTraversalUtils;
-import com.google.android.libraries.accessibility.utils.log.LogUtils;
-import com.google.android.libraries.accessibility.utils.url.SpannableUrl;
-import com.google.auto.value.AutoValue;
-import com.google.common.base.Function;
-import com.google.common.base.Strings;
-import com.google.common.collect.ImmutableList;
-import com.google.common.collect.ImmutableSet;
-import com.google.errorprone.annotations.FormatMethod;
-import com.google.errorprone.annotations.FormatString;
-import java.util.ArrayDeque;
-import java.util.ArrayList;
-import java.util.Collection;
-import java.util.HashMap;
-import java.util.HashSet;
-import java.util.List;
-import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.regex.Pattern;
-import org.checkerframework.checker.nullness.qual.NonNull;
-import org.checkerframework.checker.nullness.qual.Nullable;
-import org.checkerframework.checker.nullness.qual.PolyNull;
-
-/** Provides a series of utilities for interacting with AccessibilityNodeInfo objects. */
-public class AccessibilityNodeInfoUtils {
-
- /** Internal AccessibilityNodeInfoCompat extras bundle key constants. */
- // The minimum amount of pixels that must be visible for a view to be surfaced to the user as
- // visible (i.e. for this node to be added to the tree).
- public static final int MIN_VISIBLE_PIXELS = 15;
-
- private static final String CLASS_LISTVIEW = ListView.class.getName();
- private static final String CLASS_GRIDVIEW = GridView.class.getName();
-
- private static final HashMap actionIdToName = initActionIds();
-
- /** Returns text from an accessibility-node, including spans. */
- public static @Nullable CharSequence getText(@Nullable AccessibilityNodeInfoCompat node) {
- return (node == null) ? null : node.getText();
- }
-
- @FormatMethod
- private static void logError(String functionName, @FormatString String format, Object... args) {
- LogUtils.e(TAG, functionName + "() " + String.format(format, args));
- }
-
- //////////////////////////////////////////////////////////////////////////////////////////
- // Constants
-
- private static final String TAG = "AccessibilityNodeInfoUtils";
-
- /**
- * Class for Samsung's TouchWiz implementation of AdapterView. May be {@code null} on non-Samsung
- * devices.
- */
- private static final Class> CLASS_TOUCHWIZ_TWADAPTERVIEW =
- CompatUtils.getClass("com.sec.android.touchwiz.widget.TwAdapterView");
-
- /** Key to get accessibility web hints from the web */
- private static final String HINT_TEXT_KEY = "AccessibilityNodeInfo.hint";
-
- private static final Pattern RESOURCE_NAME_SPLIT_PATTERN = Pattern.compile(":id/");
-
- /** Class used to find clickable-spans in text. */
- public static final Class extends ClickableSpan> BASE_CLICKABLE_SPAN = ClickableSpan.class;
-
- private static final String VIEW_ID_RESOURCE_NAME_PIN_ENTRY = "com.android.systemui:id/pinEntry";
-
- @VisibleForTesting
- static final String VIEW_ID_WEAR_UNREAD_NOTIFICATION_DOT =
- "com.google.android.wearable.sysui:id/unread_dot";
-
- @VisibleForTesting static final int THRESHOLD_HEIGHT_DP_FOR_SMALL_NODE = 32;
-
- /** Key to get the chrome role from the node */
- private static final String EXTRAS_KEY_CHROME_ROLE = "AccessibilityNodeInfo.chromeRole";
-
- /**
- * Chrome role for link. The role string should come from `ToString(ax::mojom::Role role)` at
- * ui/accessibility/ax_enum_util.cc in the Chromium repo.
- * https://source.chromium.org/chromium/chromium/src/+/main:ui/accessibility/ax_enum_util.cc?q=%22ToString(ax::mojom::Role%20role)%22%20f:ui%2Faccessibility%2Fax_enum_util.cc
- */
- private static final String CHROME_ROLE_LINK = "link";
-
- /**
- * A wrapper over AccessibilityNodeInfoCompat constructor, so that we can add any desired error
- * checking and memory management.
- *
- * @param nodeInfo The AccessibilityNodeInfo which will be wrapped.
- * @return Encapsulating AccessibilityNodeInfoCompat, or null if input is null.
- */
- public static @PolyNull AccessibilityNodeInfoCompat toCompat(
- @PolyNull AccessibilityNodeInfo nodeInfo) {
- if (nodeInfo == null) {
- return null;
- }
- return AccessibilityNodeInfoCompat.wrap(nodeInfo);
- }
-
- private static final int SYSTEM_ACTION_MAX = 0x01FFFFFF;
-
- public static final int WINDOW_TYPE_PICTURE_IN_PICTURE = 1000;
-
- /**
- * Filter for scrollable items. One of the following must be true:
- *
- *
- * - {@link AccessibilityNodeInfoCompat#isScrollable()} returns {@code true}
- *
- {@link AccessibilityNodeInfoCompat#getActions()} supports {@link
- * AccessibilityNodeInfoCompat#ACTION_SCROLL_FORWARD}
- *
- {@link AccessibilityNodeInfoCompat#getActions()} supports {@link
- * AccessibilityNodeInfoCompat#ACTION_SCROLL_BACKWARD}
- *
- */
- public static final Filter FILTER_SCROLLABLE =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return isScrollable(node);
- }
- };
-
- /** Filter for items that could be scrolled forward. */
- public static final Filter FILTER_COULD_SCROLL_FORWARD =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return node != null
- && supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD);
- }
- };
-
- /** Filter for items that could be scrolled backward. */
- public static final Filter FILTER_COULD_SCROLL_BACKWARD =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return node != null
- && supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD);
- }
- };
-
- /**
- * Filter for items that should receive accessibility focus. Equivalent to calling {@link
- * #shouldFocusNode(AccessibilityNodeInfoCompat)}.
- *
- * Note: Use {@link #FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW} has a filter for
- * {@link AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events.
- */
- public static final Filter FILTER_SHOULD_FOCUS =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return node != null && shouldFocusNode(node);
- }
- };
-
- /**
- * Filter for items that should receive accessibility focus from {@link
- * AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events. WebView container node should not be focus
- * for hover enter actions.
- */
- public static final Filter FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW =
- FILTER_SHOULD_FOCUS.and(
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return Role.getRole(node) != Role.ROLE_WEB_VIEW;
- }
- });
-
- /** Filter for heading items in collections. */
- public static final Filter FILTER_HEADING =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return (node != null) && isHeading(node);
- }
- };
-
- private static final ImmutableSet CONTAINER_ROLES =
- ImmutableSet.of(
- ROLE_LIST,
- ROLE_GRID,
- ROLE_PAGER,
- ROLE_SCROLL_VIEW,
- ROLE_HORIZONTAL_SCROLL_VIEW,
- ROLE_WEB_VIEW);
-
- /** Filter for container. */
- public static final Filter FILTER_CONTAINER =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return node != null
- && (CONTAINER_ROLES.contains(Role.getRole(node))
- || !TextUtils.isEmpty(node.getContainerTitle()));
- }
- };
-
- /**
- * Filter for focusable containers with a descendant that is an unfocusable heading. This filter
- * aids navigation by headings granularity when the node that is semantically a heading isn't
- * focusable (for instance, because its text is combined with the text of other nodes to create
- * speakable text for a container in a list context).
- */
- public static final Filter
- FILTER_CONTAINER_WITH_UNFOCUSABLE_HEADING =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return searchFromBfs(
- node,
- FILTER_HEADING.and(
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat childNode) {
- return childNode.getChildCount() == 0 && !shouldFocusNode(childNode);
- }
- }))
- != null;
- }
- };
-
- /** Filter for scrollable grids. */
- public static final Filter FILTER_SCROLLABLE_GRID =
- FILTER_SCROLLABLE.and(Filter.node((n) -> Role.getRole(n) == Role.ROLE_GRID));
-
- /** Filter for table. */
- private static final Filter FILTER_TABLE =
- Filter.node((node) -> isTableRoot(node));
-
- /** Filter for table cell. */
- public static final Filter FILTER_TABLE_CELL =
- Filter.node((node) -> isTableCell(node));
-
- /** Filter for table cell and check if it is in a table. */
- public static final Filter FILTER_TABLE_CELL_UNDER_TABLE =
- Filter.node((node) -> isTableCellUnderTable(node));
-
- /** Filter the node matched the voice dictation definition. */
- public static final Filter FILTER_VOICE_DICTATION =
- Filter.node(AccessibilityNodeInfoUtils::isVoiceDictationNode);
-
- /**
- * Filter that also checks for {@param node}'s non-focusable but visible children. Sometimes, a
- * node that passes the filter can be embedded in a parent and might be not focusable by itself.
- * In those cases it is important to focus the parent. Example would be for "Control" granularity,
- * if a switch is not focusable but is embedded into a focusable parent, its parent should be
- * focused.
- */
- public static Filter getFilterIncludingChildren(
- final Filter filter) {
- return new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
- // If the node does not pass the filter, check its non focusable, visible children.
- if (!filter.accept(node)) {
- return hasMatchingDescendant(node, filter.and(FILTER_NON_FOCUSABLE_VISIBLE_NODE));
- }
- return true;
- }
- };
- }
-
- // TODO: Provides an overall experience of focusing on small nodes on both watch and
- // phone devices.
- /** Filters out nodes which are small and located on the top and bottom borders. */
- public static Filter getFilterExcludingSmallTopAndBottomBorderNode(
- final Context context) {
- // For a watch device, we don't want to put focus on the small border nodes. These nodes
- // could be located at the middle of AdapterView and they could be distorted to fit in a
- // round screen when they are near top or bottom borders.
- final Point screenPxSize = DisplayUtils.getScreenPixelSizeWithoutWindowDecor(context);
- return new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return !AccessibilityNodeInfoUtils.isSmallNodeInHeight(context, node)
- || !AccessibilityNodeInfoUtils.isTopOrBottomBorderNode(screenPxSize, node);
- }
- };
- }
-
- /** Filter to identify nodes which are not focusable but visible. */
- public static final Filter FILTER_NON_FOCUSABLE_VISIBLE_NODE =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return isVisible(node) && !isAccessibilityFocusable(node);
- }
- };
-
- /** Filter to identify nodes which are not focusable and not visible but has text. */
- public static final Filter
- FILTER_NON_FOCUSABLE_NON_VISIBLE_HAS_TEXT_NODE =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return !isVisible(node)
- && !isAccessibilityFocusable(node)
- && !TextUtils.isEmpty(AccessibilityNodeInfoUtils.getNodeText(node));
- }
- };
-
- /** Filter for controllable elements. */
- public static final Filter FILTER_CONTROL =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
- @RoleName int role = Role.getRole(node);
- return (role == Role.ROLE_BUTTON)
- || (role == Role.ROLE_IMAGE_BUTTON)
- || (role == Role.ROLE_EDIT_TEXT)
- || (role == Role.ROLE_CHECK_BOX)
- || (role == Role.ROLE_RADIO_BUTTON)
- || (role == Role.ROLE_TOGGLE_BUTTON)
- || (role == Role.ROLE_SWITCH)
- || (role == Role.ROLE_DROP_DOWN_LIST)
- || (role == Role.ROLE_SEEK_CONTROL)
- || (role == Role.ROLE_FLOATING_ACTION_BUTTON)
- || (role == Role.ROLE_VOICE_DICTATION_BUTTON)
- // The clickable view in a collection may not be a control, such as each setting item
- // in the Settings page.
- || (!nodeIsListOrGridItem(node) && (isClickable(node) || isLongClickable(node)));
- }
- };
-
- /** Filter for Spannables with links. */
- public static final Filter FILTER_LINK =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return SpannableTraversalUtils.hasTargetClickableSpanInNodeTree(
- node, BASE_CLICKABLE_SPAN);
- }
- };
-
- public static final Filter FILTER_CLICKABLE =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return AccessibilityNodeInfoUtils.isClickable(node);
- }
- };
-
- public static Filter getFilterIllegalTitleNodeAncestor(
- Context context) {
- return new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- if (isClickable(node) || isLongClickable(node)) {
- return true;
- }
-
- if (FeatureSupport.isWatch(context)) {
- // A window title node can be a descendant of AdapterView in a watch device since the
- // title node may be the first node in a AdapterView.
- return false;
- } else {
- @RoleName int role = Role.getRole(node);
- // A window title node should not be a descendant of AdapterView.
- return (role == Role.ROLE_LIST) || (role == Role.ROLE_GRID);
- }
- }
- };
- }
-
- /**
- * Filter that defines which types of views should be auto-scrolled. Generally speaking, only
- * accepts views that are capable of showing partially-visible data.
- *
- * Accepts the following classes (and sub-classes thereof):
- *
- *
- * - {@link androidx.recyclerview.widget.RecyclerView} (Should be classified as a List or Grid.)
- *
- {@link android.widget.AbsListView} (including both ListView and GridView)
- *
- {@link android.widget.AbsSpinner}
- *
- {@link android.widget.ScrollView}
- *
- {@link android.widget.HorizontalScrollView}
- *
- {@code com.sec.android.touchwiz.widget.TwAbsListView}
- *
- *
- * Specifically excludes {@link android.widget.AdapterViewAnimator} and sub-classes, since they
- * represent overlapping views. Also excludes {@link androidx.viewpager.widget.ViewPager} since it
- * exclusively represents off-screen views.
- */
- public static final Filter FILTER_AUTO_SCROLL =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- if (!isScrollable(node) || !isVisible(node)) {
- return false;
- }
- @Role.RoleName int role = Role.getRole(node);
- // TODO: Check if we should include ROLE_ADAPTER_VIEW as a target Role.
- return role == Role.ROLE_DROP_DOWN_LIST
- || role == Role.ROLE_LIST
- || role == Role.ROLE_GRID
- || role == Role.ROLE_SCROLL_VIEW
- || role == Role.ROLE_HORIZONTAL_SCROLL_VIEW
- || AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType(
- node, CLASS_TOUCHWIZ_TWADAPTERVIEW);
- }
- };
-
- public static final Filter FILTER_COLLECTION =
- Filter.node(
- (node) -> {
- int role = Role.getRole(node);
- return (role == Role.ROLE_LIST)
- || (role == Role.ROLE_GRID)
- || (role == Role.ROLE_PAGER)
- || (node != null && node.getCollectionInfo() != null);
- });
-
- public static final Filter FILTER_COLLECTION_ITEM =
- Filter.node((node) -> node != null && node.getCollectionItemInfo() != null);
-
- private AccessibilityNodeInfoUtils() {
- // This class is not instantiable.
- }
-
- /**
- * Gets the text of a node by returning the content description (if available) or by
- * returning the text.
- *
- * @param node The node.
- * @return The node text.
- */
- public static @Nullable CharSequence getNodeText(@Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return null;
- }
-
- // Prefer content description over text.
- // TODO: Why are we checking the trimmed length?
- final CharSequence contentDescription = node.getContentDescription();
- if (!TextUtils.isEmpty(contentDescription)
- && (TextUtils.getTrimmedLength(contentDescription) > 0)) {
- return contentDescription;
- }
-
- final @Nullable CharSequence text = AccessibilityNodeInfoUtils.getText(node);
- if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) {
- return text;
- }
-
- return null;
- }
-
- /**
- * Gets the state description of a node.
- *
- * @param node The node.
- * @return The node state description.
- */
- public static @Nullable CharSequence getState(@Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return null;
- }
-
- final CharSequence state = node.getStateDescription();
- if (!TextUtils.isEmpty(state) && (TextUtils.getTrimmedLength(state) > 0)) {
- return state;
- }
-
- return null;
- }
-
- /**
- * Gets the Selected text of a node by returning the selected text.
- *
- * @param node The node.
- * @return The selected node text.
- */
- public static @Nullable CharSequence getSelectedNodeText(
- @Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return null;
- }
-
- CharSequence selectedText =
- subsequenceSafe(
- AccessibilityNodeInfoUtils.getText(node),
- node.getTextSelectionStart(),
- node.getTextSelectionEnd());
- if (!TextUtils.isEmpty(selectedText) && (TextUtils.getTrimmedLength(selectedText) > 0)) {
- return selectedText;
- }
-
- return null;
- }
-
- /** Returns a sub-string or empty-string, without crashing on invalid subsequence range. */
- public static CharSequence subsequenceSafe(
- @Nullable CharSequence text, int startIndex, int endIndex) {
- if (text == null) {
- return "";
- }
- // Swap start and end.
- if (endIndex < startIndex) {
- int newStartIndex = endIndex;
- endIndex = startIndex;
- startIndex = newStartIndex;
- }
- // Enforce string bounds.
- if (startIndex < 0) {
- startIndex = 0;
- } else if (startIndex > text.length()) {
- startIndex = text.length();
- }
- if (endIndex < 0) {
- endIndex = 0;
- } else if (endIndex > text.length()) {
- endIndex = text.length();
- }
-
- return text.subSequence(startIndex, endIndex);
- }
-
- /**
- * Gets the text selection indexes safe by adjusting the checking the selection bounds.
- *
- * @param node The node
- * @return the selection indexes
- */
- public static Pair getSelectionIndexesSafe(
- @NonNull AccessibilityNodeInfoCompat node) {
- int selectionStart = node.getTextSelectionStart();
- int selectionEnd = node.getTextSelectionEnd();
- if (selectionStart < 0) {
- selectionStart = 0;
- }
- if (selectionEnd < 0) {
- selectionEnd = selectionStart;
- }
- if (selectionEnd < selectionStart) {
- // Swap start and end to make sure they are in order.
- int newStart = selectionEnd;
- selectionEnd = selectionStart;
- selectionStart = newStart;
- }
- return Pair.create(selectionStart, selectionEnd);
- }
-
- /**
- * Gets the textual representation of the view ID that can be used when no custom label is
- * available. For better readability/listenability, the "_" characters are replaced with spaces.
- *
- * @param node The node
- * @return Readable text of the view Id
- */
- public static @Nullable String getViewIdText(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return null;
- }
-
- String resourceName = node.getViewIdResourceName();
- if (resourceName == null) {
- return null;
- }
-
- String[] parsedResourceName = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2);
- if (parsedResourceName.length != 2
- || TextUtils.isEmpty(parsedResourceName[0])
- || TextUtils.isEmpty(parsedResourceName[1])) {
- return null;
- }
-
- return parsedResourceName[1].replace('_', ' '); // readable View ID text
- }
-
- public static boolean isPage(@Nullable AccessibilityNodeInfoCompat node) {
- @Nullable AccessibilityNodeInfoCompat parent = (node == null) ? null : node.getParent();
- return (parent != null) && (Role.getRole(parent) == Role.ROLE_PAGER);
- }
-
- public static @Nullable CharSequence getSelectedPageTitle(AccessibilityNodeInfoCompat viewPager) {
- if ((viewPager == null) || (Role.getRole(viewPager) != Role.ROLE_PAGER)) {
- return null;
- }
-
- int numChildren = viewPager.getChildCount(); // Not the number of pages!
- CharSequence title = null;
- for (int i = 0; i < numChildren; ++i) {
- AccessibilityNodeInfoCompat child = viewPager.getChild(i);
- if (child != null && child.isVisibleToUser()) {
- if (title == null) {
- // Try to roughly match RulePagerPage, which uses getNodeText
- // (but completely matching all the time is not critical).
- title = getNodeText(child);
- } else {
- // Multiple visible children, abort.
- return null;
- }
- }
- }
-
- return title;
- }
-
- public static List getCustomActions(AccessibilityNodeInfoCompat node) {
- List customActions = new ArrayList<>();
- for (AccessibilityActionCompat action : node.getActionList()) {
- if (isCustomAction(action)) {
- // We don't use custom actions that doesn't have a label
- if (!TextUtils.isEmpty(action.getLabel())) {
- customActions.add(action);
- }
- }
- }
-
- return customActions;
- }
-
- public static boolean isCustomAction(AccessibilityActionCompat action) {
- return action.getId() > SYSTEM_ACTION_MAX;
- }
-
- /** Returns the root node of the tree containing {@code node}. */
- public static @Nullable AccessibilityNodeInfoCompat getRoot(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return null;
- }
-
- AccessibilityWindowInfoCompat window = getWindow(node);
- if (window != null) {
- return AccessibilityWindowInfoUtils.getRoot(window);
- }
-
- Set visitedNodes = new HashSet<>();
- AccessibilityNodeInfoCompat current = null;
- AccessibilityNodeInfoCompat parent = node;
-
- do {
- if (current != null) {
- if (visitedNodes.contains(current)) {
- return null;
- }
- visitedNodes.add(current);
- }
-
- current = parent;
- parent = current.getParent();
- } while (parent != null);
-
- return current;
- }
-
- /**
- * Returns the node of the tree at {@code targetDepth} from the root of the tree containing {@code
- * nodeCompat} with the root node considered as depth 0. This returns the last node available if
- * the target depth is greater than the number of ancestors.
- */
- public static @Nullable AccessibilityNodeInfoCompat getNthAncestorFromRoot(
- AccessibilityNodeInfoCompat nodeCompat, int targetDepth) {
- if (nodeCompat == null || targetDepth <= 0) {
- return null;
- }
-
- ArrayList visitedNodes = new ArrayList<>();
- AccessibilityNodeInfoCompat current = nodeCompat;
-
- do {
- if (visitedNodes.contains(current)) {
- break;
- }
-
- visitedNodes.add(current);
- current = current.getParent();
- } while (current != null);
-
- if (targetDepth >= visitedNodes.size()) {
- targetDepth = visitedNodes.size() - 1;
- }
-
- int nodeIndex = visitedNodes.size() - 1 - targetDepth;
- return visitedNodes.get(nodeIndex);
- }
-
- /** Returns the type of the window containing {@code nodeCompat}. */
- public static int getWindowType(AccessibilityNodeInfoCompat nodeCompat) {
- if (nodeCompat == null) {
- return WINDOW_TYPE_NONE;
- }
-
- AccessibilityWindowInfoCompat windowInfoCompat = getWindow(nodeCompat);
- if (windowInfoCompat == null) {
- return WINDOW_TYPE_NONE;
- }
-
- if (isPictureInPicture(nodeCompat)) {
- return WINDOW_TYPE_PICTURE_IN_PICTURE;
- }
-
- return windowInfoCompat.getType();
- }
-
- /** Wrapper for AccessibilityNodeInfoCompat.getWindow() that handles SecurityException. */
- public static @Nullable AccessibilityWindowInfoCompat getWindow(
- AccessibilityNodeInfoCompat node) {
- // This implementation is redundant with getWindow(AccessibilityNodeInfo) because there are no
- // un/wrap() functions for AccessibilityWindowInfoCompat.
-
- if (node == null) {
- return null;
- }
-
- try {
- return node.getWindow();
- } catch (SecurityException e) {
- LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfoCompat.getWindow()");
- return null;
- }
- }
-
- public static @Nullable AccessibilityWindowInfo getWindow(AccessibilityNodeInfo node) {
- if (node == null) {
- return null;
- }
-
- try {
- return node.getWindow();
- } catch (SecurityException e) {
- LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfo.getWindow()");
- return null;
- }
- }
-
- /**
- * Returns whether a node can receive focus from focus traversal or touch exploration. One of the
- * following must be true:
- *
- *
- * - The node is actionable (see {@link #isFocusableOrClickable(AccessibilityNodeInfoCompat)})
- *
- The node is a top-level list item (see {@link
- * #isTopLevelScrollItem(AccessibilityNodeInfoCompat)} and is a speaking node
- *
- *
- * @param node The node to check.
- * @return {@code true} of the node is accessibility focusable.
- */
- public static boolean isAccessibilityFocusable(AccessibilityNodeInfoCompat node) {
- return isFocusableOrClickable(node)
- || (isTopLevelScrollItem(node) && isSpeakingNode(node, null, new HashSet<>()));
- }
-
- /**
- * Returns whether a node should receive accessibility focus from navigation. This method should
- * never be called recursively, since it traverses up the parent hierarchy on every call.
- *
- * @see #findFocusFromHover(AccessibilityNodeInfoCompat) for touch exploration
- * @see
- * com.google.android.accessibility.talkback.focusmanagement.NavigationTarget#createNodeFilter(int,
- * Map) for linear navigation
- */
- public static boolean shouldFocusNode(AccessibilityNodeInfoCompat node) {
- return shouldFocusNode(node, null, true);
- }
-
- public static boolean shouldFocusNode(
- final AccessibilityNodeInfoCompat node,
- final Map speakingNodesCache) {
- return shouldFocusNode(node, speakingNodesCache, true);
- }
-
- public static boolean shouldFocusNode(
- final AccessibilityNodeInfoCompat node,
- final Map speakingNodesCache,
- boolean checkChildren) {
- if (node == null) {
- LogUtils.v(TAG, "Don't focus, node=null");
- return false;
- }
- // Inside views that support web navigation, we delegate focus to the view itself and
- // assume that it navigates to and focuses the correct elements.
- if (WebInterfaceUtils.supportsWebActions(node)) {
- // In history, we loosen the "visibility" check for web element: A web node can be focused
- // even if it's not visibleToUser(). However we should hold the baseline that if the WebView
- // container is not visible, we should not focus on its descendants.
- AccessibilityNodeInfoCompat webViewContainer =
- WebInterfaceUtils.ascendToWebViewContainer(node);
- return webViewContainer != null && webViewContainer.isVisibleToUser();
- }
-
- if (!isVisible(node)) {
- logShouldFocusNode(
- checkChildren, FOCUS_FAIL_NOT_VISIBLE, "Don't focus, is not visible: ", node);
- return false;
- }
-
- if (isPictureInPicture(node)) {
- // For picture-in-picture, allow focusing the root node, and any app controls inside the
- // pic-in-pic window.
- return true;
- } else {
- // Reject all non-leaf nodes that are neither actionable nor focusable, and have the same
- // bounds as the window.
- if (areBoundsIdenticalToWindow(node)
- && node.getChildCount() > 0
- && !isFocusableOrClickable(node)) {
- logShouldFocusNode(
- checkChildren,
- FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN,
- "Don't focus, bounds are same as window root node bounds, node has children and"
- + " is neither actionable nor focusable: ",
- node);
- return false;
- }
- }
-
- HashSet visitedNodes = new HashSet<>();
- // This checks if a node is clickable, focusable, screen reader focusable, or a direct
- // spekaing child of a scrollable container.
- boolean accessibilityFocusable =
- isFocusableOrClickable(node)
- || (isTopLevelScrollItem(node) && isSpeakingNode(node, null, visitedNodes));
-
- if (!checkChildren) {
- // End of the line. Don't check children and don't allow any recursion.
- // checkChildren is only false in the shouldFocusNode call below. This is to avoid
- // repetitive checks down the tree when looking up at the ancestors.
- LogUtils.d(
- TAG, "checkChildren=false and isAccessibilityFocusable=%s", accessibilityFocusable);
- return accessibilityFocusable;
- }
-
- // A node that is deemed accessibility focusable shouldn't actually get focus if it has
- // nothing to speak. For example, a view may be focusable, but if it has no text and all of
- // its children are clickable, focus should go on each child individually and not on this
- // view.
- // Note: This is redundant for nodes that pass isSpeakingNode above
- // Note: A special case exists for unlabeled buttons which otherwise wouldn't get focus.
- if (accessibilityFocusable) {
- visitedNodes.clear();
- // For TalkBack labeling feature, but this may still result in focusing non-speaking nodes.
- // We should try to narrow down the check to close to TalkBackLabelManager#needsLabel.
- if (node.getChildCount() == 0) {
- logShouldFocusNode(
- checkChildren, NONE, "Focus, is focusable and cannot keep search children: ", node);
- return true;
- } else if (isSpeakingNode(node, speakingNodesCache, visitedNodes)) {
- logShouldFocusNode(
- checkChildren, NONE, "Focus, is focusable and has something to speak: ", node);
- return true;
- } else {
- logShouldFocusNode(
- checkChildren,
- FOCUS_FAIL_NOT_SPEAKABLE,
- "Don't focus, is focusable but has nothing to speak: ",
- node);
- return false;
- }
- }
-
- // At this point, the node is an unfocusable target.
- // If it has no focusable ancestors, but it still has text, then it should receive focus and be
- // read aloud.
- Filter filter =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return shouldFocusNode(node, speakingNodesCache, false);
- }
- };
-
- if (!hasMatchingAncestor(node, filter) && (hasText(node) || hasStateDescription(node))) {
- logShouldFocusNode(checkChildren, NONE, "Focus, has text and no focusable ancestors: ", node);
- return true;
- }
-
- logShouldFocusNode(
- checkChildren,
- FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS,
- "Don't focus, failed all focusability tests: ",
- node);
- return false;
- }
-
- private static void logShouldFocusNode(
- boolean checkChildren,
- @DiagnosticType @Nullable Integer diagnosticType,
- String message,
- AccessibilityNodeInfoCompat node) {
- // When shouldFocusNode calls itself, the logs get inundated by unnecessary info about the
- // ancestors. So only log when checkChildren is true.
- if (checkChildren) {
- if (diagnosticType != NONE) {
- DiagnosticOverlayUtils.appendLog(diagnosticType, node);
- }
- // Show debug logs for #shouldFocusNode. Verbose logs will show for #isSpeakingNode
- LogUtils.v(TAG, "%s %s", message, node);
- }
- }
-
- public static boolean isPictureInPicture(@NonNull AccessibilityNodeInfoCompat node) {
- return isPictureInPicture(node.unwrap());
- }
-
- public static boolean isPictureInPicture(@Nullable AccessibilityNodeInfo node) {
- return node != null && AccessibilityWindowInfoUtils.isPictureInPicture(getWindow(node));
- }
-
- /**
- * Returns the node that should receive focus from hover by starting from the touched node and
- * calling {@link #shouldFocusNode} at each level of the view hierarchy and exclude WebView
- * container node.
- */
- public static AccessibilityNodeInfoCompat findFocusFromHover(
- @Nullable AccessibilityNodeInfoCompat touched) {
- return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(
- touched, FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW);
- }
-
- /**
- * Returns whether a node can be spoken.
- *
- * A node should be spoken if it has text, is checkable, or has children that should be spoken
- * but can't be focused themselves. This method can call itself recursively through {@link
- * #hasNonActionableSpeakingChildren}.
- *
- *
Note: This is called in the context of looking for a a11y focusable node through {@link
- * #shouldFocusNode} and {@link #isAccessibilityFocusable}
- *
- * @param node the node to check
- * @param speakingNodesCache the cache that holds the speaking results for visited nodes
- * @param visitedNodes the set of nodes that have already been visited
- * @return {@code true} if the node can be spoken
- */
- private static boolean isSpeakingNode(
- @NonNull AccessibilityNodeInfoCompat node,
- @Nullable Map speakingNodesCache,
- @NonNull Set visitedNodes) {
- if (speakingNodesCache != null && speakingNodesCache.containsKey(node)) {
- return speakingNodesCache.get(node);
- }
-
- boolean result = false;
- if (hasText(node)) {
- LogUtils.v(TAG, "Speaking, has text");
- result = true;
- } else if (hasStateDescription(node)) {
- LogUtils.v(TAG, "Speaking, has state description");
- result = true;
- } else if (node.isCheckable()) { // Special case for check boxes.
- LogUtils.v(TAG, "Speaking, is checkable");
- result = true;
- } else if (hasNonActionableSpeakingChildren(node, speakingNodesCache, visitedNodes)) {
- // Special case for containers with non-focusable content. In this case, the container should
- // speak its non-focusable yet speakable content.
- LogUtils.v(TAG, "Speaking, has non-actionable speaking children");
- result = true;
- }
-
- if (speakingNodesCache != null) {
- speakingNodesCache.put(node, result);
- }
-
- return result;
- }
-
- /**
- * Returns whether a node has children that are not actionable/focusable but should be spoken.
- *
- * This is done by ignoring any children nodes that are actionable/focusable, and checking the
- * remaining for speaking ability. Also considers offscreen/invisible children which are
- * non-actionable but which have speakable text.
- *
- * @param node the node to check
- * @param speakingNodesCache the cache that holds the speaking results for visited nodes
- * @param visitedNodes the set of nodes that have already been visited.
- * @return {@code true} if the node has children that are speaking
- */
- private static boolean hasNonActionableSpeakingChildren(
- @NonNull AccessibilityNodeInfoCompat node,
- @Nullable Map speakingNodesCache,
- @NonNull Set visitedNodes) {
- final int childCount = node.getChildCount();
-
- AccessibilityNodeInfoCompat child;
-
- for (int i = 0; i < childCount; i++) {
- child = node.getChild(i);
-
- if (child == null) {
- LogUtils.v(TAG, "Child %d is null, skipping it", i);
- continue;
- }
-
- if (!visitedNodes.add(child)) {
- return false;
- }
-
- // Ignore invisible nodes.
- if (!isVisible(child)) {
- LogUtils.v(TAG, "Child %d, %s is invisible, skipping it", i, printId(node));
- continue;
- }
-
- // Ignore focusable nodes
- if (isFocusableOrClickable(child)) {
- LogUtils.v(TAG, "Child %d, %s is focusable or clickable, skipping it", i, printId(node));
- continue;
- }
-
- // Ignore top level scroll items that 1) are speaking and 2) have non-clickable parents. This
- // means that a scrollable container that is clickable should get focus before its children.
- if ((isTopLevelScrollItem(child) && isSpeakingNode(child, speakingNodesCache, visitedNodes))
- && !(isClickable(node) || isLongClickable(node))) {
-
- LogUtils.v(TAG, "Child %d, %s is a top level scroll item, skipping it", i, printId(node));
- continue;
- }
-
- // Recursively check non-focusable child nodes.
- if (isSpeakingNode(child, speakingNodesCache, visitedNodes)) {
- LogUtils.v(TAG, "Does have actionable speaking children (child %d, %s)", i, printId(node));
- return true;
- }
- }
-
- LogUtils.v(TAG, "Does not have non-actionable speaking children. Examining invisible children");
- return hasInvisibleNonActionableSpeakingChildren(node, childCount);
- }
-
- private static boolean hasInvisibleNonActionableSpeakingChildren(
- AccessibilityNodeInfoCompat node, int childCount) {
- // We don't want the presence of invisible children to lead to focus being set on a scrollable
- // parent that is capable of showing partially-visible data.
- if (FILTER_AUTO_SCROLL.accept(node)) {
- return false;
- }
-
- // We look at invisible children and return true if an invisible child is non-actionable and
- // has associated text. Without this check, a parent would be considered unfocusable, and this
- // would cause ACTION_SHOW_ON_SCREEN to fail when the non-actionable/speakable child nodes of
- // a container are offscreen.
- AccessibilityNodeInfoCompat child;
- for (int i = 0; i < childCount; i++) {
- child = node.getChild(i);
-
- if (child == null) {
- LogUtils.v(TAG, "Child %d is null, skipping it", i);
- continue;
- }
-
- if (!child.isVisibleToUser()
- && hasText(child)
- && !(child.isScreenReaderFocusable() || isActionableForAccessibility(child))) {
- LogUtils.v(
- TAG, "Non-actionable invisible node with text found (child %d, %s)", i, printId(node));
- return true;
- }
- }
- return false;
- }
-
- public static int countVisibleChildren(@Nullable 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 && child.isVisibleToUser()) {
- ++childVisibleCount;
- }
- }
- return childVisibleCount;
- }
-
- /**
- * Returns whether a node is actionable. That is, the node supports one of the following actions:
- *
- *
- * - {@link AccessibilityNodeInfoCompat#isClickable()}
- *
- {@link AccessibilityNodeInfoCompat#isFocusable()}
- *
- {@link AccessibilityNodeInfoCompat#isLongClickable()}
- *
- *
- * This parities the system method View#isActionableForAccessibility(), which was added in
- * JellyBean.
- *
- * @param node The node to examine.
- * @return {@code true} if node is actionable.
- */
- public static boolean isActionableForAccessibility(@Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
-
- // Nodes that are clickable are always actionable.
- if (isClickable(node) || isLongClickable(node)) {
- return true;
- }
-
- if (node.isFocusable()) {
- return true;
- }
-
- if (WebInterfaceUtils.hasNativeWebContent(node)) {
- return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS);
- }
-
- return supportsAnyAction(
- node,
- AccessibilityNodeInfoCompat.ACTION_FOCUS,
- AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT,
- AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT);
- }
-
- public static boolean isSelfOrAncestorFocused(@Nullable AccessibilityNodeInfoCompat node) {
- return node != null
- && (node.isAccessibilityFocused()
- || hasMatchingAncestor(
- node,
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return (node != null) && node.isAccessibilityFocused();
- }
- }));
- }
-
- /** Returns whether {@code node} is editable or has an ancestor that is editable. */
- public static boolean isSelfOrAncestorEditable(@Nullable AccessibilityNodeInfoCompat node) {
- return getSelfOrMatchingAncestor(node, Filter.node((n) -> n.isEditable())) != null;
- }
-
- public static boolean isSelfOrAncestorRoleEditText(@Nullable AccessibilityNodeInfoCompat node) {
- return isSelfOrAncestorWithRole(node, Role.ROLE_EDIT_TEXT);
- }
-
- public static boolean isSelfOrAncestorRoleWebView(@Nullable AccessibilityNodeInfoCompat node) {
- return isSelfOrAncestorWithRole(node, Role.ROLE_WEB_VIEW);
- }
-
- private static boolean isSelfOrAncestorWithRole(
- @Nullable AccessibilityNodeInfoCompat node, int role) {
- return getSelfOrMatchingAncestor(node, Filter.node((n) -> Role.getRole(n) == role)) != null;
- }
-
- /** Returns whether {@code node} or its ancestor has the given {@code chromeRole}. */
- public static boolean isSelfOrAncestorWithChromeRole(
- @Nullable AccessibilityNodeInfoCompat node, String chromeRole) {
- return getSelfOrMatchingAncestor(
- node, Filter.node((n) -> TextUtils.equals(getChromeRole(n), chromeRole)))
- != null;
- }
-
- /** Returns whether {@code node} has the chrome role "link". */
- public static boolean isChromeRoleLink(@Nullable AccessibilityNodeInfoCompat node) {
- return node != null && TextUtils.equals(getChromeRole(node), CHROME_ROLE_LINK);
- }
-
- private static CharSequence getChromeRole(@Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return "";
- }
- AccessibilityNodeInfo info = node.unwrap();
- if (info == null) {
- return "";
- }
- return info.getExtras().getCharSequence(EXTRAS_KEY_CHROME_ROLE);
- }
-
- /**
- * Returns whether {@code node} is interactable with arrow keys. That is, the node supports at
- * least one of the following:
- *
- *
- * - {@link Role.ROLE_SEEK_CONTROL}
- *
- *
- * @return {@code true} if node is self interactable with arrow keys.
- */
- public static boolean isInteractableWithArrowKeys(@Nullable AccessibilityNodeInfoCompat node) {
- return Role.getRole(node) == Role.ROLE_SEEK_CONTROL;
- }
-
- /**
- * Returns whether a node is clickable. That is, the node supports at least one of the following:
- *
- *
- * - {@link AccessibilityNodeInfoCompat#isClickable()}
- *
- {@link AccessibilityNodeInfoCompat#ACTION_CLICK}
- *
- *
- * @param node The node to examine.
- * @return {@code true} if node is clickable.
- */
- public static boolean isClickable(@Nullable AccessibilityNodeInfoCompat node) {
- return node != null
- && (node.isClickable()
- || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_CLICK));
- }
-
- /**
- * Returns whether a node is long clickable. That is, the node supports at least one of the
- * following:
- *
- *
- * - {@link AccessibilityNodeInfoCompat#isLongClickable()}
- *
- {@link AccessibilityNodeInfoCompat#ACTION_LONG_CLICK}
- *
- *
- * @param node The node to examine.
- * @return {@code true} if node is long clickable.
- */
- public static boolean isLongClickable(@Nullable AccessibilityNodeInfoCompat node) {
- return node != null
- && (node.isLongClickable()
- || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_LONG_CLICK));
- }
-
- /**
- * Returns whether the node is focusable. That is, the node supports at least one of the
- * following:
- *
- *
- * - {@link AccessibilityNodeInfoCompat#isFocusable()}
- *
- {@link AccessibilityNodeInfoCompat#ACTION_FOCUS}
- *
- */
- public static boolean isFocusable(@Nullable AccessibilityNodeInfoCompat node) {
- return node != null
- && (node.isFocusable()
- || supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS));
- }
-
- /**
- * Returns whether a node is expandable. That is, the node supports the following action:
- *
- *
- * - {@link AccessibilityNodeInfoCompat#ACTION_EXPAND}
- *
- *
- * @param node The node to examine.
- * @return {@code true} if node is expandable.
- */
- public static boolean isExpandable(@Nullable AccessibilityNodeInfoCompat node) {
- return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_EXPAND);
- }
-
- /**
- * Returns whether a node is collapsible. That is, the node supports the following action:
- *
- *
- * - {@link AccessibilityNodeInfoCompat#ACTION_COLLAPSE}
- *
- *
- * @param node The node to examine.
- * @return {@code true} if node is collapsible.
- */
- public static boolean isCollapsible(@Nullable AccessibilityNodeInfoCompat node) {
- return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_COLLAPSE);
- }
-
- /**
- * Returns whether a node can be dismissed by the user. the node supports the following action:
- *
- *
- * - {@link AccessibilityNodeInfoCompat#ACTION_DISMISS}
- *
- *
- * @param node The node to examine.
- * @return {@code true} if node is dismissible.
- */
- public static boolean isDismissible(@Nullable AccessibilityNodeInfoCompat node) {
- return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_DISMISS);
- }
-
- /** Returns {@code true} if the node is on keyboard. */
- public static boolean isKeyboard(@Nullable AccessibilityNodeInfo source) {
- return isKeyboard(AccessibilityNodeInfoUtils.toCompat(source));
- }
-
- /** Returns {@code true} if the node is on keyboard. */
- public static boolean isKeyboard(@Nullable AccessibilityNodeInfoCompat source) {
- if (source == null) {
- return false;
- }
- AccessibilityWindowInfoCompat window = getWindow(source);
- if (window == null) {
- return false;
- }
- return AccessibilityWindowInfoUtils.isImeWindow(window);
- }
-
- /**
- * Check whether a given node has a matching ancestor given a filter.
- *
- * @param node The node to examine.
- * @param filter The filter to match the nodes against.
- * @return {@code true} if one of the node's ancestors is matching the filter.
- */
- public static boolean hasMatchingAncestor(
- @Nullable AccessibilityNodeInfoCompat node,
- @NonNull Filter filter) {
- return (node != null) && (getMatchingAncestor(node, filter) != null);
- }
-
- // TODO: Discuss with framework owner to make unread notification context available
- // to the app side.
- /**
- * Checks whether the node is the unread notification dot on the wearable sysUI.
- *
- * @param node the node to check
- * @return {@code true} if the node is the unread notification dot on the wearable sysUI.
- */
- public static boolean isWearUnreadNotificationDot(@Nullable AccessibilityNodeInfoCompat node) {
- return (node != null)
- && TextUtils.equals(node.getViewIdResourceName(), VIEW_ID_WEAR_UNREAD_NOTIFICATION_DOT);
- }
-
- /** Returns whether the node is the Pin edit field at unlock screen. */
- public static boolean isPinEntry(@Nullable AccessibilityNodeInfo node) {
- return isPinEntry(AccessibilityNodeInfoUtils.toCompat(node));
- }
-
- public static boolean isPinEntry(@Nullable AccessibilityNodeInfoCompat node) {
- return (node != null)
- && TextUtils.equals(node.getViewIdResourceName(), VIEW_ID_RESOURCE_NAME_PIN_ENTRY);
- }
-
- /**
- * 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(
- @Nullable AccessibilityNodeInfoCompat node,
- @NonNull Filter filter) {
- return (node != null) && (getSelfOrMatchingAncestor(node, filter) != null);
- }
-
- /** Check whether a given node has any descendant matching a given filter. */
- public static boolean hasMatchingDescendant(
- @Nullable AccessibilityNodeInfoCompat node,
- @NonNull Filter filter) {
- return (node != null) && (getMatchingDescendant(node, filter) != null);
- }
-
- /** Checks whether a given node or any of its descendants matches the given filter. */
- public static boolean isOrHasMatchingDescendant(
- @Nullable AccessibilityNodeInfoCompat node,
- @NonNull Filter filter) {
- return (node != null) && (getSelfOrMatchingDescendant(node, filter) != null);
- }
-
- /** Returns depth of node in node-tree, where root has depth=0. */
- public static int findDepth(@Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return -1;
- }
- NodeCounter counter = new NodeCounter();
- processSelfAndAncestors(node, counter);
- return counter.count - 1;
- }
-
- private static class NodeCounter extends Filter {
- public int count = 0;
-
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- ++count;
- return false;
- }
- }
-
- /** Applies filter to ancestor nodes. */
- public static void processSelfAndAncestors(
- @Nullable AccessibilityNodeInfoCompat node,
- @NonNull Filter filter) {
- if (node != null) {
- isOrHasMatchingAncestor(node, filter);
- }
- }
-
- /**
- * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor.
- * Returns {@code null} if no nodes match.
- */
- public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingAncestor(
- @Nullable AccessibilityNodeInfoCompat node,
- @NonNull Filter filter) {
- if (node == null) {
- return null;
- }
- if (filter.accept(node)) {
- return node;
- }
-
- return getMatchingAncestor(node, filter);
- }
-
- /**
- * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor,
- * ending the ancestor search once it reaches {@code end}. The search is inclusive of {@code node}
- * but exclusive of {@code end}. If {@code node} equals {@code end}, then {@code node} is an
- * eligible match. Returns {@code null} if no nodes match.
- */
- public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingAncestor(
- @Nullable AccessibilityNodeInfoCompat node,
- @Nullable AccessibilityNodeInfoCompat end,
- @NonNull Filter filter) {
- if (node == null) {
- return null;
- }
- if (filter.accept(node)) {
- return node;
- }
- return getMatchingAncestor(node, end, filter);
- }
-
- /**
- * Returns the {@code node} if it matches the {@code filter}, or the first matching descendant.
- * Returns {@code null} if no nodes match.
- */
- public static @Nullable AccessibilityNodeInfoCompat getSelfOrMatchingDescendant(
- @Nullable AccessibilityNodeInfoCompat node,
- @NonNull Filter filter) {
- if (node == null) {
- return null;
- }
- if (filter.accept(node)) {
- return node;
- }
- return getMatchingDescendant(node, filter);
- }
-
- /** Processes subtree of root by {@code filter}. */
- public static void processSubtree(
- @Nullable AccessibilityNodeInfoCompat root,
- @NonNull Filter filter) {
-
- AccessibilityNodeInfoUtils.getSelfOrMatchingDescendant(
- root,
- Filter.node(
- (node) -> {
- filter.accept(node);
- return false; // Force search to traverse whole subtree.
- }));
- }
-
- /**
- * Determines whether the two nodes are in the same branch; that is, they are equal or one is the
- * ancestor of the other.
- */
- public static boolean areInSameBranch(
- final @Nullable AccessibilityNodeInfoCompat node1,
- final @Nullable AccessibilityNodeInfoCompat node2) {
- if (node1 != null && node2 != null) {
- // Same node?
- if (node1.equals(node2)) {
- return true;
- }
-
- // Is node1 an ancestor of node2?
- Filter matchNode1 =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return node != null && node.equals(node1);
- }
- };
- if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node2, matchNode1)) {
- return true;
- }
-
- // Is node2 an ancestor of node1?
- Filter matchNode2 =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return node != null && node.equals(node2);
- }
- };
- if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node1, matchNode2)) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Returns the first ancestor of {@code node} that matches the {@code filter}. Returns {@code
- * null} if no nodes match.
- */
- public static @Nullable AccessibilityNodeInfoCompat getMatchingAncestor(
- @Nullable AccessibilityNodeInfoCompat node,
- @NonNull Filter filter) {
- return getMatchingAncestor(node, null, filter);
- }
-
- /**
- * 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.
- */
- private static @Nullable AccessibilityNodeInfoCompat getMatchingAncestor(
- @Nullable AccessibilityNodeInfoCompat node,
- @Nullable AccessibilityNodeInfoCompat end,
- @NonNull Filter filter) {
- if (node == null) {
- return null;
- }
-
- final HashSet ancestors = new HashSet<>();
-
- ancestors.add(node);
- node = node.getParent();
-
- while (node != null) {
- if (!ancestors.add(node)) {
- // Already seen this node, so abort!
- return null;
- }
-
- if (end != null && node.equals(end)) {
- // Reached the end node, so abort!
- return null;
- }
-
- if (filter.accept(node)) {
- return node;
- }
-
- node = node.getParent();
- }
-
- return null;
- }
-
- /**
- * Returns the number of ancestors matching the given filter. Does not include the current node in
- * the count, even if it matches the filter. If there is a cycle in the ancestor hierarchy, then
- * this method will return 0.
- */
- public static int countMatchingAncestors(
- AccessibilityNodeInfoCompat node, Filter filter) {
- if (node == null) {
- return 0;
- }
-
- final HashSet ancestors = new HashSet<>();
- int matchingAncestors = 0;
-
- ancestors.add(node);
- node = node.getParent();
-
- while (node != null) {
- if (!ancestors.add(node)) {
- // Already seen this node, so abort!
- return 0;
- }
-
- if (filter.accept(node)) {
- matchingAncestors++;
- }
-
- node = node.getParent();
- }
-
- return matchingAncestors;
- }
-
- private static @Nullable AccessibilityNodeInfoCompat getMatchingDescendant(
- AccessibilityNodeInfoCompat node,
- Filter filter,
- Filter endFilter,
- HashSet visitedNodes) {
- if (node == null) {
- return null;
- }
-
- if (visitedNodes.contains(node)) {
- return null;
- } else {
- visitedNodes.add(node);
- }
-
- int childCount = node.getChildCount();
- for (int i = 0; i < childCount; ++i) {
- AccessibilityNodeInfoCompat child = node.getChild(i);
-
- if (child == null) {
- continue;
- }
-
- if (filter.accept(child)) {
- return child; // child was already obtained by node.getChild().
- }
-
- if (endFilter != null && endFilter.accept(child)) {
- continue;
- }
-
- AccessibilityNodeInfoCompat childMatch =
- getMatchingDescendant(child, filter, endFilter, visitedNodes);
- if (childMatch != null) {
- return childMatch;
- }
- }
-
- return null;
- }
-
- /**
- * Returns the first child (by depth-first search) of {@code node} that matches the {@code
- * filter}, and skips the nodes that match the {@code endFilter}. Returns {@code null} if no nodes
- * match.
- */
- public static @Nullable AccessibilityNodeInfoCompat getMatchingDescendant(
- AccessibilityNodeInfoCompat node,
- Filter filter,
- Filter endFilter) {
- return getMatchingDescendant(node, filter, endFilter, new HashSet<>());
- }
-
- /**
- * Returns the first child (by depth-first search) of {@code node} that matches the {@code
- * filter}. Returns {@code null} if no nodes match.
- */
- public static @Nullable AccessibilityNodeInfoCompat getMatchingDescendant(
- AccessibilityNodeInfoCompat node, Filter filter) {
- return getMatchingDescendant(node, filter, /* endFilter= */ null, new HashSet<>());
- }
-
- /** Returns all descendants that match filter but skips the nested. */
- public static @Nullable List getMatchingDescendantsNotNested(
- @Nullable AccessibilityNodeInfoCompat node, Filter filter) {
- if (node == null) {
- return null;
- }
- List matches = new ArrayList<>();
- getMatchingDescendants(node, filter, /* matchChild= */ false, new HashSet<>(), matches);
- return matches;
- }
-
- /** Returns all descendants that match filter. */
- public static @Nullable List getMatchingDescendantsOrRoot(
- @Nullable AccessibilityNodeInfoCompat node, Filter filter) {
- if (node == null) {
- return null;
- }
- List matches = new ArrayList<>();
- getMatchingDescendants(node, filter, /* matchChild= */ true, new HashSet<>(), matches);
- return matches;
- }
-
- /**
- * Returns all descendants that match filter, until the stopNode is found. At that point, the
- * search will stop. Note that the stopNode is included in the results, if it matches the filter.
- */
- public static @Nullable List getMatchingDescendantsOrRootUntilNode(
- @Nullable AccessibilityNodeInfoCompat node,
- Filter filter,
- @Nullable AccessibilityNodeInfoCompat stopNode) {
- if (node == null) {
- return null;
- }
- List matches = new ArrayList<>();
- getMatchingDescendantsCore(
- node,
- filter,
- /* matchChild= */ true,
- new HashSet<>(),
- matches,
- /* stopNode= */ stopNode,
- /* searchControlFlag= */ new SearchControlFlag());
- return matches;
- }
-
- /**
- * 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 matchChild Flag that allows match with the childs of the matched nodes.
- * @param visitedNodes The set of nodes already visited, for protection against loops. This will
- * be modified.
- * @param matches The list of nodes matching filter. This will be appended to.
- */
- private static void getMatchingDescendants(
- @Nullable AccessibilityNodeInfoCompat node,
- Filter filter,
- boolean matchChild,
- Set visitedNodes,
- List matches) {
- getMatchingDescendantsCore(
- node,
- filter,
- matchChild,
- visitedNodes,
- matches,
- /* stopNode= */ null,
- /* searchControlFlag= */ null);
- }
-
- /**
- * A flag to indicate whether the stop node has been found. Using a class instead of a boolean
- * flag allows {@link #getMatchingDescendantsCore} to modify the flag and have the updated value
- * reflected in other branches of the recursive search.
- */
- private static class SearchControlFlag {
- public boolean stopNodeHasBeenFound = false;
- }
-
- /**
- * 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 matchChild Flag that allows match with the childs of the matched nodes.
- * @param visitedNodes The set of nodes already visited, for protection against loops. This will
- * be modified.
- * @param matches The list of nodes matching filter. This will be appended to.
- * @param stopNode The node to stop searching at. Note that this node is included in the matches,
- * if it matches the filter.
- * @param searchControlFlag A flag to indicate whether the stop node has been found. See {@link
- * SearchControlFlag} for details.
- */
- private static void getMatchingDescendantsCore(
- @Nullable AccessibilityNodeInfoCompat node,
- Filter filter,
- boolean matchChild,
- Set visitedNodes,
- List matches,
- @Nullable AccessibilityNodeInfoCompat stopNode,
- @Nullable SearchControlFlag searchControlFlag) {
-
- if (node == null) {
- return;
- }
-
- // Update visited nodes.
- if (visitedNodes.contains(node)) {
- return;
- } else {
- visitedNodes.add(node);
- }
-
- // Stop searching if the stop node has been found.
- if (searchControlFlag != null && searchControlFlag.stopNodeHasBeenFound) {
- return;
- }
-
- // If node matches filter... collect node.
- if (filter.accept(node)) {
- matches.add(node);
- }
-
- // If the stop node has been found, future searches can be skipped, even if the stopNode does
- // not match the filter.
- if (searchControlFlag != null && node.equals(stopNode)) {
- searchControlFlag.stopNodeHasBeenFound = true;
- }
-
- // For each child of node...
- if (!matches.contains(node) || matchChild) {
- int childCount = node.getChildCount();
- for (int i = 0; i < childCount; ++i) {
- AccessibilityNodeInfoCompat child = node.getChild(i);
- if (child == null) {
- continue;
- }
- getMatchingDescendantsCore(
- child, filter, matchChild, visitedNodes, matches, stopNode, searchControlFlag);
- }
- }
- }
-
- /**
- * Check whether a given node is scrollable.
- *
- * @param node The node to examine.
- * @return {@code true} if the node is scrollable.
- */
- public static boolean isScrollable(AccessibilityNodeInfoCompat node) {
- // In some cases node#isScrollable lies. (Notably, some nodes that correspond to WebViews claim
- // to be scrollable, but do not support any scroll actions. This seems to stem from a bug in the
- // translation from the DOM to the AccessibilityNodeInfo.) To avoid labeling views that don't
- // support scrolling (e.g. REFERTO), check for the explicit presence of
- // AccessibilityActions.
- return supportsAnyAction(
- node,
- AccessibilityActionCompat.ACTION_SCROLL_FORWARD,
- AccessibilityActionCompat.ACTION_SCROLL_BACKWARD,
- AccessibilityActionCompat.ACTION_SCROLL_DOWN,
- AccessibilityActionCompat.ACTION_SCROLL_UP,
- AccessibilityActionCompat.ACTION_SCROLL_RIGHT,
- AccessibilityActionCompat.ACTION_SCROLL_LEFT);
- }
-
- /**
- * Returns whether the specified node has text. For the purposes of this check, any node with a
- * CollectionInfo is considered to not have text since its text and content description are used
- * only for collection transitions.
- *
- * @param node The node to check.
- * @return {@code true} if the node has text.
- */
- private static boolean hasText(@Nullable AccessibilityNodeInfoCompat node) {
- return node != null
- && node.getCollectionInfo() == null
- && (!TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))
- || !TextUtils.isEmpty(node.getContentDescription())
- || !TextUtils.isEmpty(node.getHintText()));
- }
-
- /**
- * 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 if a node is focusable or clickable.
- *
- * This is used in {@link #shouldFocusNode} and {@link #isAccessibilityFocusable}
- *
- * @param node the node to check
- * @return {@code true} if the node is focusable or clickable
- */
- private static boolean isFocusableOrClickable(AccessibilityNodeInfoCompat node) {
- return (node != null)
- && isVisible(node)
- && (node.isScreenReaderFocusable() || isActionableForAccessibility(node));
- }
-
- /**
- * Determines whether a node is a top-level item in a scrollable container.
- *
- * @param node The node to test.
- * @return {@code true} if {@code node} is a top-level item in a scrollable container.
- */
- public static boolean isTopLevelScrollItem(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
-
- if (!isVisible(node)) {
- return false;
- }
-
- AccessibilityNodeInfoCompat parent = node.getParent();
- return isScrollItem(parent);
- }
-
- private static boolean isScrollItem(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- // Not a child node of anything.
- return false;
- }
-
- // Drop down lists (spinners) are not included to retain the old behavior of focusing on
- // the spinner itself rather than on the single visible item.
- // A spinner being scrollable is disingenuous since the scrollable list inside isn't exposed
- // without interaction.
- if (Role.getRole(node) == Role.ROLE_DROP_DOWN_LIST) {
- return false;
- }
-
- // A node with a scrollable parent is a top level scroll item.
- if (isScrollable(node)) {
- return true;
- }
-
- @Role.RoleName int parentRole = Role.getRole(node);
- // Note that ROLE_DROP_DOWN_LIST(Spinner) is not accepted.
- // RecyclerView is classified as a list or grid based on its CollectionInfo.
- // These parents may not be scrollable in some cases, like if the list is too short to be
- // scrolled, but their children should still be considered top level scroll items.
- return parentRole == Role.ROLE_LIST
- || parentRole == Role.ROLE_GRID
- || parentRole == Role.ROLE_SCROLL_VIEW
- || parentRole == Role.ROLE_HORIZONTAL_SCROLL_VIEW
- || nodeMatchesAnyClassByType(node, CLASS_TOUCHWIZ_TWADAPTERVIEW);
- }
-
- public static boolean hasAncestor(
- AccessibilityNodeInfoCompat node, final AccessibilityNodeInfoCompat targetAncestor) {
- if (node == null || targetAncestor == null) {
- return false;
- }
-
- Filter filter =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return targetAncestor.equals(node);
- }
- };
-
- return (getMatchingAncestor(node, filter) != null);
- }
-
- public static boolean hasDescendant(
- @Nullable AccessibilityNodeInfoCompat node,
- @Nullable AccessibilityNodeInfoCompat targetDescendant) {
- if (node == null || targetDescendant == null) {
- return false;
- }
-
- Filter filter =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat node) {
- return targetDescendant.equals(node);
- }
- };
-
- return (getMatchingDescendant(node, filter) != null);
- }
-
- /**
- * Determines if the generating class of an {@link AccessibilityNodeInfoCompat} matches a given
- * {@link Class} by type.
- *
- * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility
- * framework.
- * @param referenceClass A {@link Class} to match by type or inherited type.
- * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object matches the {@link
- * Class} by type or inherited type, {@code false} otherwise.
- */
- public static boolean nodeMatchesClassByType(
- AccessibilityNodeInfoCompat node, Class> referenceClass) {
- if ((node == null) || (referenceClass == null)) {
- return false;
- }
-
- // Attempt to take a shortcut.
- final CharSequence nodeClassName = node.getClassName();
- if (TextUtils.equals(nodeClassName, referenceClass.getName())) {
- return true;
- }
-
- return ClassLoadingCache.checkInstanceOf(nodeClassName, referenceClass);
- }
-
- /**
- * Determines if the generating class of an {@link AccessibilityNodeInfoCompat} matches any of the
- * given {@link Class}es by type.
- *
- * @param node A sealed {@link AccessibilityNodeInfoCompat} dispatched by the accessibility
- * framework.
- * @return {@code true} if the {@link AccessibilityNodeInfoCompat} object matches the {@link
- * Class} by type or inherited type, {@code false} otherwise.
- * @param referenceClasses A variable-length list of {@link Class} objects to match by type or
- * inherited type.
- */
- public static boolean nodeMatchesAnyClassByType(
- AccessibilityNodeInfoCompat node, Class>... referenceClasses) {
- if (node == null) {
- return false;
- }
-
- for (Class> referenceClass : referenceClasses) {
- if (ClassLoadingCache.checkInstanceOf(node.getClassName(), referenceClass)) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Recycles the given nodes.
- *
- * @param nodes The nodes to recycle.
- */
- public static void recycleNodes(Collection nodes) {
- nodes.clear();
- }
-
- /**
- * Recycles the given nodes.
- *
- * @param nodes The nodes to recycle.
- */
- public static void recycleNodes(@Nullable AccessibilityNodeInfo... nodes) {}
-
- /**
- * Recycles the given nodes.
- *
- * @param nodes The nodes to recycle.
- */
- public static void recycleNodes(@Nullable AccessibilityNodeInfoCompat... nodes) {}
-
- /**
- * Returns {@code true} if the node supports at least one of the specified actions. This method
- * supports actions introduced in API level 21 and later. However, it does not support bitmasks.
- *
- * @param node The node to check
- * @param actions The actions to check
- * @return {@code true} if at least one action is supported
- */
- // TODO: Use A11yActionCompat once AccessibilityActionCompat#equals is overridden
- public static boolean supportsAnyAction(
- AccessibilityNodeInfoCompat node, AccessibilityActionCompat... actions) {
- if (node == null) {
- return false;
- }
- // Unwrap the node and compare AccessibilityActions because AccessibilityActions, unlike
- // AccessibilityActionCompats, are static (so checks for equality work correctly).
- final List supportedActions = node.getActionList();
-
- for (AccessibilityActionCompat action : actions) {
- if (supportedActions.contains(action)) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Returns {@code true} if the node supports at least one of the specified actions. To check
- * whether a node supports multiple actions, combine them using the {@code |} (logical OR)
- * operator.
- *
- * Note: this method will check against the getActions() method of AccessibilityNodeInfo, which
- * will not contain information for actions introduced in API level 21 or later.
- *
- * @param node The node to check.
- * @param actions The actions to check.
- * @return {@code true} if at least one action is supported.
- */
- // TODO: Remove this method once AccessibilityActionCompat#equals is overridden
- public static boolean supportsAnyAction(
- @Nullable AccessibilityNodeInfoCompat node, int... actions) {
- if (node != null) {
- final int supportedActions = node.getActions();
-
- for (int action : actions) {
- if ((supportedActions & action) == action) {
- return true;
- }
- }
- }
-
- return false;
- }
-
- /**
- * Returns {@code true} if the node supports the specified action. This method supports actions
- * introduced in API level 21 and later. However, it does not support bitmasks.
- */
- public static boolean supportsAction(@NonNull AccessibilityNodeInfoCompat node, int action) {
- // New actions in >= API 21 won't appear in getActions() but in getActionList().
- // On Lollipop+ devices, pre-API 21 actions will also appear in getActionList().
- List actions = node.getActionList();
- int size = actions.size();
- for (int i = 0; i < size; ++i) {
- AccessibilityActionCompat actionCompat = actions.get(i);
- if (actionCompat.getId() == action) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Returns the action label on the node by given action ID, or an empty text if the node doesn't
- * support the action.
- */
- public static @Nullable CharSequence getActionLabelById(
- @NonNull AccessibilityNodeInfoCompat node, int action) {
- List actions = node.getActionList();
- int size = actions.size();
- for (int i = 0; i < size; ++i) {
- AccessibilityActionCompat actionCompat = actions.get(i);
- if (actionCompat.getId() == action) {
- return actionCompat.getLabel();
- }
- }
- return "";
- }
-
- /**
- * Returns the result of applying a filter using breadth-first traversal.
- *
- * @param node The root node to traverse from.
- * @param filter The filter to satisfy.
- * @return The first node reached via BFS traversal that satisfies the filter.
- */
- public static AccessibilityNodeInfoCompat searchFromBfs(
- AccessibilityNodeInfoCompat node, Filter filter) {
- return searchFromBfs(node, filter, /* filterToSkip= */ null);
- }
-
- /**
- * Returns the result of applying a filter using breadth-first traversal. It allows skip nodes to
- * speed up the BFS traversal.
- *
- * @param node The root node to traverse from.
- * @param filter The filter to satisfy.
- * @param filterToSkip The filter for skipping nodes, all childs under the node will be skipped.
- * @return The first node reached via BFS traversal that satisfies the filter.
- */
- public static @Nullable AccessibilityNodeInfoCompat searchFromBfs(
- AccessibilityNodeInfoCompat node,
- Filter filter,
- @Nullable Filter filterToSkip) {
- if (node == null) {
- return null;
- }
-
- final ArrayDeque queue = new ArrayDeque<>();
- Set visitedNodes = new HashSet<>();
-
- queue.add(node);
-
- while (!queue.isEmpty()) {
- final AccessibilityNodeInfoCompat item = queue.removeFirst();
- visitedNodes.add(item);
-
- if (filterToSkip != null && filterToSkip.accept(item)) {
- continue;
- }
-
- if (filter.accept(item)) {
- return item;
- }
-
- final int childCount = item.getChildCount();
-
- for (int i = 0; i < childCount; i++) {
- final AccessibilityNodeInfoCompat child = item.getChild(i);
-
- if (child != null && !visitedNodes.contains(child)) {
- queue.addLast(child);
- }
- }
- }
- return null;
- }
-
- /** Safely obtains a copy of node. */
- @Deprecated
- public static @Nullable AccessibilityNodeInfoCompat obtain(AccessibilityNodeInfoCompat node) {
- return (node == null) ? null : AccessibilityNodeInfoCompat.obtain(node);
- }
-
- /**
- * Returns a fresh copy of {@code node} with properties that are less likely to be stale. Returns
- * {@code null} if the node can't be found anymore.
- */
- public static @Nullable AccessibilityNodeInfoCompat refreshNode(
- AccessibilityNodeInfoCompat node) {
- return ((node == null) || !node.refresh()) ? null : node;
- }
-
- /**
- * Gets the location of specific range of node text. It returns null if the node doesn't support
- * text location data or the index is incorrect.
- *
- * @param node The node being queried.
- * @param fromCharIndex start index of the queried text range.
- * @param toCharIndex end index of the queried text range.
- */
- public static @Nullable List getTextLocations(
- AccessibilityNodeInfoCompat node, int fromCharIndex, int toCharIndex) {
- return getTextLocations(
- node, AccessibilityNodeInfoUtils.getText(node), fromCharIndex, toCharIndex);
- }
-
- /**
- * Gets the location of specific range of node {@code text}. It returns null if the node doesn't
- * support text location data or the index is incorrect.
- *
- * @param node The node being queried.
- * @param text The node's text. This is typically the text, but can also be the content
- * description if the node was not properly created. If the content description is used, its
- * text location will only be returned if it's visible on the screen.
- * @param fromCharIndex start index of the queried text range.
- * @param toCharIndex end index of the queried text range.
- */
- public static @Nullable List getTextLocations(
- AccessibilityNodeInfoCompat node, CharSequence text, int fromCharIndex, int toCharIndex) {
- return getTextLocations(node, text, fromCharIndex, toCharIndex, true);
- }
-
- /**
- * Gets the location of specific range of node {@code text}. It returns null if the node doesn't
- * support text location data or the index is incorrect.
- *
- * @param node The node being queried.
- * @param text The node's text. This is typically the text, but can also be the content
- * description if the node was not properly created. If the content description is used, its
- * text location will only be returned if it's visible on the screen.
- * @param fromCharIndex start index of the queried text range.
- * @param toCharIndex end index of the queried text range.
- * @param useWindowBound experimental feature that should more accurately determine word position.
- */
- public static @Nullable List getTextLocations(
- AccessibilityNodeInfoCompat node,
- CharSequence text,
- int fromCharIndex,
- int toCharIndex,
- boolean useWindowBound) {
- if (node == null) {
- return null;
- }
-
- if (fromCharIndex < 0
- || TextUtils.isEmpty(text)
- || !PrimitiveUtils.isInInterval(toCharIndex, fromCharIndex, text.length(), true)) {
- return null;
- }
- AccessibilityNodeInfo info = node.unwrap();
- if (info == null) {
- return null;
- }
- // Prefer character bounds in window, but fall back to character bounds in screen if not
- // available.
- String key = AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY;
- boolean isBoundsInWindow = false;
- if (useWindowBound
- && BuildVersionUtils.isAtLeastBaklava()
- && info.getAvailableExtraData()
- .contains(
- AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY)) {
- key = AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY;
- isBoundsInWindow = true;
- }
- Bundle args = new Bundle();
- args.putInt(
- AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_START_INDEX, fromCharIndex);
- args.putInt(
- AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_ARG_LENGTH,
- toCharIndex - fromCharIndex);
- if (!info.refreshWithExtraData(key, args)) {
- return null;
- }
-
- Bundle extras = info.getExtras();
- Parcelable[] data = extras.getParcelableArray(key);
- if (data == null) {
- return null;
- }
-
- Rect windowBounds = new Rect();
- if (isBoundsInWindow) {
- AccessibilityWindowInfo windowInfo = info.getWindow();
- windowInfo.getBoundsInScreen(windowBounds);
- }
- List result = new ArrayList<>(data.length);
- for (Parcelable item : data) {
- if (item == null) {
- continue;
- }
- RectF rectF = (RectF) item;
- if (isBoundsInWindow) {
- rectF.offset(windowBounds.left, windowBounds.top);
- }
- result.add(
- new Rect((int) rectF.left, (int) rectF.top, (int) rectF.right, (int) rectF.bottom));
- }
- return result;
- }
-
- /** Returns true if the node supports text location data. */
- public static boolean supportsTextLocation(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
- AccessibilityNodeInfo info = node.unwrap();
- if (info == null) {
- return false;
- }
- List extraData = info.getAvailableExtraData();
- return extraData != null
- && (extraData.contains(AccessibilityNodeInfo.EXTRA_DATA_TEXT_CHARACTER_LOCATION_KEY)
- || (BuildVersionUtils.isAtLeastBaklava()
- && extraData.contains(
- AccessibilityNodeInfoCompat.EXTRA_DATA_TEXT_CHARACTER_LOCATION_IN_WINDOW_KEY)));
- }
-
- /** Helper method that returns {@code true} if the specified node is visible to the user */
- public static boolean isVisible(AccessibilityNodeInfoCompat node) {
- // We need to move focus to invisible node in WebView to scroll it but we don't want to
- // move focus if WebView itself is invisible.
- return node != null
- && (node.isVisibleToUser()
- || (WebInterfaceUtils.isWebContainer(node)
- && Role.getRole(node) != Role.ROLE_WEB_VIEW));
- }
-
- /**
- * Checks whether the node's height is smaller than the threshold
- *
- * @param context the context
- * @param node the node to check
- * @return {@code true} if the node's height is smaller than the dp threshold.
- */
- public static boolean isSmallNodeInHeight(Context context, AccessibilityNodeInfoCompat node) {
- final Rect nodeRect = new Rect();
- node.getBoundsInScreen(nodeRect);
-
- return nodeRect.height() < DisplayUtils.dpToPx(context, THRESHOLD_HEIGHT_DP_FOR_SMALL_NODE);
- }
-
- /**
- * Checks whether the node is a top or bottom border node or not. Horizontal scrolling with a
- * check of left or right border isn't yet supported in this method.
- *
- * @param screenPxSize the pixel size of a screen
- * @param node the node to check
- * @return {@code true} if the node is at top or bottom border.
- */
- public static boolean isTopOrBottomBorderNode(
- Point screenPxSize, AccessibilityNodeInfoCompat node) {
-
- final Rect nodeRect = new Rect();
- node.getBoundsInScreen(nodeRect);
-
- // check the screen's border
- if (isTopOrBottomBorderNode(nodeRect, screenPxSize)) {
- return true;
- }
-
- // check the scrollable container's border
- final Rect parentRect = new Rect();
- Filter filter =
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat parent) {
- if (isScrollItem(parent)) {
- parent.getBoundsInScreen(parentRect);
- return parentRect.top == nodeRect.top || parentRect.bottom == nodeRect.bottom;
- }
- return false;
- }
- };
-
- return hasMatchingAncestor(node, filter);
- }
-
- private static boolean isTopOrBottomBorderNode(Rect nodeRect, Point screenPxSize) {
- return nodeRect.top <= 0 || nodeRect.bottom >= screenPxSize.y;
- }
-
- /** Determines whether the specified node has bounds identical to the bounds of its window. */
- private static boolean areBoundsIdenticalToWindow(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
-
- AccessibilityWindowInfoCompat window = getWindow(node);
- if (window == null) {
- return false;
- }
-
- Rect windowBounds = new Rect();
- window.getBoundsInScreen(windowBounds);
-
- Rect nodeBounds = new Rect();
- node.getBoundsInScreen(nodeBounds);
-
- return windowBounds.equals(nodeBounds);
- }
-
- /**
- * Analyses if the edit text has no text.
- *
- * If there is a text field with hint text and no text, {@link
- * AccessibilityNodeInfoUtils#getText()} returns hint text. Hence this method checks for {@link
- * AccessibilityNodeInfo#ACTION_SET_SELECTION} to disregard the hint text.
- */
- public static boolean isEmptyEditTextRegardlessOfHint(
- @Nullable AccessibilityNodeInfoCompat node) {
- if (node == null || !node.isEditable()) {
- return false;
- }
-
- if (TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))) {
- return true;
- }
- return !supportsAction(node, AccessibilityNodeInfo.ACTION_SET_SELECTION);
- }
-
- /** * Checks if node represents non-editable selectable text. */
- public static boolean isNonEditableSelectableText(AccessibilityNodeInfoCompat node) {
- if (node != null && FeatureSupport.supportsIsTextSelectable()) {
- return !node.isEditable() && node.unwrap().isTextSelectable();
- }
- return false;
- }
-
- /** * Checks if node represents selectable text. Editable text is selectable. */
- public static boolean isTextSelectable(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
- boolean isEditable = Role.getRole(node) == Role.ROLE_EDIT_TEXT || node.isEditable();
- boolean isNonEditableSelectableText =
- AccessibilityNodeInfoUtils.isNonEditableSelectableText(node);
- return isEditable || isNonEditableSelectableText;
- }
-
- /**
- * Gets a list of URLs contained within an {@link AccessibilityNodeInfoCompat}.
- *
- * @param node The node that will be searched for links
- * @return A list of {@link SpannableUrl}s from the URLs found within the Node
- */
- public static List getNodeUrls(AccessibilityNodeInfoCompat node) {
- return getNodeClickableElements(
- node, URLSpan.class, input -> SpannableUrl.create(input.first, (URLSpan) input.second));
- }
-
- /**
- * Gets a list of ClickableSpans paired with the String they span within a node's text.
- *
- * @param node The node that will be searched for spans
- * @return A list of Clickable elements found within the Node.
- */
- public static List getNodeClickableStrings(AccessibilityNodeInfoCompat node) {
- return getNodeClickableElements(
- node, ClickableSpan.class, input -> ClickableString.create(input.first, input.second));
- }
-
- /**
- * Gets a list of the clickable elements within a node.
- *
- * @param node the node to get the clickable elements from
- * @param clickableType the type of clickable span that we look for within the node
- * @param clickableElementFn a function taking the visual string representation and the clickable
- * portion of the clickable element to produces the desired format that will be displayable to
- * the user
- * @param the displayable format representation of the clickable element
- * @return a list of clickable elements, empty if there is none
- */
- private static List getNodeClickableElements(
- AccessibilityNodeInfoCompat node,
- Class extends ClickableSpan> clickableType,
- Function, E> clickableElementFn) {
- List spannableStrings = new ArrayList<>();
- SpannableTraversalUtils.getSpannableStringsWithTargetClickableSpanInNodeTree(
- node, clickableType, spannableStrings);
-
- List clickables = new ArrayList<>(1);
- for (SpannableWithOffset spannableOffset : spannableStrings) {
- if (spannableOffset == null || spannableOffset.spannableString == null) {
- continue;
- }
- SpannableString spannable = spannableOffset.spannableString;
- for (ClickableSpan span : spannable.getSpans(0, spannable.length(), clickableType)) {
- // Child classes may not use #getUrl, so just check that the class is a URLSpan, instead of
- // a child class with "instanceof".
- if ((span.getClass() == URLSpan.class)
- && Strings.isNullOrEmpty(((URLSpan) span).getURL())) {
- continue;
- }
- int start = spannable.getSpanStart(span);
- int end = spannable.getSpanEnd(span);
- if (end > start) {
- char[] chars = new char[end - start];
- spannable.getChars(start, end, chars, 0);
- clickables.add(clickableElementFn.apply(Pair.create(new String(chars), span)));
- }
- }
- }
- return clickables;
- }
-
- public static int getMovementGranularity(AccessibilityNodeInfoCompat node) {
- // Some nodes in Webview have movement granularities even its content description/text is
- // empty.
- if (WebInterfaceUtils.supportsWebActions(node)
- && TextUtils.isEmpty(node.getContentDescription())
- && TextUtils.isEmpty(AccessibilityNodeInfoUtils.getText(node))) {
- return 0;
- }
-
- return node.getMovementGranularities();
- }
-
- public static CharSequence getHintText(AccessibilityNodeInfoCompat node) {
- CharSequence hintText = node.getHintText();
- if (TextUtils.isEmpty(hintText)) {
- Bundle bundle = node.getExtras();
- if (bundle != null) {
- // Hint text for WebView.
- hintText = bundle.getCharSequence(HINT_TEXT_KEY);
- }
- }
-
- return hintText;
- }
-
- /**
- * To setup a hashmap for AccessibilityAction id and the display string. We only build into the
- * hash map with identifiers which are supported in the running platform.
- */
- private static HashMap initActionIds() {
- HashMap actionIdHashMap = new HashMap<>();
-
- actionIdHashMap.put(AccessibilityAction.ACTION_SHOW_ON_SCREEN.getId(), "ACTION_SHOW_ON_SCREEN");
- actionIdHashMap.put(
- AccessibilityAction.ACTION_SCROLL_TO_POSITION.getId(), "ACTION_SCROLL_TO_POSITION");
- actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_UP.getId(), "ACTION_SCROLL_UP");
- actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_LEFT.getId(), "ACTION_SCROLL_LEFT");
- actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_DOWN.getId(), "ACTION_SCROLL_DOWN");
- actionIdHashMap.put(AccessibilityAction.ACTION_SCROLL_RIGHT.getId(), "ACTION_SCROLL_RIGHT");
- actionIdHashMap.put(AccessibilityAction.ACTION_CONTEXT_CLICK.getId(), "ACTION_CONTEXT_CLICK");
- actionIdHashMap.put(AccessibilityAction.ACTION_SET_PROGRESS.getId(), "ACTION_SET_PROGRESS");
- actionIdHashMap.put(AccessibilityAction.ACTION_MOVE_WINDOW.getId(), "ACTION_MOVE_WINDOW");
-
- if (BuildVersionUtils.isAtLeastP()) {
- actionIdHashMap.put(AccessibilityAction.ACTION_SHOW_TOOLTIP.getId(), "ACTION_SHOW_TOOLTIP");
- actionIdHashMap.put(AccessibilityAction.ACTION_HIDE_TOOLTIP.getId(), "ACTION_HIDE_TOOLTIP");
- }
- if (BuildVersionUtils.isAtLeastQ()) {
- actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_RIGHT.getId(), "ACTION_PAGE_RIGHT");
- actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_LEFT.getId(), "ACTION_PAGE_LEFT");
- actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_DOWN.getId(), "ACTION_PAGE_DOWN");
- actionIdHashMap.put(AccessibilityAction.ACTION_PAGE_UP.getId(), "ACTION_PAGE_UP");
- }
- if (BuildVersionUtils.isAtLeastR()) {
- actionIdHashMap.put(
- AccessibilityAction.ACTION_PRESS_AND_HOLD.getId(), "ACTION_PRESS_AND_HOLD");
- actionIdHashMap.put(AccessibilityAction.ACTION_IME_ENTER.getId(), "ACTION_IME_ENTER");
- }
- return actionIdHashMap;
- }
-
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // Methods for displaying node data
-
- public static String actionToString(int action) {
- switch (action) {
- case AccessibilityNodeInfoCompat.ACTION_ACCESSIBILITY_FOCUS -> {
- return "ACTION_ACCESSIBILITY_FOCUS";
- }
- case AccessibilityNodeInfoCompat.ACTION_CLEAR_ACCESSIBILITY_FOCUS -> {
- return "ACTION_CLEAR_ACCESSIBILITY_FOCUS";
- }
- case AccessibilityNodeInfoCompat.ACTION_CLEAR_FOCUS -> {
- return "ACTION_CLEAR_FOCUS";
- }
- case AccessibilityNodeInfoCompat.ACTION_CLEAR_SELECTION -> {
- return "ACTION_CLEAR_SELECTION";
- }
- case AccessibilityNodeInfoCompat.ACTION_CLICK -> {
- return "ACTION_CLICK";
- }
- case AccessibilityNodeInfoCompat.ACTION_COLLAPSE -> {
- return "ACTION_COLLAPSE";
- }
- case AccessibilityNodeInfoCompat.ACTION_COPY -> {
- return "ACTION_COPY";
- }
- case AccessibilityNodeInfoCompat.ACTION_CUT -> {
- return "ACTION_CUT";
- }
- case AccessibilityNodeInfoCompat.ACTION_DISMISS -> {
- return "ACTION_DISMISS";
- }
- case AccessibilityNodeInfoCompat.ACTION_EXPAND -> {
- return "ACTION_EXPAND";
- }
- case AccessibilityNodeInfoCompat.ACTION_FOCUS -> {
- return "ACTION_FOCUS";
- }
- case AccessibilityNodeInfoCompat.ACTION_LONG_CLICK -> {
- return "ACTION_LONG_CLICK";
- }
- case AccessibilityNodeInfoCompat.ACTION_NEXT_AT_MOVEMENT_GRANULARITY -> {
- return "ACTION_NEXT_AT_MOVEMENT_GRANULARITY";
- }
- case AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT -> {
- return "ACTION_NEXT_HTML_ELEMENT";
- }
- case AccessibilityNodeInfoCompat.ACTION_PASTE -> {
- return "ACTION_PASTE";
- }
- case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY -> {
- return "ACTION_PREVIOUS_AT_MOVEMENT_GRANULARITY";
- }
- case AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT -> {
- return "ACTION_PREVIOUS_HTML_ELEMENT";
- }
- case AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD -> {
- return "ACTION_SCROLL_BACKWARD";
- }
- case AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD -> {
- return "ACTION_SCROLL_FORWARD";
- }
- case AccessibilityNodeInfoCompat.ACTION_SELECT -> {
- return "ACTION_SELECT";
- }
- case AccessibilityNodeInfoCompat.ACTION_SET_SELECTION -> {
- return "ACTION_SET_SELECTION";
- }
- case AccessibilityNodeInfoCompat.ACTION_SET_TEXT -> {
- return "ACTION_SET_TEXT";
- }
- default -> {}
- }
- @Nullable String actionName = actionIdToName.get(action);
- return actionName == null ? "(unhandled action:" + action + ")" : actionName;
- }
-
- public static String toStringShort(@Nullable AccessibilityNodeInfo node) {
- return toStringShort(toCompat(node));
- }
-
- public static String toStringShort(@Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return "null";
- }
- return StringBuilderUtils.joinFields(
- "AccessibilityNodeInfoCompat",
- StringBuilderUtils.optionalInt("id", node.hashCode(), -1),
- StringBuilderUtils.optionalText("class", node.getClassName()),
- StringBuilderUtils.optionalText("package", node.getPackageName()),
- // TODO: Uses hash value in production build
- StringBuilderUtils.optionalText(
- "text",
- (AccessibilityNodeInfoUtils.getText(node) == null)
- ? null
- : FeatureSupport.logcatIncludePsi()
- // Logs for DEBUG build or user had opt-in
- ? AccessibilityNodeInfoUtils.getText(node)
- : "***"),
- StringBuilderUtils.optionalText("state", node.getStateDescription()),
- StringBuilderUtils.optionalText("content", node.getContentDescription()),
- StringBuilderUtils.optionalText("viewIdResName", node.getViewIdResourceName()),
- StringBuilderUtils.optionalText("hint", node.getHintText()),
- StringBuilderUtils.optionalTag("enabled", node.isEnabled()),
- StringBuilderUtils.optionalTag("checkable", node.isCheckable()),
- StringBuilderUtils.optionalTag("checked", node.isChecked()),
- StringBuilderUtils.optionalTag("accessibilityFocused", node.isAccessibilityFocused()),
- StringBuilderUtils.optionalTag("focusable", isFocusable(node)),
- StringBuilderUtils.optionalTag("screenReaderFocusable", node.isScreenReaderFocusable()),
- StringBuilderUtils.optionalTag("focused", node.isFocused()),
- StringBuilderUtils.optionalTag("selected", node.isSelected()),
- StringBuilderUtils.optionalTag("clickable", isClickable(node)),
- StringBuilderUtils.optionalTag("longClickable", isLongClickable(node)),
- StringBuilderUtils.optionalTag("password", node.isPassword()),
- StringBuilderUtils.optionalTag("textEntryKey", node.isTextEntryKey()),
- StringBuilderUtils.optionalTag("scrollable", isScrollable(node)),
- StringBuilderUtils.optionalTag(
- "heading", FeatureSupport.isHeadingWorks() && node.isHeading()),
- StringBuilderUtils.optionalTag("collapsible", isCollapsible(node)),
- StringBuilderUtils.optionalTag("expandable", isExpandable(node)),
- StringBuilderUtils.optionalTag("dismissable", isDismissible(node)),
- StringBuilderUtils.optionalTag("pinEntry", isPinEntry(node)),
- StringBuilderUtils.optionalTag("visible", node.isVisibleToUser()));
- }
-
- /** Copied from AccessibilityNodeInfo.java */
- public static @Nullable String getMovementGranularitySymbolicName(int granularity) {
- if (granularity == 0) {
- return null;
- }
- return switch (granularity) {
- case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_CHARACTER -> "MOVEMENT_GRANULARITY_CHARACTER";
- case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_WORD -> "MOVEMENT_GRANULARITY_WORD";
- case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_LINE -> "MOVEMENT_GRANULARITY_LINE";
- case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PARAGRAPH -> "MOVEMENT_GRANULARITY_PARAGRAPH";
- case AccessibilityNodeInfo.MOVEMENT_GRANULARITY_PAGE -> "MOVEMENT_GRANULARITY_PAGE";
- default -> Integer.toHexString(granularity);
- };
- }
-
- /**
- * Given a double value, get the int percentage (0 to 100, both inclusive). Only return 0 or 100
- * when percentage is exactly 0 or 100 percent.
- */
- public static int roundForProgressPercent(double percent) {
- if (percent < 0.0f) {
- return 0;
- } else if (percent > 0.0f && percent < 1.0f) {
- return 1;
- } else if (percent > 99.0f && percent < 100.0f) {
- return 99;
- } else if (percent > 100.0f) {
- return 100;
- }
- return (int) Math.round(percent);
- }
-
- ////////////////////////////////////////////////////////////////////////////////////////////////
- // Methods for node properties
-
- /**
- * Returns {@code true} if the height and width of the {@link AccessibilityNodeInfoCompat}'s
- * visible bounds on the screen are greater than a specified number of minimum pixels. This can be
- * used to prune tiny elements or elements off the screen.
- *
- * {@link AccessibilityNodeInfo#isVisibleToUser()} sometimes returns {@code true} for {@link
- * android.webkit.WebView} items off the screen, so this method allows us to better ignore WebView
- * content off the screen.
- *
- * @param node The node that will be checked for a minimum number of pixels on the screen
- * @return {@code true} if the node has at least the number of minimum visible pixels in both
- * width and height on the screen
- */
- public static boolean hasMinimumPixelsVisibleOnScreen(AccessibilityNodeInfoCompat node) {
- Rect visibleBounds = new Rect();
- node.getBoundsInScreen(visibleBounds);
- return ((Math.abs(visibleBounds.height()) >= MIN_VISIBLE_PIXELS)
- && (Math.abs(visibleBounds.width()) >= MIN_VISIBLE_PIXELS));
- }
-
- /**
- * Returns the progress percentage from the node. The value will be in the range [0, 100].
- *
- * @param node The node from which to obtain the progress percentage.
- * @return The progress percentage.
- */
- public static float getProgressPercent(@Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return 0.0f;
- }
-
- final @Nullable RangeInfoCompat rangeInfo = node.getRangeInfo();
- if (rangeInfo == null) {
- return 0.0f;
- }
-
- final float maxProgress = rangeInfo.getMax();
- final float minProgress = rangeInfo.getMin();
- final float currentProgress = rangeInfo.getCurrent();
- final float diffProgress = maxProgress - minProgress;
- if (diffProgress <= 0.0f) {
- logError("getProgressPercent", "Range is invalid. [%f, %f]", minProgress, maxProgress);
- return 0.0f;
- }
-
- if (currentProgress < minProgress) {
- logError(
- "getProgressPercent",
- "Current percent is out of range. Current: %f Range: [%f, %f]",
- currentProgress,
- minProgress,
- maxProgress);
- return 0.0f;
- }
-
- if (currentProgress > maxProgress) {
- logError(
- "getProgressPercent",
- "Current percent is out of range. Current: %f Range: [%f, %f]",
- currentProgress,
- minProgress,
- maxProgress);
- return 100.0f;
- }
-
- final float percent = (currentProgress - minProgress) / diffProgress;
- return (100.0f * Math.max(0.0f, Math.min(1.0f, percent)));
- }
-
- /**
- * 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;
- }
-
- final @Nullable 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);
- }
-
- /** Checks whether the given node is still in the window. */
- public static boolean isInWindow(
- AccessibilityNodeInfoCompat checkingNode,
- @Nullable AccessibilityWindowInfoCompat windowInfoCompat) {
- if (windowInfoCompat == null) {
- return false;
- }
- int windowId = checkingNode.getWindowId();
- if (windowId != WINDOW_ID_NONE && windowId != windowInfoCompat.getId()) {
- return false;
- }
- return hasDescendant(windowInfoCompat.getRoot(), checkingNode);
- }
-
- /** Checks whether the given node is still in the window. */
- public static boolean isInWindow(
- AccessibilityNodeInfoCompat checkingNode, @Nullable AccessibilityWindowInfo windowInfo) {
- if (windowInfo == null) {
- return false;
- }
- int windowId = checkingNode.getWindowId();
- if (windowId != WINDOW_ID_NONE && windowId != windowInfo.getId()) {
- return false;
- }
- return hasDescendant(toCompat(windowInfo.getRoot()), checkingNode);
- }
-
- /**
- * Checks whether the given node is a header.
- *
- *
On M devices, the return value is always false if the node is an item in ListView or
- * GridView but not in WebView.
- */
- // TODO On pre-N devices, the framework ListView/GridView will mark non-headers
- // as headers. The workaround should be removed when TalkBack doesn't support android M.
- public static boolean isHeading(AccessibilityNodeInfoCompat node) {
- if (!FeatureSupport.isHeadingWorks()) {
- AccessibilityNodeInfoCompat collectionRoot = getCollectionRoot(node);
- if (nodeIsListOrGrid(collectionRoot) && !WebInterfaceUtils.isWebContainer(collectionRoot)) {
- return false;
- }
- }
- return node.isHeading();
- }
-
- /**
- * Returns the collection root for the given node. As it searches for the collection root, if
- * there are more than one collection item along the way upwards, this function will return null
- * as the a11y tree is formatted incorrectly.
- *
- *
For nested collection items, a collection node must always exist between an ancestor and a
- * descendant collection item. If this function is called on a descendant item that is directly
- * nested under an ancestor item (without an intermediary collection node), it will return null.
- * See b/409569562#4.
- *
- * @param node The node to search for the collection root.
- * @return The collection root, or {@code null} if no collection root is found.
- */
- public static @Nullable AccessibilityNodeInfoCompat getCollectionRoot(
- @Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return null;
- }
-
- Filter filter = FILTER_COLLECTION.or(FILTER_COLLECTION_ITEM);
-
- AccessibilityNodeInfoCompat collectionRoot = getSelfOrMatchingAncestor(node, filter);
- if (collectionRoot == null || FILTER_COLLECTION.accept(collectionRoot)) {
- return collectionRoot;
- }
-
- collectionRoot = getMatchingAncestor(collectionRoot, filter);
- if (collectionRoot == null || FILTER_COLLECTION.accept(collectionRoot)) {
- return collectionRoot;
- }
-
- return null;
- }
-
- /**
- * Returns the collection root for the given node, excluding the node itself from the search.
- *
- * @param node The node to search for the collection root.
- * @return The collection root, or {@code null} if no collection root is found.
- */
- public static @Nullable AccessibilityNodeInfoCompat getCollectionRootExcludeSelf(
- @Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return null;
- }
-
- if (FILTER_COLLECTION.accept(node)) {
- return getCollectionRoot(node.getParent());
- }
-
- return getCollectionRoot(node);
- }
-
- /** Returns a table root containing the given node. */
- public static @Nullable AccessibilityNodeInfoCompat getTableRoot(
- @Nullable AccessibilityNodeInfoCompat descendant) {
- return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(descendant, FILTER_TABLE);
- }
-
- /** Returns whether the given node is a table root. */
- private static boolean isTableRoot(AccessibilityNodeInfoCompat node) {
- CollectionInfoCompat collectionInfo = node.getCollectionInfo();
- return collectionInfo != null
- && collectionInfo.getRowCount() > 1
- && collectionInfo.getColumnCount() > 1;
- }
-
- /** Returns a table cell under table containing the given node. */
- public static @Nullable AccessibilityNodeInfoCompat getTableCellUnderTable(
- @Nullable AccessibilityNodeInfoCompat descendant) {
- return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(
- descendant, FILTER_TABLE_CELL_UNDER_TABLE);
- }
-
- /** Returns a node that mapped to the voice dictation clickable view. */
- public static @Nullable AccessibilityNodeInfoCompat getVoiceDictationNode(
- @Nullable AccessibilityNodeInfoCompat descendant) {
- return AccessibilityNodeInfoUtils.getSelfOrMatchingDescendant(
- descendant, FILTER_VOICE_DICTATION);
- }
-
- private static boolean isVoiceDictationNode(AccessibilityNodeInfoCompat node) {
- return Role.getRole(node) == Role.ROLE_VOICE_DICTATION_BUTTON;
- }
-
- /** Returns whether the given node is a table cell. */
- private static boolean isTableCell(AccessibilityNodeInfoCompat node) {
- CollectionItemInfoCompat collectionItemInfo = node.getCollectionItemInfo();
- return collectionItemInfo != null
- && collectionItemInfo.getRowIndex() >= 0
- && collectionItemInfo.getColumnIndex() >= 0;
- }
-
- /** Returns whether the given node is a table cell in a table. */
- private static boolean isTableCellUnderTable(AccessibilityNodeInfoCompat node) {
- CollectionItemInfoCompat collectionItemInfo = node.getCollectionItemInfo();
- return collectionItemInfo != null
- && collectionItemInfo.getRowIndex() >= 0
- && collectionItemInfo.getColumnIndex() >= 0
- && getTableRoot(node) != null;
- }
-
- /** Checks if given node is ListView or GirdView. */
- public static boolean nodeIsListOrGrid(@Nullable AccessibilityNodeInfoCompat node) {
- return nodeMatchesAnyClassName(node, CLASS_LISTVIEW, CLASS_GRIDVIEW);
- }
-
- /** Returns {@code true} if the parent of the {@code node} is a collection. */
- public static boolean nodeIsListOrGridItem(@Nullable AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
-
- @Nullable AccessibilityNodeInfoCompat parent = node.getParent();
- if (parent == null) {
- return false;
- }
-
- @RoleName int role = Role.getRole(parent);
- return role == Role.ROLE_LIST || role == Role.ROLE_GRID;
- }
-
- /** Returns true if the {@code node} is in a collection. */
- public static boolean isInCollection(AccessibilityNodeInfoCompat node) {
- return AccessibilityNodeInfoUtils.hasMatchingAncestor(
- node,
- new Filter() {
- @Override
- public boolean accept(AccessibilityNodeInfoCompat ancestor) {
- @RoleName int role = Role.getRole(ancestor);
- return role == Role.ROLE_LIST
- || role == Role.ROLE_GRID
- || (ancestor != null && ancestor.getCollectionInfo() != null);
- }
- });
- }
-
- public static @Nullable String getGridRowTitle(AccessibilityNodeInfoCompat node) {
- if (FeatureSupport.supportGridTitle() && node.unwrap() != null) {
- CollectionItemInfo itemInfo = node.unwrap().getCollectionItemInfo();
- if (itemInfo != null) {
- return itemInfo.getRowTitle();
- }
- }
- return null;
- }
-
- public static @Nullable String getGridColumnTitle(AccessibilityNodeInfoCompat node) {
- if (FeatureSupport.supportGridTitle() && node.unwrap() != null) {
- CollectionItemInfo itemInfo = node.unwrap().getCollectionItemInfo();
- if (itemInfo != null) {
- return itemInfo.getColumnTitle();
- }
- }
- return null;
- }
-
- /**
- * Returns true if the {@link
- * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat} associated
- * with {@code node} is not null and reflects the presence of at least 1 row and 1 column.
- */
- public static boolean hasUsableCollectionInfo(AccessibilityNodeInfoCompat node) {
- return node != null
- && node.getCollectionInfo() != null
- && node.getCollectionInfo().getRowCount() >= 1
- && node.getCollectionInfo().getColumnCount() >= 1;
- }
-
- /**
- * Returns true if the {@link
- * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat}
- * associated with {@code node} is not null and contains legal collection row and column indices.
- */
- public static boolean hasUsableCollectionItemInfo(AccessibilityNodeInfoCompat node) {
- return node != null
- && node.getCollectionItemInfo() != null
- && node.getCollectionItemInfo().getRowIndex() >= 0
- && node.getCollectionItemInfo().getColumnIndex() >= 0;
- }
-
- /**
- * Returns true if the {@link
- * androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat}
- * associated with {@code node} is not null, and it contains legal collection row and column
- * indices, which fall within the row and column bounds of {@code parent}.
- */
- public static boolean hasUsableCollectionItemInfo(
- AccessibilityNodeInfoCompat item, AccessibilityNodeInfoCompat collection) {
- return hasUsableCollectionItemInfo(item)
- && hasUsableCollectionInfo(collection)
- && item.getCollectionItemInfo().getRowIndex() < collection.getCollectionInfo().getRowCount()
- && item.getCollectionItemInfo().getColumnIndex()
- < collection.getCollectionInfo().getColumnCount();
- }
-
- /**
- * Returns the {@link Rect} of the node bounds in screen coordinates, and returns an empty Rect if
- * the given node is null.
- */
- public static Rect getNodeBoundsInScreen(@Nullable AccessibilityNodeInfoCompat node) {
- Rect nodeBounds = new Rect();
- if (node != null) {
- node.getBoundsInScreen(nodeBounds);
- }
- return nodeBounds;
- }
-
- /**
- * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor
- * position in the given {@link AccessibilityNodeInfoCompat} if the input method for the node is
- * able to display spelling suggestions.
- *
- * @param node The node to check
- */
- public static ImmutableList getSpellingSuggestions(
- Context context, AccessibilityNodeInfoCompat node) {
- return getSpellingSuggestions(context, node, /* activeSpellCheck= */ true);
- }
-
- /**
- * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor
- * position in the given {@link AccessibilityNodeInfoCompat}.
- *
- * @param node the node to check
- * @param cursorPosition index of the cursor position
- */
- public static ImmutableList getSpellingSuggestions(
- Context context, AccessibilityNodeInfoCompat node, int cursorPosition) {
- return getSpellingSuggestions(context, node, cursorPosition, /* activeSpellCheck= */ true);
- }
-
- /**
- * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor
- * position in the given {@link AccessibilityNodeInfoCompat} if the input method for the node is
- * able to display spelling suggestions.
- *
- * @param node The node to check
- * @param activeSpellCheck Perform in service spell check or not
- */
- public static ImmutableList getSpellingSuggestions(
- Context context, AccessibilityNodeInfoCompat node, boolean activeSpellCheck) {
- if (node == null || hasNoSuggestionsNeed(node.getInputType())) {
- return ImmutableList.of();
- }
-
- int start = node.getTextSelectionStart();
- int end = node.getTextSelectionEnd();
-
- if (start != end) {
- LogUtils.v(TAG, "Spelling suggestion does not work when text is selected.");
- return ImmutableList.of();
- }
-
- return getSpellingSuggestions(context, node, end, activeSpellCheck);
- }
-
- /**
- * Returns a list of {@link SpellingSuggestion} for all {@link SuggestionSpan}s at the cursor
- * position in the given {@link AccessibilityNodeInfoCompat}.
- *
- * @param node the node to check
- * @param cursorPosition index of the cursor position
- */
- // common_typos_disable
- public static ImmutableList getSpellingSuggestions(
- Context context,
- AccessibilityNodeInfoCompat node,
- int cursorPosition,
- boolean activeSpellCheck) {
- @Nullable CharSequence text =
- activeSpellCheck ? SpellChecker.getTextWithSuggestionSpans(context, node) : node.getText();
- List spellingSuggestions = new ArrayList<>();
- if (TextUtils.isEmpty(text) || !(text instanceof Spannable spannedText)) {
- LogUtils.v(TAG, "getSpellingSuggestions() text is null or not a Spannable");
- return ImmutableList.of();
- }
-
- // Returns the suggestion if just a space or punctuation is between the typo and the cursor.
- // For example: helllo,|
- if (cursorPosition > 0) {
- if (cursorPosition < text.length()) {
- // Do not return the suggestion if a word is after the cursor. For example: helllo |world
- if (!Character.isLetterOrDigit(text.charAt(cursorPosition - 1))
- && !Character.isLetterOrDigit(text.charAt(cursorPosition))) {
- cursorPosition--;
- }
- } else if (cursorPosition == text.length()) {
- // It is unnecessary to check the character after the cursor because the cursor is at the
- // end of the line. For example: helllo |
- if (!Character.isLetterOrDigit(text.charAt(cursorPosition - 1))) {
- cursorPosition--;
- }
- }
- }
-
- SuggestionSpan[] spans = spannedText.getSpans(0, text.length(), SuggestionSpan.class);
- StringBuilder logMessage =
- new StringBuilder(
- String.format(
- Locale.ENGLISH,
- "cursor=[%d] suggestion_spans text=[%s] spans=[%d]",
- cursorPosition,
- text,
- spans.length));
- // TODO: Uses stream to simplify it.
- for (SuggestionSpan span : spans) {
- int start = spannedText.getSpanStart(span);
- int end = spannedText.getSpanEnd(span);
- if (start <= cursorPosition && end >= cursorPosition) {
- SpellingSuggestion spellingSuggestion =
- SpellingSuggestion.create(start, end, text.subSequence(start, end), span);
- // Ignore the span which has no suggestion to avoid announcing suggestions available but
- // there is no suggestion that can be chosen.
- if (span.getSuggestions().length > 0) {
- spellingSuggestions.add(spellingSuggestion);
- } else {
- LogUtils.v(TAG, "%s no suggestion", text.subSequence(start, end));
- }
-
- logMessage.append("\n");
- logMessage.append(spellingSuggestion);
- }
- }
-
- LogUtils.v(TAG, logMessage.toString());
- return ImmutableList.copyOf(spellingSuggestions);
- }
-
- /**
- * Returns the total number of typos which are in the edit field.
- *
- * @return 0, there is no typo or the input method for the node won't display spelling
- * suggestions.
- */
- public static int getTypoCount(Context context, AccessibilityNodeInfoCompat node) {
- return getSuggestionSpans(context, node).size();
- }
-
- /**
- * Returns {@code true} if the given {@link AccessibilityNodeInfoCompat} text includes misspelled
- * words which have spelling suggestions and the input method for the node is able to display
- * spelling suggestions.
- */
- public static boolean hasSpellingSuggestionsForTypos(
- Context context, AccessibilityNodeInfoCompat node) {
- ImmutableList spans = getSuggestionSpans(context, node);
- for (SuggestionSpan span : spans) {
- if (span.getSuggestions().length > 0) {
- return true;
- }
- }
- return false;
- }
-
- /**
- * Returns {@link Locale} if the given {@link AccessibilityNodeInfoCompat} supports App Locale.
- */
- public static @Nullable Locale getLocalesByNode(AccessibilityNodeInfoCompat node) {
- if (node == null || !FeatureSupport.supportAccessibilityAppLocale()) {
- return null;
- }
- AccessibilityWindowInfoCompat windowInfoCompat = node.getWindow();
- if (windowInfoCompat == null) {
- return null;
- }
- AccessibilityWindowInfo windowInfo = windowInfoCompat.unwrap();
- if (windowInfo == null) {
- return null;
- }
- LocaleList localeList = windowInfo.getLocales();
- Locale defaultLocal = Locale.getDefault();
-
- int count = (localeList == null) ? 0 : localeList.size();
- if (count == 0 || defaultLocal.equals(localeList.get(0))) {
- // AccessibilityWindowInfo#getLocales may return the system default locale. When the 1st entry
- // matches the default locale, we don't insert the locale which will invalidate the locale
- // embedded within the content.
- return null;
- }
- return localeList.get(0);
- }
-
- /** Returns whether the node has requested initial accessibility focus. */
- public static boolean hasRequestInitialAccessibilityFocus(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return false;
- }
-
- boolean hasRequestInitialAccessibilityFocus = node.hasRequestInitialAccessibilityFocus();
-
- // In the early version of AndroidX, the property was retrieved from the AccessibilityNodeInfo.
- // See b/279108748 for details.
- if (!hasRequestInitialAccessibilityFocus
- && FeatureSupport.supportRequestInitialAccessibilityFocusNative()) {
- AccessibilityNodeInfo unwrap = node.unwrap();
- if (unwrap != null) {
- hasRequestInitialAccessibilityFocus = unwrap.hasRequestInitialAccessibilityFocus();
- }
- }
-
- return hasRequestInitialAccessibilityFocus;
- }
-
- /**
- * Returns the rate update limitation (in milli-second) if the given {@link
- * AccessibilityNodeInfoCompat} supports it.
- */
- public static long getMinDurationBetweenContentChangesMillis(AccessibilityNodeInfoCompat node) {
- if (node == null) {
- LogUtils.w(TAG, "Failed to getMinDurationBetweenContentChangesMillis/node is null");
- return 0L;
- }
- return node.getMinDurationBetweenContentChangesMillis();
- }
-
- /**
- * Returns a list of {@link SuggestionSpan} in the given {@link AccessibilityNodeInfoCompat} text
- * or an empty list if the input method for the node won't display spelling suggestions.
- */
- private static ImmutableList getSuggestionSpans(
- Context context, AccessibilityNodeInfoCompat node) {
- if (node == null) {
- return ImmutableList.of();
- }
- return getSuggestionSpans(context, node.getText(), node.getInputType());
- }
-
- /**
- * Returns a list of {@link SuggestionSpan} in the given text or an empty list if the input type
- * is no suggestion.
- */
- public static ImmutableList getSuggestionSpans(
- Context context, @Nullable CharSequence text, int inputType) {
- if (TextUtils.isEmpty(text) || hasNoSuggestionsNeed(inputType)) {
- return ImmutableList.of();
- }
-
- @Nullable CharSequence textWithSuggestionSpans =
- SpellChecker.getTextWithSuggestionSpans(context, text);
- if (TextUtils.isEmpty(textWithSuggestionSpans)
- || !(textWithSuggestionSpans instanceof Spannable spannedText)) {
- return ImmutableList.of();
- }
-
- SuggestionSpan[] spans =
- spannedText.getSpans(0, textWithSuggestionSpans.length(), SuggestionSpan.class);
- if (spans.length == 0) {
- return ImmutableList.of();
- }
-
- return ImmutableList.copyOf(spans);
- }
-
- /**
- * Returns {@code true}, if the input method for the {@code node} won't display spelling
- * suggestions.
- */
- private static boolean hasNoSuggestionsNeed(int input) {
- return input == TYPE_TEXT_FLAG_NO_SUGGESTIONS;
- }
-
- private static boolean nodeMatchesAnyClassName(
- @Nullable AccessibilityNodeInfoCompat node, CharSequence... classNames) {
- if (node == null || node.getClassName() == null || classNames == null) {
- return false;
- }
-
- for (CharSequence name : classNames) {
- if (TextUtils.equals(node.getClassName(), name)) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * Splits a fully-qualified resource identifier name into its package and ID name. For example,
- * "com.android.deskclock:id/analog_appwidget" which provides by {@link
- * AccessibilityNodeInfoCompat#getViewIdResourceName()}
- */
- @AutoValue
- public abstract static class ViewResourceName {
- public abstract String packageName();
-
- public abstract String viewIdName();
-
- /** Creates a ViewResourceName instance by {@link AccessibilityNodeInfoCompat}. */
- public static @Nullable ViewResourceName create(AccessibilityNodeInfoCompat node) {
- String resourceName = node.getViewIdResourceName();
- if (TextUtils.isEmpty(resourceName)) {
- return null;
- }
-
- final String[] splitId = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2);
- if (splitId.length != 2 || TextUtils.isEmpty(splitId[0]) || TextUtils.isEmpty(splitId[1])) {
- // Invalid view resource name.
- LogUtils.w(TAG, "Failed to parse resource: %s", resourceName);
- return null;
- }
-
- return new AutoValue_AccessibilityNodeInfoUtils_ViewResourceName(splitId[0], splitId[1]);
- }
-
- @Override
- public final String toString() {
- return "ViewResourceName= "
- + StringBuilderUtils.joinFields(
- StringBuilderUtils.optionalText("packageName", packageName()),
- StringBuilderUtils.optionalText("viewIdName", viewIdName()));
- }
- }
-
- /**
- * Represents a {@link ClickableSpan} and the string it spans to reduce the effort of downstream
- * consumers; getting the spanned string is non-trivial.
- */
- @AutoValue
- public abstract static class ClickableString {
- public static ClickableString create(String string, ClickableSpan clickableSpan) {
- return new AutoValue_AccessibilityNodeInfoUtils_ClickableString(string, clickableSpan);
- }
-
- public abstract String string();
-
- public abstract ClickableSpan clickableSpan();
-
- // ClickableSpan.onClick is actually fine with a null param.
- public void onClick() {
- clickableSpan().onClick(null);
- }
- }
-
- /** A wrapper of {@link SuggestionSpan}. */
- @AutoValue
- public abstract static class SpellingSuggestion {
- public abstract int start();
-
- public abstract int end();
-
- public abstract CharSequence misspelledWord();
-
- public abstract SuggestionSpan suggestionSpan();
-
- public static SpellingSuggestion create(
- int start, int end, CharSequence misspelledWord, SuggestionSpan suggestionSpan) {
- return new AutoValue_AccessibilityNodeInfoUtils_SpellingSuggestion(
- start, end, misspelledWord, suggestionSpan);
- }
-
- @Override
- public final @NonNull String toString() {
- StringBuilder suggestionsString =
- new StringBuilder()
- .append(
- String.format(Locale.ENGLISH, "[%d-%d][%s]", start(), end(), misspelledWord()));
- for (String suggestion : suggestionSpan().getSuggestions()) {
- suggestionsString.append(String.format(Locale.ENGLISH, "[suggestion=%s]", suggestion));
- }
-
- return suggestionsString.toString();
- }
- }
-
- private static String printId(AccessibilityNodeInfoCompat node) {
- return String.format("Node(id=%s class=%s)", node.hashCode(), node.getClassName());
- }
-}
diff --git a/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.kt b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.kt
new file mode 100644
index 000000000..993759011
--- /dev/null
+++ b/utils/src/main/java/com/google/android/accessibility/utils/AccessibilityNodeInfoUtils.kt
@@ -0,0 +1,3671 @@
+/*
+ * Copyright (C) 2012 The Android Open Source Project
+ *
+ * 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.
+ *
+ * Ported from Java to Kotlin.
+ */
+
+package com.google.android.accessibility.utils
+
+import android.content.Context
+import android.graphics.Point
+import android.graphics.Rect
+import android.graphics.RectF
+import android.os.Bundle
+import android.os.LocaleList
+import android.os.Parcelable
+import android.text.InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
+import android.text.Spannable
+import android.text.SpannableString
+import android.text.TextUtils
+import android.text.style.ClickableSpan
+import android.text.style.SuggestionSpan
+import android.text.style.URLSpan
+import android.util.Pair
+import android.view.accessibility.AccessibilityNodeInfo
+import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction
+import android.view.accessibility.AccessibilityNodeInfo.CollectionItemInfo
+import android.view.accessibility.AccessibilityWindowInfo
+import android.widget.GridView
+import android.widget.ListView
+import androidx.annotation.VisibleForTesting
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.AccessibilityActionCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionInfoCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.CollectionItemInfoCompat
+import androidx.core.view.accessibility.AccessibilityNodeInfoCompat.RangeInfoCompat
+import androidx.core.view.accessibility.AccessibilityWindowInfoCompat
+import com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_ID_NONE
+import com.google.android.accessibility.utils.AccessibilityWindowInfoUtils.WINDOW_TYPE_NONE
+import com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS
+import com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_SPEAKABLE
+import com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_NOT_VISIBLE
+import com.google.android.accessibility.utils.DiagnosticOverlayUtils.FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN
+import com.google.android.accessibility.utils.DiagnosticOverlayUtils.NONE
+import com.google.android.accessibility.utils.DiagnosticOverlayUtils.DiagnosticType
+import com.google.android.accessibility.utils.Role.ROLE_GRID
+import com.google.android.accessibility.utils.Role.ROLE_HORIZONTAL_SCROLL_VIEW
+import com.google.android.accessibility.utils.Role.ROLE_LIST
+import com.google.android.accessibility.utils.Role.ROLE_PAGER
+import com.google.android.accessibility.utils.Role.ROLE_SCROLL_VIEW
+import com.google.android.accessibility.utils.Role.ROLE_WEB_VIEW
+import com.google.android.accessibility.utils.Role.RoleName
+import com.google.android.accessibility.utils.SpannableUtils.SpannableWithOffset
+import com.google.android.accessibility.utils.compat.CompatUtils
+import com.google.android.accessibility.utils.traversal.SpannableTraversalUtils
+import com.google.android.libraries.accessibility.utils.log.LogUtils
+import com.google.android.libraries.accessibility.utils.url.SpannableUrl
+import com.google.common.base.Function
+import com.google.common.base.Strings
+import com.google.common.collect.ImmutableList
+import com.google.common.collect.ImmutableSet
+import com.google.errorprone.annotations.FormatMethod
+import com.google.errorprone.annotations.FormatString
+import java.util.ArrayDeque
+import java.util.Locale
+import java.util.regex.Pattern
+
+/** Provides a series of utilities for interacting with AccessibilityNodeInfo objects. */
+object AccessibilityNodeInfoUtils {
+
+ /** Internal AccessibilityNodeInfoCompat extras bundle key constants. */
+ // The minimum amount of pixels that must be visible for a view to be surfaced to the user as
+ // visible (i.e. for this node to be added to the tree).
+ const val MIN_VISIBLE_PIXELS = 15
+
+ private val CLASS_LISTVIEW: String = ListView::class.java.name
+ private val CLASS_GRIDVIEW: String = GridView::class.java.name
+
+ private val actionIdToName: HashMap = initActionIds()
+
+ /** Returns text from an accessibility-node, including spans. */
+ @JvmStatic
+ fun getText(node: AccessibilityNodeInfoCompat?): CharSequence? {
+ return if (node == null) null else node.text
+ }
+
+ @FormatMethod
+ private fun logError(functionName: String, @FormatString format: String, vararg args: Any?) {
+ LogUtils.e(TAG, functionName + "() " + String.format(format, *args))
+ }
+
+ //////////////////////////////////////////////////////////////////////////////////////////
+ // Constants
+
+ private const val TAG = "AccessibilityNodeInfoUtils"
+
+ /**
+ * Class for Samsung's TouchWiz implementation of AdapterView. May be {@code null} on non-Samsung
+ * devices.
+ */
+ private val CLASS_TOUCHWIZ_TWADAPTERVIEW: Class<*>? =
+ CompatUtils.getClass("com.sec.android.touchwiz.widget.TwAdapterView")
+
+ /** Key to get accessibility web hints from the web */
+ private const val HINT_TEXT_KEY = "AccessibilityNodeInfo.hint"
+
+ private val RESOURCE_NAME_SPLIT_PATTERN: Pattern = Pattern.compile(":id/")
+
+ /** Class used to find clickable-spans in text. */
+ @JvmField val BASE_CLICKABLE_SPAN: Class = ClickableSpan::class.java
+
+ private const val VIEW_ID_RESOURCE_NAME_PIN_ENTRY = "com.android.systemui:id/pinEntry"
+
+ @VisibleForTesting
+ internal const val VIEW_ID_WEAR_UNREAD_NOTIFICATION_DOT =
+ "com.google.android.wearable.sysui:id/unread_dot"
+
+ @VisibleForTesting internal const val THRESHOLD_HEIGHT_DP_FOR_SMALL_NODE = 32
+
+ /** Key to get the chrome role from the node */
+ private const val EXTRAS_KEY_CHROME_ROLE = "AccessibilityNodeInfo.chromeRole"
+
+ /**
+ * Chrome role for link. The role string should come from `ToString(ax::mojom::Role role)` at
+ * ui/accessibility/ax_enum_util.cc in the Chromium repo.
+ * https://source.chromium.org/chromium/chromium/src/+/main:ui/accessibility/ax_enum_util.cc?q=%22ToString(ax::mojom::Role%20role)%22%20f:ui%2Faccessibility%2Fax_enum_util.cc
+ */
+ private const val CHROME_ROLE_LINK = "link"
+
+ /**
+ * A wrapper over AccessibilityNodeInfoCompat constructor, so that we can add any desired error
+ * checking and memory management.
+ *
+ * @param nodeInfo The AccessibilityNodeInfo which will be wrapped.
+ * @return Encapsulating AccessibilityNodeInfoCompat, or null if input is null.
+ */
+ @JvmStatic
+ fun toCompat(nodeInfo: AccessibilityNodeInfo?): AccessibilityNodeInfoCompat? {
+ if (nodeInfo == null) {
+ return null
+ }
+ return AccessibilityNodeInfoCompat.wrap(nodeInfo)
+ }
+
+ private const val SYSTEM_ACTION_MAX = 0x01FFFFFF
+
+ const val WINDOW_TYPE_PICTURE_IN_PICTURE = 1000
+
+ /**
+ * Filter for scrollable items. One of the following must be true:
+ *
+ *
+ * - {@link AccessibilityNodeInfoCompat#isScrollable()} returns {@code true}
+ *
- {@link AccessibilityNodeInfoCompat#getActions()} supports {@link
+ * AccessibilityNodeInfoCompat#ACTION_SCROLL_FORWARD}
+ *
- {@link AccessibilityNodeInfoCompat#getActions()} supports {@link
+ * AccessibilityNodeInfoCompat#ACTION_SCROLL_BACKWARD}
+ *
+ */
+ @JvmField
+ val FILTER_SCROLLABLE: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return isScrollable(node)
+ }
+ }
+
+ /** Filter for items that could be scrolled forward. */
+ @JvmField
+ val FILTER_COULD_SCROLL_FORWARD: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null &&
+ supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_FORWARD)
+ }
+ }
+
+ /** Filter for items that could be scrolled backward. */
+ @JvmField
+ val FILTER_COULD_SCROLL_BACKWARD: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null &&
+ supportsAction(node, AccessibilityNodeInfoCompat.ACTION_SCROLL_BACKWARD)
+ }
+ }
+
+ /**
+ * Filter for items that should receive accessibility focus. Equivalent to calling {@link
+ * #shouldFocusNode(AccessibilityNodeInfoCompat)}.
+ *
+ * Note: Use {@link #FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW} has a filter for
+ * {@link AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events.
+ */
+ @JvmField
+ val FILTER_SHOULD_FOCUS: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null && shouldFocusNode(node)
+ }
+ }
+
+ /**
+ * Filter for items that should receive accessibility focus from {@link
+ * AccessibilityEvent#TYPE_VIEW_HOVER_ENTER} events. WebView container node should not be focus
+ * for hover enter actions.
+ */
+ @JvmField
+ val FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW: Filter =
+ FILTER_SHOULD_FOCUS.and(
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return Role.getRole(node) != Role.ROLE_WEB_VIEW
+ }
+ })
+
+ /** Filter for heading items in collections. */
+ @JvmField
+ val FILTER_HEADING: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return (node != null) && isHeading(node)
+ }
+ }
+
+ private val CONTAINER_ROLES: ImmutableSet =
+ ImmutableSet.of(
+ ROLE_LIST,
+ ROLE_GRID,
+ ROLE_PAGER,
+ ROLE_SCROLL_VIEW,
+ ROLE_HORIZONTAL_SCROLL_VIEW,
+ ROLE_WEB_VIEW)
+
+ /** Filter for container. */
+ @JvmField
+ val FILTER_CONTAINER: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null &&
+ (CONTAINER_ROLES.contains(Role.getRole(node)) ||
+ !TextUtils.isEmpty(node.containerTitle))
+ }
+ }
+
+ /**
+ * Filter for focusable containers with a descendant that is an unfocusable heading. This filter
+ * aids navigation by headings granularity when the node that is semantically a heading isn't
+ * focusable (for instance, because its text is combined with the text of other nodes to create
+ * speakable text for a container in a list context).
+ */
+ @JvmField
+ val FILTER_CONTAINER_WITH_UNFOCUSABLE_HEADING: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return searchFromBfs(
+ node,
+ FILTER_HEADING.and(
+ object : Filter() {
+ override fun accept(childNode: AccessibilityNodeInfoCompat): Boolean {
+ return childNode.childCount == 0 && !shouldFocusNode(childNode)
+ }
+ })) != null
+ }
+ }
+
+ /** Filter for scrollable grids. */
+ @JvmField
+ val FILTER_SCROLLABLE_GRID: Filter =
+ FILTER_SCROLLABLE.and(Filter.node { n -> Role.getRole(n) == Role.ROLE_GRID })
+
+ /** Filter for table. */
+ private val FILTER_TABLE: Filter =
+ Filter.node { node -> isTableRoot(node) }
+
+ /** Filter for table cell. */
+ @JvmField
+ val FILTER_TABLE_CELL: Filter =
+ Filter.node { node -> isTableCell(node) }
+
+ /** Filter for table cell and check if it is in a table. */
+ @JvmField
+ val FILTER_TABLE_CELL_UNDER_TABLE: Filter =
+ Filter.node { node -> isTableCellUnderTable(node) }
+
+ /** Filter the node matched the voice dictation definition. */
+ @JvmField
+ val FILTER_VOICE_DICTATION: Filter =
+ Filter.node { node -> isVoiceDictationNode(node) }
+
+ /**
+ * Filter that also checks for {@param node}'s non-focusable but visible children. Sometimes, a
+ * node that passes the filter can be embedded in a parent and might be not focusable by itself.
+ * In those cases it is important to focus the parent. Example would be for "Control" granularity,
+ * if a switch is not focusable but is embedded into a focusable parent, its parent should be
+ * focused.
+ */
+ @JvmStatic
+ fun getFilterIncludingChildren(
+ filter: Filter
+ ): Filter {
+ return object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ if (node == null) {
+ return false
+ }
+ // If the node does not pass the filter, check its non focusable, visible children.
+ if (!filter.accept(node)) {
+ return hasMatchingDescendant(node, filter.and(FILTER_NON_FOCUSABLE_VISIBLE_NODE))
+ }
+ return true
+ }
+ }
+ }
+
+ // TODO: Provides an overall experience of focusing on small nodes on both watch and
+ // phone devices.
+ /** Filters out nodes which are small and located on the top and bottom borders. */
+ @JvmStatic
+ fun getFilterExcludingSmallTopAndBottomBorderNode(
+ context: Context
+ ): Filter {
+ // For a watch device, we don't want to put focus on the small border nodes. These nodes
+ // could be located at the middle of AdapterView and they could be distorted to fit in a
+ // round screen when they are near top or bottom borders.
+ val screenPxSize = DisplayUtils.getScreenPixelSizeWithoutWindowDecor(context)
+ return object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat): Boolean {
+ return !AccessibilityNodeInfoUtils.isSmallNodeInHeight(context, node) ||
+ !AccessibilityNodeInfoUtils.isTopOrBottomBorderNode(screenPxSize, node)
+ }
+ }
+ }
+
+ /** Filter to identify nodes which are not focusable but visible. */
+ @JvmField
+ val FILTER_NON_FOCUSABLE_VISIBLE_NODE: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return isVisible(node) && !isAccessibilityFocusable(node)
+ }
+ }
+
+ /** Filter to identify nodes which are not focusable and not visible but has text. */
+ @JvmField
+ val FILTER_NON_FOCUSABLE_NON_VISIBLE_HAS_TEXT_NODE: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return !isVisible(node) &&
+ !isAccessibilityFocusable(node) &&
+ !TextUtils.isEmpty(AccessibilityNodeInfoUtils.getNodeText(node))
+ }
+ }
+
+ /** Filter for controllable elements. */
+ @JvmField
+ val FILTER_CONTROL: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ if (node == null) {
+ return false
+ }
+ @RoleName val role = Role.getRole(node)
+ return (role == Role.ROLE_BUTTON) ||
+ (role == Role.ROLE_IMAGE_BUTTON) ||
+ (role == Role.ROLE_EDIT_TEXT) ||
+ (role == Role.ROLE_CHECK_BOX) ||
+ (role == Role.ROLE_RADIO_BUTTON) ||
+ (role == Role.ROLE_TOGGLE_BUTTON) ||
+ (role == Role.ROLE_SWITCH) ||
+ (role == Role.ROLE_DROP_DOWN_LIST) ||
+ (role == Role.ROLE_SEEK_CONTROL) ||
+ (role == Role.ROLE_FLOATING_ACTION_BUTTON) ||
+ (role == Role.ROLE_VOICE_DICTATION_BUTTON) ||
+ // The clickable view in a collection may not be a control, such as each setting item
+ // in the Settings page.
+ (!nodeIsListOrGridItem(node) && (isClickable(node) || isLongClickable(node)))
+ }
+ }
+
+ /** Filter for Spannables with links. */
+ @JvmField
+ val FILTER_LINK: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return SpannableTraversalUtils.hasTargetClickableSpanInNodeTree(
+ node, BASE_CLICKABLE_SPAN)
+ }
+ }
+
+ @JvmField
+ val FILTER_CLICKABLE: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return AccessibilityNodeInfoUtils.isClickable(node)
+ }
+ }
+
+ @JvmStatic
+ fun getFilterIllegalTitleNodeAncestor(
+ context: Context
+ ): Filter {
+ return object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ if (isClickable(node) || isLongClickable(node)) {
+ return true
+ }
+
+ if (FeatureSupport.isWatch(context)) {
+ // A window title node can be a descendant of AdapterView in a watch device since the
+ // title node may be the first node in a AdapterView.
+ return false
+ } else {
+ @RoleName val role = Role.getRole(node)
+ // A window title node should not be a descendant of AdapterView.
+ return (role == Role.ROLE_LIST) || (role == Role.ROLE_GRID)
+ }
+ }
+ }
+ }
+
+ /**
+ * Filter that defines which types of views should be auto-scrolled. Generally speaking, only
+ * accepts views that are capable of showing partially-visible data.
+ *
+ * Accepts the following classes (and sub-classes thereof):
+ *
+ *
+ * - {@link androidx.recyclerview.widget.RecyclerView} (Should be classified as a List or Grid.)
+ *
- {@link android.widget.AbsListView} (including both ListView and GridView)
+ *
- {@link android.widget.AbsSpinner}
+ *
- {@link android.widget.ScrollView}
+ *
- {@link android.widget.HorizontalScrollView}
+ *
- {@code com.sec.android.touchwiz.widget.TwAbsListView}
+ *
+ *
+ * Specifically excludes {@link android.widget.AdapterViewAnimator} and sub-classes, since they
+ * represent overlapping views. Also excludes {@link androidx.viewpager.widget.ViewPager} since it
+ * exclusively represents off-screen views.
+ */
+ @JvmField
+ val FILTER_AUTO_SCROLL: Filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ if (!isScrollable(node) || !isVisible(node)) {
+ return false
+ }
+ @Role.RoleName val role = Role.getRole(node)
+ // TODO: Check if we should include ROLE_ADAPTER_VIEW as a target Role.
+ return role == Role.ROLE_DROP_DOWN_LIST ||
+ role == Role.ROLE_LIST ||
+ role == Role.ROLE_GRID ||
+ role == Role.ROLE_SCROLL_VIEW ||
+ role == Role.ROLE_HORIZONTAL_SCROLL_VIEW ||
+ AccessibilityNodeInfoUtils.nodeMatchesAnyClassByType(
+ node, CLASS_TOUCHWIZ_TWADAPTERVIEW)
+ }
+ }
+
+ @JvmField
+ val FILTER_COLLECTION: Filter =
+ Filter.node { node ->
+ val role = Role.getRole(node)
+ (role == Role.ROLE_LIST) ||
+ (role == Role.ROLE_GRID) ||
+ (role == Role.ROLE_PAGER) ||
+ (node != null && node.collectionInfo != null)
+ }
+
+ @JvmField
+ val FILTER_COLLECTION_ITEM: Filter =
+ Filter.node { node -> node != null && node.collectionItemInfo != null }
+
+ // This class is not instantiable.
+
+ /**
+ * Gets the text of a node by returning the content description (if available) or by
+ * returning the text.
+ *
+ * @param node The node.
+ * @return The node text.
+ */
+ @JvmStatic
+ fun getNodeText(node: AccessibilityNodeInfoCompat?): CharSequence? {
+ if (node == null) {
+ return null
+ }
+
+ // Prefer content description over text.
+ // TODO: Why are we checking the trimmed length?
+ val contentDescription = node.contentDescription
+ if (!TextUtils.isEmpty(contentDescription) &&
+ (TextUtils.getTrimmedLength(contentDescription) > 0)) {
+ return contentDescription
+ }
+
+ val text = AccessibilityNodeInfoUtils.getText(node)
+ if (!TextUtils.isEmpty(text) && (TextUtils.getTrimmedLength(text) > 0)) {
+ return text
+ }
+
+ return null
+ }
+
+ /**
+ * Gets the state description of a node.
+ *
+ * @param node The node.
+ * @return The node state description.
+ */
+ @JvmStatic
+ fun getState(node: AccessibilityNodeInfoCompat?): CharSequence? {
+ if (node == null) {
+ return null
+ }
+
+ val state = node.stateDescription
+ if (!TextUtils.isEmpty(state) && (TextUtils.getTrimmedLength(state) > 0)) {
+ return state
+ }
+
+ return null
+ }
+
+ /**
+ * Gets the Selected text of a node by returning the selected text.
+ *
+ * @param node The node.
+ * @return The selected node text.
+ */
+ @JvmStatic
+ fun getSelectedNodeText(node: AccessibilityNodeInfoCompat?): CharSequence? {
+ if (node == null) {
+ return null
+ }
+
+ val selectedText =
+ subsequenceSafe(
+ AccessibilityNodeInfoUtils.getText(node),
+ node.textSelectionStart,
+ node.textSelectionEnd)
+ if (!TextUtils.isEmpty(selectedText) && (TextUtils.getTrimmedLength(selectedText) > 0)) {
+ return selectedText
+ }
+
+ return null
+ }
+
+ /** Returns a sub-string or empty-string, without crashing on invalid subsequence range. */
+ @JvmStatic
+ fun subsequenceSafe(text: CharSequence?, startIndex: Int, endIndex: Int): CharSequence {
+ if (text == null) {
+ return ""
+ }
+ var startIndexVar = startIndex
+ var endIndexVar = endIndex
+ // Swap start and end.
+ if (endIndexVar < startIndexVar) {
+ val newStartIndex = endIndexVar
+ endIndexVar = startIndexVar
+ startIndexVar = newStartIndex
+ }
+ // Enforce string bounds.
+ if (startIndexVar < 0) {
+ startIndexVar = 0
+ } else if (startIndexVar > text.length) {
+ startIndexVar = text.length
+ }
+ if (endIndexVar < 0) {
+ endIndexVar = 0
+ } else if (endIndexVar > text.length) {
+ endIndexVar = text.length
+ }
+
+ return text.subSequence(startIndexVar, endIndexVar)
+ }
+
+ /**
+ * Gets the text selection indexes safe by adjusting the checking the selection bounds.
+ *
+ * @param node The node
+ * @return the selection indexes
+ */
+ @JvmStatic
+ fun getSelectionIndexesSafe(node: AccessibilityNodeInfoCompat): Pair {
+ var selectionStart = node.textSelectionStart
+ var selectionEnd = node.textSelectionEnd
+ if (selectionStart < 0) {
+ selectionStart = 0
+ }
+ if (selectionEnd < 0) {
+ selectionEnd = selectionStart
+ }
+ if (selectionEnd < selectionStart) {
+ // Swap start and end to make sure they are in order.
+ val newStart = selectionEnd
+ selectionEnd = selectionStart
+ selectionStart = newStart
+ }
+ return Pair.create(selectionStart, selectionEnd)
+ }
+
+ /**
+ * Gets the textual representation of the view ID that can be used when no custom label is
+ * available. For better readability/listenability, the "_" characters are replaced with spaces.
+ *
+ * @param node The node
+ * @return Readable text of the view Id
+ */
+ @JvmStatic
+ fun getViewIdText(node: AccessibilityNodeInfoCompat?): String? {
+ if (node == null) {
+ return null
+ }
+
+ val resourceName = node.viewIdResourceName ?: return null
+
+ val parsedResourceName = RESOURCE_NAME_SPLIT_PATTERN.split(resourceName, 2)
+ if (parsedResourceName.size != 2 ||
+ TextUtils.isEmpty(parsedResourceName[0]) ||
+ TextUtils.isEmpty(parsedResourceName[1])) {
+ return null
+ }
+
+ return parsedResourceName[1].replace('_', ' ') // readable View ID text
+ }
+
+ @JvmStatic
+ fun isPage(node: AccessibilityNodeInfoCompat?): Boolean {
+ val parent = if (node == null) null else node.parent
+ return (parent != null) && (Role.getRole(parent) == Role.ROLE_PAGER)
+ }
+
+ @JvmStatic
+ fun getSelectedPageTitle(viewPager: AccessibilityNodeInfoCompat?): CharSequence? {
+ if ((viewPager == null) || (Role.getRole(viewPager) != Role.ROLE_PAGER)) {
+ return null
+ }
+
+ val numChildren = viewPager.childCount // Not the number of pages!
+ var title: CharSequence? = null
+ for (i in 0 until numChildren) {
+ val child = viewPager.getChild(i)
+ if (child != null && child.isVisibleToUser) {
+ if (title == null) {
+ // Try to roughly match RulePagerPage, which uses getNodeText
+ // (but completely matching all the time is not critical).
+ title = getNodeText(child)
+ } else {
+ // Multiple visible children, abort.
+ return null
+ }
+ }
+ }
+
+ return title
+ }
+
+ @JvmStatic
+ fun getCustomActions(node: AccessibilityNodeInfoCompat): List {
+ val customActions = ArrayList()
+ for (action in node.actionList) {
+ if (isCustomAction(action)) {
+ // We don't use custom actions that doesn't have a label
+ if (!TextUtils.isEmpty(action.label)) {
+ customActions.add(action)
+ }
+ }
+ }
+
+ return customActions
+ }
+
+ @JvmStatic
+ fun isCustomAction(action: AccessibilityActionCompat): Boolean {
+ return action.id > SYSTEM_ACTION_MAX
+ }
+
+ /** Returns the root node of the tree containing {@code node}. */
+ @JvmStatic
+ fun getRoot(node: AccessibilityNodeInfoCompat?): AccessibilityNodeInfoCompat? {
+ if (node == null) {
+ return null
+ }
+
+ val window = getWindow(node)
+ if (window != null) {
+ return AccessibilityWindowInfoUtils.getRoot(window)
+ }
+
+ val visitedNodes = HashSet()
+ var current: AccessibilityNodeInfoCompat? = null
+ var parent: AccessibilityNodeInfoCompat? = node
+
+ do {
+ if (current != null) {
+ if (visitedNodes.contains(current)) {
+ return null
+ }
+ visitedNodes.add(current)
+ }
+
+ current = parent
+ parent = current!!.parent
+ } while (parent != null)
+
+ return current
+ }
+
+ /**
+ * Returns the node of the tree at {@code targetDepth} from the root of the tree containing {@code
+ * nodeCompat} with the root node considered as depth 0. This returns the last node available if
+ * the target depth is greater than the number of ancestors.
+ */
+ @JvmStatic
+ fun getNthAncestorFromRoot(
+ nodeCompat: AccessibilityNodeInfoCompat?, targetDepth: Int
+ ): AccessibilityNodeInfoCompat? {
+ if (nodeCompat == null || targetDepth <= 0) {
+ return null
+ }
+
+ var targetDepthVar = targetDepth
+ val visitedNodes = ArrayList()
+ var current: AccessibilityNodeInfoCompat? = nodeCompat
+
+ do {
+ if (visitedNodes.contains(current)) {
+ break
+ }
+
+ visitedNodes.add(current!!)
+ current = current.parent
+ } while (current != null)
+
+ if (targetDepthVar >= visitedNodes.size) {
+ targetDepthVar = visitedNodes.size - 1
+ }
+
+ val nodeIndex = visitedNodes.size - 1 - targetDepthVar
+ return visitedNodes[nodeIndex]
+ }
+
+ /** Returns the type of the window containing {@code nodeCompat}. */
+ @JvmStatic
+ fun getWindowType(nodeCompat: AccessibilityNodeInfoCompat?): Int {
+ if (nodeCompat == null) {
+ return WINDOW_TYPE_NONE
+ }
+
+ val windowInfoCompat = getWindow(nodeCompat) ?: return WINDOW_TYPE_NONE
+
+ if (isPictureInPicture(nodeCompat)) {
+ return WINDOW_TYPE_PICTURE_IN_PICTURE
+ }
+
+ return windowInfoCompat.type
+ }
+
+ /** Wrapper for AccessibilityNodeInfoCompat.getWindow() that handles SecurityException. */
+ @JvmStatic
+ fun getWindow(node: AccessibilityNodeInfoCompat?): AccessibilityWindowInfoCompat? {
+ // This implementation is redundant with getWindow(AccessibilityNodeInfo) because there are no
+ // un/wrap() functions for AccessibilityWindowInfoCompat.
+
+ if (node == null) {
+ return null
+ }
+
+ try {
+ return node.window
+ } catch (e: SecurityException) {
+ LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfoCompat.getWindow()")
+ return null
+ }
+ }
+
+ @JvmStatic
+ fun getWindow(node: AccessibilityNodeInfo?): AccessibilityWindowInfo? {
+ if (node == null) {
+ return null
+ }
+
+ try {
+ return node.window
+ } catch (e: SecurityException) {
+ LogUtils.e(TAG, "SecurityException in AccessibilityWindowInfo.getWindow()")
+ return null
+ }
+ }
+
+ /**
+ * Returns whether a node can receive focus from focus traversal or touch exploration. One of the
+ * following must be true:
+ *
+ *
+ * - The node is actionable (see {@link #isFocusableOrClickable(AccessibilityNodeInfoCompat)})
+ *
- The node is a top-level list item (see {@link
+ * #isTopLevelScrollItem(AccessibilityNodeInfoCompat)} and is a speaking node
+ *
+ *
+ * @param node The node to check.
+ * @return {@code true} of the node is accessibility focusable.
+ */
+ @JvmStatic
+ fun isAccessibilityFocusable(node: AccessibilityNodeInfoCompat?): Boolean {
+ return isFocusableOrClickable(node) ||
+ (isTopLevelScrollItem(node) && isSpeakingNode(node!!, null, HashSet()))
+ }
+
+ /**
+ * Returns whether a node should receive accessibility focus from navigation. This method should
+ * never be called recursively, since it traverses up the parent hierarchy on every call.
+ *
+ * @see #findFocusFromHover(AccessibilityNodeInfoCompat) for touch exploration
+ * @see
+ * com.google.android.accessibility.talkback.focusmanagement.NavigationTarget#createNodeFilter(int,
+ * Map) for linear navigation
+ */
+ @JvmStatic
+ fun shouldFocusNode(node: AccessibilityNodeInfoCompat?): Boolean {
+ return shouldFocusNode(node, null, true)
+ }
+
+ @JvmStatic
+ fun shouldFocusNode(
+ node: AccessibilityNodeInfoCompat?,
+ speakingNodesCache: MutableMap?
+ ): Boolean {
+ return shouldFocusNode(node, speakingNodesCache, true)
+ }
+
+ @JvmStatic
+ fun shouldFocusNode(
+ node: AccessibilityNodeInfoCompat?,
+ speakingNodesCache: MutableMap?,
+ checkChildren: Boolean
+ ): Boolean {
+ if (node == null) {
+ LogUtils.v(TAG, "Don't focus, node=null")
+ return false
+ }
+ // Inside views that support web navigation, we delegate focus to the view itself and
+ // assume that it navigates to and focuses the correct elements.
+ if (WebInterfaceUtils.supportsWebActions(node)) {
+ // In history, we loosen the "visibility" check for web element: A web node can be focused
+ // even if it's not visibleToUser(). However we should hold the baseline that if the WebView
+ // container is not visible, we should not focus on its descendants.
+ val webViewContainer = WebInterfaceUtils.ascendToWebViewContainer(node)
+ return webViewContainer != null && webViewContainer.isVisibleToUser
+ }
+
+ if (!isVisible(node)) {
+ logShouldFocusNode(
+ checkChildren, FOCUS_FAIL_NOT_VISIBLE, "Don't focus, is not visible: ", node)
+ return false
+ }
+
+ if (isPictureInPicture(node)) {
+ // For picture-in-picture, allow focusing the root node, and any app controls inside the
+ // pic-in-pic window.
+ return true
+ } else {
+ // Reject all non-leaf nodes that are neither actionable nor focusable, and have the same
+ // bounds as the window.
+ if (areBoundsIdenticalToWindow(node) &&
+ node.childCount > 0 &&
+ !isFocusableOrClickable(node)) {
+ logShouldFocusNode(
+ checkChildren,
+ FOCUS_FAIL_SAME_WINDOW_BOUNDS_CHILDREN,
+ "Don't focus, bounds are same as window root node bounds, node has children and" +
+ " is neither actionable nor focusable: ",
+ node)
+ return false
+ }
+ }
+
+ val visitedNodes = HashSet()
+ // This checks if a node is clickable, focusable, screen reader focusable, or a direct
+ // spekaing child of a scrollable container.
+ val accessibilityFocusable =
+ isFocusableOrClickable(node) ||
+ (isTopLevelScrollItem(node) && isSpeakingNode(node, null, visitedNodes))
+
+ if (!checkChildren) {
+ // End of the line. Don't check children and don't allow any recursion.
+ // checkChildren is only false in the shouldFocusNode call below. This is to avoid
+ // repetitive checks down the tree when looking up at the ancestors.
+ LogUtils.d(
+ TAG, "checkChildren=false and isAccessibilityFocusable=%s", accessibilityFocusable)
+ return accessibilityFocusable
+ }
+
+ // A node that is deemed accessibility focusable shouldn't actually get focus if it has
+ // nothing to speak. For example, a view may be focusable, but if it has no text and all of
+ // its children are clickable, focus should go on each child individually and not on this
+ // view.
+ // Note: This is redundant for nodes that pass isSpeakingNode above
+ // Note: A special case exists for unlabeled buttons which otherwise wouldn't get focus.
+ if (accessibilityFocusable) {
+ visitedNodes.clear()
+ // For TalkBack labeling feature, but this may still result in focusing non-speaking nodes.
+ // We should try to narrow down the check to close to TalkBackLabelManager#needsLabel.
+ if (node.childCount == 0) {
+ logShouldFocusNode(
+ checkChildren, NONE, "Focus, is focusable and cannot keep search children: ", node)
+ return true
+ } else if (isSpeakingNode(node, speakingNodesCache, visitedNodes)) {
+ logShouldFocusNode(
+ checkChildren, NONE, "Focus, is focusable and has something to speak: ", node)
+ return true
+ } else {
+ logShouldFocusNode(
+ checkChildren,
+ FOCUS_FAIL_NOT_SPEAKABLE,
+ "Don't focus, is focusable but has nothing to speak: ",
+ node)
+ return false
+ }
+ }
+
+ // At this point, the node is an unfocusable target.
+ // If it has no focusable ancestors, but it still has text, then it should receive focus and be
+ // read aloud.
+ val filter =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return shouldFocusNode(node, speakingNodesCache, false)
+ }
+ }
+
+ if (!hasMatchingAncestor(node, filter) && (hasText(node) || hasStateDescription(node))) {
+ logShouldFocusNode(checkChildren, NONE, "Focus, has text and no focusable ancestors: ", node)
+ return true
+ }
+
+ logShouldFocusNode(
+ checkChildren,
+ FOCUS_FAIL_FAIL_ALL_FOCUS_TESTS,
+ "Don't focus, failed all focusability tests: ",
+ node)
+ return false
+ }
+
+ private fun logShouldFocusNode(
+ checkChildren: Boolean,
+ @DiagnosticType diagnosticType: Int?,
+ message: String,
+ node: AccessibilityNodeInfoCompat
+ ) {
+ // When shouldFocusNode calls itself, the logs get inundated by unnecessary info about the
+ // ancestors. So only log when checkChildren is true.
+ if (checkChildren) {
+ if (diagnosticType != NONE) {
+ DiagnosticOverlayUtils.appendLog(diagnosticType, node)
+ }
+ // Show debug logs for #shouldFocusNode. Verbose logs will show for #isSpeakingNode
+ LogUtils.v(TAG, "%s %s", message, node)
+ }
+ }
+
+ @JvmStatic
+ fun isPictureInPicture(node: AccessibilityNodeInfoCompat): Boolean {
+ return isPictureInPicture(node.unwrap())
+ }
+
+ @JvmStatic
+ fun isPictureInPicture(node: AccessibilityNodeInfo?): Boolean {
+ return node != null && AccessibilityWindowInfoUtils.isPictureInPicture(getWindow(node))
+ }
+
+ /**
+ * Returns the node that should receive focus from hover by starting from the touched node and
+ * calling {@link #shouldFocusNode} at each level of the view hierarchy and exclude WebView
+ * container node.
+ */
+ @JvmStatic
+ fun findFocusFromHover(
+ touched: AccessibilityNodeInfoCompat?
+ ): AccessibilityNodeInfoCompat? {
+ return AccessibilityNodeInfoUtils.getSelfOrMatchingAncestor(
+ touched, FILTER_SHOULD_FOCUS_EXCEPT_WEB_VIEW)
+ }
+
+ /**
+ * Returns whether a node can be spoken.
+ *
+ * A node should be spoken if it has text, is checkable, or has children that should be spoken
+ * but can't be focused themselves. This method can call itself recursively through {@link
+ * #hasNonActionableSpeakingChildren}.
+ *
+ *
Note: This is called in the context of looking for a a11y focusable node through {@link
+ * #shouldFocusNode} and {@link #isAccessibilityFocusable}
+ *
+ * @param node the node to check
+ * @param speakingNodesCache the cache that holds the speaking results for visited nodes
+ * @param visitedNodes the set of nodes that have already been visited
+ * @return {@code true} if the node can be spoken
+ */
+ private fun isSpeakingNode(
+ node: AccessibilityNodeInfoCompat,
+ speakingNodesCache: MutableMap?,
+ visitedNodes: MutableSet
+ ): Boolean {
+ if (speakingNodesCache != null && speakingNodesCache.containsKey(node)) {
+ return speakingNodesCache[node]!!
+ }
+
+ var result = false
+ if (hasText(node)) {
+ LogUtils.v(TAG, "Speaking, has text")
+ result = true
+ } else if (hasStateDescription(node)) {
+ LogUtils.v(TAG, "Speaking, has state description")
+ result = true
+ } else if (node.isCheckable) { // Special case for check boxes.
+ LogUtils.v(TAG, "Speaking, is checkable")
+ result = true
+ } else if (hasNonActionableSpeakingChildren(node, speakingNodesCache, visitedNodes)) {
+ // Special case for containers with non-focusable content. In this case, the container should
+ // speak its non-focusable yet speakable content.
+ LogUtils.v(TAG, "Speaking, has non-actionable speaking children")
+ result = true
+ }
+
+ if (speakingNodesCache != null) {
+ speakingNodesCache[node] = result
+ }
+
+ return result
+ }
+
+ /**
+ * Returns whether a node has children that are not actionable/focusable but should be spoken.
+ *
+ * This is done by ignoring any children nodes that are actionable/focusable, and checking the
+ * remaining for speaking ability. Also considers offscreen/invisible children which are
+ * non-actionable but which have speakable text.
+ *
+ * @param node the node to check
+ * @param speakingNodesCache the cache that holds the speaking results for visited nodes
+ * @param visitedNodes the set of nodes that have already been visited.
+ * @return {@code true} if the node has children that are speaking
+ */
+ private fun hasNonActionableSpeakingChildren(
+ node: AccessibilityNodeInfoCompat,
+ speakingNodesCache: MutableMap?,
+ visitedNodes: MutableSet
+ ): Boolean {
+ val childCount = node.childCount
+
+ for (i in 0 until childCount) {
+ val child = node.getChild(i)
+
+ if (child == null) {
+ LogUtils.v(TAG, "Child %d is null, skipping it", i)
+ continue
+ }
+
+ if (!visitedNodes.add(child)) {
+ return false
+ }
+
+ // Ignore invisible nodes.
+ if (!isVisible(child)) {
+ LogUtils.v(TAG, "Child %d, %s is invisible, skipping it", i, printId(node))
+ continue
+ }
+
+ // Ignore focusable nodes
+ if (isFocusableOrClickable(child)) {
+ LogUtils.v(TAG, "Child %d, %s is focusable or clickable, skipping it", i, printId(node))
+ continue
+ }
+
+ // Ignore top level scroll items that 1) are speaking and 2) have non-clickable parents. This
+ // means that a scrollable container that is clickable should get focus before its children.
+ if ((isTopLevelScrollItem(child) && isSpeakingNode(child, speakingNodesCache, visitedNodes)) &&
+ !(isClickable(node) || isLongClickable(node))) {
+
+ LogUtils.v(TAG, "Child %d, %s is a top level scroll item, skipping it", i, printId(node))
+ continue
+ }
+
+ // Recursively check non-focusable child nodes.
+ if (isSpeakingNode(child, speakingNodesCache, visitedNodes)) {
+ LogUtils.v(TAG, "Does have actionable speaking children (child %d, %s)", i, printId(node))
+ return true
+ }
+ }
+
+ LogUtils.v(TAG, "Does not have non-actionable speaking children. Examining invisible children")
+ return hasInvisibleNonActionableSpeakingChildren(node, childCount)
+ }
+
+ private fun hasInvisibleNonActionableSpeakingChildren(
+ node: AccessibilityNodeInfoCompat, childCount: Int
+ ): Boolean {
+ // We don't want the presence of invisible children to lead to focus being set on a scrollable
+ // parent that is capable of showing partially-visible data.
+ if (FILTER_AUTO_SCROLL.accept(node)) {
+ return false
+ }
+
+ // We look at invisible children and return true if an invisible child is non-actionable and
+ // has associated text. Without this check, a parent would be considered unfocusable, and this
+ // would cause ACTION_SHOW_ON_SCREEN to fail when the non-actionable/speakable child nodes of
+ // a container are offscreen.
+ for (i in 0 until childCount) {
+ val child = node.getChild(i)
+
+ if (child == null) {
+ LogUtils.v(TAG, "Child %d is null, skipping it", i)
+ continue
+ }
+
+ if (!child.isVisibleToUser &&
+ hasText(child) &&
+ !(child.isScreenReaderFocusable || isActionableForAccessibility(child))) {
+ LogUtils.v(
+ TAG, "Non-actionable invisible node with text found (child %d, %s)", i, printId(node))
+ return true
+ }
+ }
+ return false
+ }
+
+ @JvmStatic
+ fun countVisibleChildren(node: AccessibilityNodeInfoCompat?): Int {
+ if (node == null) {
+ return 0
+ }
+ val childCount = node.childCount
+ var childVisibleCount = 0
+ for (i in 0 until childCount) {
+ val child = node.getChild(i)
+ if (child != null && child.isVisibleToUser) {
+ ++childVisibleCount
+ }
+ }
+ return childVisibleCount
+ }
+
+ /**
+ * Returns whether a node is actionable. That is, the node supports one of the following actions:
+ *
+ *
+ * - {@link AccessibilityNodeInfoCompat#isClickable()}
+ *
- {@link AccessibilityNodeInfoCompat#isFocusable()}
+ *
- {@link AccessibilityNodeInfoCompat#isLongClickable()}
+ *
+ *
+ * This parities the system method View#isActionableForAccessibility(), which was added in
+ * JellyBean.
+ *
+ * @param node The node to examine.
+ * @return {@code true} if node is actionable.
+ */
+ @JvmStatic
+ fun isActionableForAccessibility(node: AccessibilityNodeInfoCompat?): Boolean {
+ if (node == null) {
+ return false
+ }
+
+ // Nodes that are clickable are always actionable.
+ if (isClickable(node) || isLongClickable(node)) {
+ return true
+ }
+
+ if (node.isFocusable) {
+ return true
+ }
+
+ if (WebInterfaceUtils.hasNativeWebContent(node)) {
+ return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS)
+ }
+
+ return supportsAnyAction(
+ node,
+ AccessibilityNodeInfoCompat.ACTION_FOCUS,
+ AccessibilityNodeInfoCompat.ACTION_NEXT_HTML_ELEMENT,
+ AccessibilityNodeInfoCompat.ACTION_PREVIOUS_HTML_ELEMENT)
+ }
+
+ @JvmStatic
+ fun isSelfOrAncestorFocused(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null &&
+ (node.isAccessibilityFocused ||
+ hasMatchingAncestor(
+ node,
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return (node != null) && node.isAccessibilityFocused
+ }
+ }))
+ }
+
+ /** Returns whether {@code node} is editable or has an ancestor that is editable. */
+ @JvmStatic
+ fun isSelfOrAncestorEditable(node: AccessibilityNodeInfoCompat?): Boolean {
+ return getSelfOrMatchingAncestor(node, Filter.node { n -> n.isEditable }) != null
+ }
+
+ @JvmStatic
+ fun isSelfOrAncestorRoleEditText(node: AccessibilityNodeInfoCompat?): Boolean {
+ return isSelfOrAncestorWithRole(node, Role.ROLE_EDIT_TEXT)
+ }
+
+ @JvmStatic
+ fun isSelfOrAncestorRoleWebView(node: AccessibilityNodeInfoCompat?): Boolean {
+ return isSelfOrAncestorWithRole(node, Role.ROLE_WEB_VIEW)
+ }
+
+ private fun isSelfOrAncestorWithRole(node: AccessibilityNodeInfoCompat?, role: Int): Boolean {
+ return getSelfOrMatchingAncestor(node, Filter.node { n -> Role.getRole(n) == role }) != null
+ }
+
+ /** Returns whether {@code node} or its ancestor has the given {@code chromeRole}. */
+ @JvmStatic
+ fun isSelfOrAncestorWithChromeRole(
+ node: AccessibilityNodeInfoCompat?, chromeRole: String
+ ): Boolean {
+ return getSelfOrMatchingAncestor(
+ node, Filter.node { n -> TextUtils.equals(getChromeRole(n), chromeRole) }) != null
+ }
+
+ /** Returns whether {@code node} has the chrome role "link". */
+ @JvmStatic
+ fun isChromeRoleLink(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null && TextUtils.equals(getChromeRole(node), CHROME_ROLE_LINK)
+ }
+
+ private fun getChromeRole(node: AccessibilityNodeInfoCompat?): CharSequence? {
+ if (node == null) {
+ return ""
+ }
+ val info = node.unwrap() ?: return ""
+ return info.extras.getCharSequence(EXTRAS_KEY_CHROME_ROLE)
+ }
+
+ /**
+ * Returns whether {@code node} is interactable with arrow keys. That is, the node supports at
+ * least one of the following:
+ *
+ *
+ * - {@link Role.ROLE_SEEK_CONTROL}
+ *
+ *
+ * @return {@code true} if node is self interactable with arrow keys.
+ */
+ @JvmStatic
+ fun isInteractableWithArrowKeys(node: AccessibilityNodeInfoCompat?): Boolean {
+ return Role.getRole(node) == Role.ROLE_SEEK_CONTROL
+ }
+
+ /**
+ * Returns whether a node is clickable. That is, the node supports at least one of the following:
+ *
+ *
+ * - {@link AccessibilityNodeInfoCompat#isClickable()}
+ *
- {@link AccessibilityNodeInfoCompat#ACTION_CLICK}
+ *
+ *
+ * @param node The node to examine.
+ * @return {@code true} if node is clickable.
+ */
+ @JvmStatic
+ fun isClickable(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null &&
+ (node.isClickable ||
+ supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_CLICK))
+ }
+
+ /**
+ * Returns whether a node is long clickable. That is, the node supports at least one of the
+ * following:
+ *
+ *
+ * - {@link AccessibilityNodeInfoCompat#isLongClickable()}
+ *
- {@link AccessibilityNodeInfoCompat#ACTION_LONG_CLICK}
+ *
+ *
+ * @param node The node to examine.
+ * @return {@code true} if node is long clickable.
+ */
+ @JvmStatic
+ fun isLongClickable(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null &&
+ (node.isLongClickable ||
+ supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_LONG_CLICK))
+ }
+
+ /**
+ * Returns whether the node is focusable. That is, the node supports at least one of the
+ * following:
+ *
+ *
+ * - {@link AccessibilityNodeInfoCompat#isFocusable()}
+ *
- {@link AccessibilityNodeInfoCompat#ACTION_FOCUS}
+ *
+ */
+ @JvmStatic
+ fun isFocusable(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null &&
+ (node.isFocusable ||
+ supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_FOCUS))
+ }
+
+ /**
+ * Returns whether a node is expandable. That is, the node supports the following action:
+ *
+ *
+ * - {@link AccessibilityNodeInfoCompat#ACTION_EXPAND}
+ *
+ *
+ * @param node The node to examine.
+ * @return {@code true} if node is expandable.
+ */
+ @JvmStatic
+ fun isExpandable(node: AccessibilityNodeInfoCompat?): Boolean {
+ return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_EXPAND)
+ }
+
+ /**
+ * Returns whether a node is collapsible. That is, the node supports the following action:
+ *
+ *
+ * - {@link AccessibilityNodeInfoCompat#ACTION_COLLAPSE}
+ *
+ *
+ * @param node The node to examine.
+ * @return {@code true} if node is collapsible.
+ */
+ @JvmStatic
+ fun isCollapsible(node: AccessibilityNodeInfoCompat?): Boolean {
+ return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_COLLAPSE)
+ }
+
+ /**
+ * Returns whether a node can be dismissed by the user. the node supports the following action:
+ *
+ *
+ * - {@link AccessibilityNodeInfoCompat#ACTION_DISMISS}
+ *
+ *
+ * @param node The node to examine.
+ * @return {@code true} if node is dismissible.
+ */
+ @JvmStatic
+ fun isDismissible(node: AccessibilityNodeInfoCompat?): Boolean {
+ return supportsAnyAction(node, AccessibilityNodeInfoCompat.ACTION_DISMISS)
+ }
+
+ /** Returns {@code true} if the node is on keyboard. */
+ @JvmStatic
+ fun isKeyboard(source: AccessibilityNodeInfo?): Boolean {
+ return isKeyboard(AccessibilityNodeInfoUtils.toCompat(source))
+ }
+
+ /** Returns {@code true} if the node is on keyboard. */
+ @JvmStatic
+ fun isKeyboard(source: AccessibilityNodeInfoCompat?): Boolean {
+ if (source == null) {
+ return false
+ }
+ val window = getWindow(source) ?: return false
+ return AccessibilityWindowInfoUtils.isImeWindow(window)
+ }
+
+ /**
+ * Check whether a given node has a matching ancestor given a filter.
+ *
+ * @param node The node to examine.
+ * @param filter The filter to match the nodes against.
+ * @return {@code true} if one of the node's ancestors is matching the filter.
+ */
+ @JvmStatic
+ fun hasMatchingAncestor(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): Boolean {
+ return (node != null) && (getMatchingAncestor(node, filter) != null)
+ }
+
+ // TODO: Discuss with framework owner to make unread notification context available
+ // to the app side.
+ /**
+ * Checks whether the node is the unread notification dot on the wearable sysUI.
+ *
+ * @param node the node to check
+ * @return {@code true} if the node is the unread notification dot on the wearable sysUI.
+ */
+ @JvmStatic
+ fun isWearUnreadNotificationDot(node: AccessibilityNodeInfoCompat?): Boolean {
+ return (node != null) &&
+ TextUtils.equals(node.viewIdResourceName, VIEW_ID_WEAR_UNREAD_NOTIFICATION_DOT)
+ }
+
+ /** Returns whether the node is the Pin edit field at unlock screen. */
+ @JvmStatic
+ fun isPinEntry(node: AccessibilityNodeInfo?): Boolean {
+ return isPinEntry(AccessibilityNodeInfoUtils.toCompat(node))
+ }
+
+ @JvmStatic
+ fun isPinEntry(node: AccessibilityNodeInfoCompat?): Boolean {
+ return (node != null) &&
+ TextUtils.equals(node.viewIdResourceName, VIEW_ID_RESOURCE_NAME_PIN_ENTRY)
+ }
+
+ /**
+ * 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.
+ */
+ @JvmStatic
+ fun isOrHasMatchingAncestor(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): Boolean {
+ return (node != null) && (getSelfOrMatchingAncestor(node, filter) != null)
+ }
+
+ /** Check whether a given node has any descendant matching a given filter. */
+ @JvmStatic
+ fun hasMatchingDescendant(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): Boolean {
+ return (node != null) && (getMatchingDescendant(node, filter) != null)
+ }
+
+ /** Checks whether a given node or any of its descendants matches the given filter. */
+ @JvmStatic
+ fun isOrHasMatchingDescendant(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): Boolean {
+ return (node != null) && (getSelfOrMatchingDescendant(node, filter) != null)
+ }
+
+ /** Returns depth of node in node-tree, where root has depth=0. */
+ @JvmStatic
+ fun findDepth(node: AccessibilityNodeInfoCompat?): Int {
+ if (node == null) {
+ return -1
+ }
+ val counter = NodeCounter()
+ processSelfAndAncestors(node, counter)
+ return counter.count - 1
+ }
+
+ private class NodeCounter : Filter() {
+ var count = 0
+
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ ++count
+ return false
+ }
+ }
+
+ /** Applies filter to ancestor nodes. */
+ @JvmStatic
+ fun processSelfAndAncestors(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ) {
+ if (node != null) {
+ isOrHasMatchingAncestor(node, filter)
+ }
+ }
+
+ /**
+ * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor.
+ * Returns {@code null} if no nodes match.
+ */
+ @JvmStatic
+ fun getSelfOrMatchingAncestor(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): AccessibilityNodeInfoCompat? {
+ if (node == null) {
+ return null
+ }
+ if (filter.accept(node)) {
+ return node
+ }
+
+ return getMatchingAncestor(node, filter)
+ }
+
+ /**
+ * Returns the {@code node} if it matches the {@code filter}, or the first matching ancestor,
+ * ending the ancestor search once it reaches {@code end}. The search is inclusive of {@code node}
+ * but exclusive of {@code end}. If {@code node} equals {@code end}, then {@code node} is an
+ * eligible match. Returns {@code null} if no nodes match.
+ */
+ @JvmStatic
+ fun getSelfOrMatchingAncestor(
+ node: AccessibilityNodeInfoCompat?,
+ end: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): AccessibilityNodeInfoCompat? {
+ if (node == null) {
+ return null
+ }
+ if (filter.accept(node)) {
+ return node
+ }
+ return getMatchingAncestor(node, end, filter)
+ }
+
+ /**
+ * Returns the {@code node} if it matches the {@code filter}, or the first matching descendant.
+ * Returns {@code null} if no nodes match.
+ */
+ @JvmStatic
+ fun getSelfOrMatchingDescendant(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): AccessibilityNodeInfoCompat? {
+ if (node == null) {
+ return null
+ }
+ if (filter.accept(node)) {
+ return node
+ }
+ return getMatchingDescendant(node, filter)
+ }
+
+ /** Processes subtree of root by {@code filter}. */
+ @JvmStatic
+ fun processSubtree(
+ root: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ) {
+
+ AccessibilityNodeInfoUtils.getSelfOrMatchingDescendant(
+ root,
+ Filter.node { node ->
+ filter.accept(node)
+ false // Force search to traverse whole subtree.
+ })
+ }
+
+ /**
+ * Determines whether the two nodes are in the same branch; that is, they are equal or one is the
+ * ancestor of the other.
+ */
+ @JvmStatic
+ fun areInSameBranch(
+ node1: AccessibilityNodeInfoCompat?,
+ node2: AccessibilityNodeInfoCompat?
+ ): Boolean {
+ if (node1 != null && node2 != null) {
+ // Same node?
+ if (node1 == node2) {
+ return true
+ }
+
+ // Is node1 an ancestor of node2?
+ val matchNode1 =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null && node == node1
+ }
+ }
+ if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node2, matchNode1)) {
+ return true
+ }
+
+ // Is node2 an ancestor of node1?
+ val matchNode2 =
+ object : Filter() {
+ override fun accept(node: AccessibilityNodeInfoCompat?): Boolean {
+ return node != null && node == node2
+ }
+ }
+ if (AccessibilityNodeInfoUtils.hasMatchingAncestor(node1, matchNode2)) {
+ return true
+ }
+ }
+
+ return false
+ }
+
+ /**
+ * Returns the first ancestor of {@code node} that matches the {@code filter}. Returns {@code
+ * null} if no nodes match.
+ */
+ @JvmStatic
+ fun getMatchingAncestor(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): AccessibilityNodeInfoCompat? {
+ return getMatchingAncestor(node, null, filter)
+ }
+
+ /**
+ * 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.
+ */
+ private fun getMatchingAncestor(
+ node: AccessibilityNodeInfoCompat?,
+ end: AccessibilityNodeInfoCompat?,
+ filter: Filter
+ ): AccessibilityNodeInfoCompat? {
+ if (node == null) {
+ return null
+ }
+
+ val ancestors = HashSet()
+
+ ancestors.add(node)
+ var current: AccessibilityNodeInfoCompat? = node.parent
+
+ while (current != null) {
+ if (!ancestors.add(current)) {
+ // Already seen this node, so abort!
+ return null
+ }
+
+ if (end != null && current == end) {
+ // Reached the end node, so abort!
+ return null
+ }
+
+ if (filter.accept(current)) {
+ return current
+ }
+
+ current = current.parent
+ }
+
+ return null
+ }
+
+ /**
+ * Returns the number of ancestors matching the given filter. Does not include the current node in
+ * the count, even if it matches the filter. If there is a cycle in the ancestor hierarchy, then
+ * this method will return 0.
+ */
+ @JvmStatic
+ fun countMatchingAncestors(
+ node: AccessibilityNodeInfoCompat?, filter: Filter
+ ): Int {
+ if (node == null) {
+ return 0
+ }
+
+ val ancestors = HashSet()
+ var matchingAncestors = 0
+
+ ancestors.add(node)
+ var current: AccessibilityNodeInfoCompat? = node.parent
+
+ while (current != null) {
+ if (!ancestors.add(current)) {
+ // Already seen this node, so abort!
+ return 0
+ }
+
+ if (filter.accept(current)) {
+ matchingAncestors++
+ }
+
+ current = current.parent
+ }
+
+ return matchingAncestors
+ }
+
+ private fun getMatchingDescendant(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter,
+ endFilter: Filter?,
+ visitedNodes: HashSet
+ ): AccessibilityNodeInfoCompat? {
+ if (node == null) {
+ return null
+ }
+
+ if (visitedNodes.contains(node)) {
+ return null
+ } else {
+ visitedNodes.add(node)
+ }
+
+ val childCount = node.childCount
+ for (i in 0 until childCount) {
+ val child = node.getChild(i) ?: continue
+
+ if (filter.accept(child)) {
+ return child // child was already obtained by node.getChild().
+ }
+
+ if (endFilter != null && endFilter.accept(child)) {
+ continue
+ }
+
+ val childMatch = getMatchingDescendant(child, filter, endFilter, visitedNodes)
+ if (childMatch != null) {
+ return childMatch
+ }
+ }
+
+ return null
+ }
+
+ /**
+ * Returns the first child (by depth-first search) of {@code node} that matches the {@code
+ * filter}, and skips the nodes that match the {@code endFilter}. Returns {@code null} if no nodes
+ * match.
+ */
+ @JvmStatic
+ fun getMatchingDescendant(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter,
+ endFilter: Filter?
+ ): AccessibilityNodeInfoCompat? {
+ return getMatchingDescendant(node, filter, endFilter, HashSet())
+ }
+
+ /**
+ * Returns the first child (by depth-first search) of {@code node} that matches the {@code
+ * filter}. Returns {@code null} if no nodes match.
+ */
+ @JvmStatic
+ fun getMatchingDescendant(
+ node: AccessibilityNodeInfoCompat?, filter: Filter
+ ): AccessibilityNodeInfoCompat? {
+ return getMatchingDescendant(node, filter, /* endFilter= */ null, HashSet())
+ }
+
+ /** Returns all descendants that match filter but skips the nested. */
+ @JvmStatic
+ fun getMatchingDescendantsNotNested(
+ node: AccessibilityNodeInfoCompat?, filter: Filter
+ ): List? {
+ if (node == null) {
+ return null
+ }
+ val matches = ArrayList()
+ getMatchingDescendants(node, filter, /* matchChild= */ false, HashSet(), matches)
+ return matches
+ }
+
+ /** Returns all descendants that match filter. */
+ @JvmStatic
+ fun getMatchingDescendantsOrRoot(
+ node: AccessibilityNodeInfoCompat?, filter: Filter
+ ): List? {
+ if (node == null) {
+ return null
+ }
+ val matches = ArrayList()
+ getMatchingDescendants(node, filter, /* matchChild= */ true, HashSet(), matches)
+ return matches
+ }
+
+ /**
+ * Returns all descendants that match filter, until the stopNode is found. At that point, the
+ * search will stop. Note that the stopNode is included in the results, if it matches the filter.
+ */
+ @JvmStatic
+ fun getMatchingDescendantsOrRootUntilNode(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter,
+ stopNode: AccessibilityNodeInfoCompat?
+ ): List? {
+ if (node == null) {
+ return null
+ }
+ val matches = ArrayList()
+ getMatchingDescendantsCore(
+ node,
+ filter,
+ /* matchChild= */ true,
+ HashSet(),
+ matches,
+ /* stopNode= */ stopNode,
+ /* searchControlFlag= */ SearchControlFlag())
+ return matches
+ }
+
+ /**
+ * 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 matchChild Flag that allows match with the childs of the matched nodes.
+ * @param visitedNodes The set of nodes already visited, for protection against loops. This will
+ * be modified.
+ * @param matches The list of nodes matching filter. This will be appended to.
+ */
+ private fun getMatchingDescendants(
+ node: AccessibilityNodeInfoCompat?,
+ filter: Filter,
+ matchChild: Boolean,
+ visitedNodes: MutableSet